From 3435c4f11ce32c0ea70c80f5577bf0f702c36dc7 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 11:28:32 +0000 Subject: [PATCH 01/35] Implement UV TODO: set it up in CI --- .pre-commit-config.yaml | 2 +- pyproject.toml | 30 +- uv.lock | 758 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 780 insertions(+), 10 deletions(-) create mode 100644 uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb44b21..18701e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: blacken-docs additional_dependencies: [black==23.10.0] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.1.2' + rev: 'v0.7.1' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/pyproject.toml b/pyproject.toml index 77bb6e5..2af4118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] description = "A Python implementation of microkanren extended with constraints" readme = "README.md" -requires-python = ">=3.11,<3.13" +requires-python = ">=3.12,<3.13" classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -27,9 +27,10 @@ license = {file = "LICENSE"} "Bug Tracker" = "https://github.com/jams2/microkanren/issues" [project.optional-dependencies] -dev = ["ruff == 0.1.2"] -testing = ["tox == 4.11.3", "pytest == 7.2.2", "pytest-profiling == 1.7.0"] build = ["hatch == 1.7.0"] +hy = [ + "hy>=1.0.0", +] [build-system] requires = ["hatchling"] @@ -43,6 +44,13 @@ minversion = 7.0 line-length = 88 src = ["src", "tests"] target-version = "py311" + +[tool.ruff.lint] +ignore = [ + "S101", # Use of `assert` + "E501", # line-too-long (conflicts with formatter) + "W191", # tab-indentation (conflicts with formatter) +] select = [ "B", # flake8-bugbear "BLE", # flake8-blind-except @@ -64,11 +72,6 @@ select = [ unfixable = [ "ARG", # unused arguments ] -ignore = [ - "S101", # Use of `assert` - "E501", # line-too-long (conflicts with formatter) - "W191", # tab-indentation (conflicts with formatter) -] [isort] known-first-party = ["microkanren"] @@ -76,5 +79,14 @@ lines-between-types = 1 lines-after-imports = 2 [tool.pyright] -pythonVersion = "3.11" +pythonVersion = "3.12" stubPath = "" + +[tool.uv] +dev-dependencies = [ + "ruff>=0.7.1", + "pyright>=1.1.377", + "tox==4.11.3", + "pytest==7.2.2", + "pytest-profiling==1.7.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7b8fdc1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,758 @@ +version = 1 +requires-python = "==3.12.*" + +[[package]] +name = "anyio" +version = "4.6.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, +] + +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + +[[package]] +name = "cachetools" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "43.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905 }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271 }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606 }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484 }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131 }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647 }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968 }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754 }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458 }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220 }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898 }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "fastcons" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/94/f4947083d04781447a14210def1040d509287383a5fc106a0a29fff0d86c/fastcons-0.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bbd3475bbc1aaee8fab59bab086a418a05853e6ed84c7c9870f545ddfae164e7", size = 10873 }, + { url = "https://files.pythonhosted.org/packages/76/18/5735a6d5a533f1049d3694df62f2284381b1577335b20e9a69ee671ee2a1/fastcons-0.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:634fb6950e7f26d38d7ca31dd020ed9a6ee0e308f5aeab43eb0b5e9d9ecfdf07", size = 33790 }, + { url = "https://files.pythonhosted.org/packages/ae/65/961951bba7fa910e345d6dde319924d26e5b4b7a7e6058cbd1e109bf5f5c/fastcons-0.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbd2c8e462001c7bc545965303b88c9ce693211a00edfea8a0cd17b0f1b6079b", size = 36351 }, + { url = "https://files.pythonhosted.org/packages/42/15/bfcaadd7e6498f1aa515935aca7a0c0ee2f07c8702f34a1cfd0dddce2a4c/fastcons-0.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e83b81be03032f66617f9cfc1c21c38981f8767ced8df9781e1f93ecd31076be", size = 36860 }, + { url = "https://files.pythonhosted.org/packages/c4/47/ffc7868cfc321b086762e75ce175a3eae7a820813c3d3737b04e0fc04f03/fastcons-0.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9209403ade04ea8757faf36d2a5db0db4092b853830627e950d9897ad28e9cf7", size = 38916 }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[package]] +name = "funcparserlib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/44/a21dfd9c45ad6909257e5186378a4fedaf41406824ce1ec06bc2a6c168e7/funcparserlib-1.0.1.tar.gz", hash = "sha256:a2c4a0d7942f7a0e7635c369d921066c8d4cae7f8b5bf7914466bec3c69837f4", size = 17238 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/66/acd740d84f59c655935f586c113a863aa404dfe932052a68a1163d88ea63/funcparserlib-1.0.1-py2.py3-none-any.whl", hash = "sha256:95da15d3f0d00b9b6f4bf04005c708af3faa115f7b45692ace064ebe758c68e8", size = 17842 }, +] + +[[package]] +name = "gprof2dot" +version = "2024.6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/11/16fc5b985741378812223f2c6213b0a95cda333b797def622ac702d28e81/gprof2dot-2024.6.6.tar.gz", hash = "sha256:fa1420c60025a9eb7734f65225b4da02a10fc6dd741b37fa129bc6b41951e5ab", size = 36536 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/27/15c4d20871a86281e2bacde9e9f634225d1c2ed0db072f98acf201022411/gprof2dot-2024.6.6-py2.py3-none-any.whl", hash = "sha256:45b14ad7ce64e299c8f526881007b9eb2c6b75505d5613e96e66ee4d5ab33696", size = 34763 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "hatch" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "hatchling" }, + { name = "httpx" }, + { name = "hyperlink" }, + { name = "keyring" }, + { name = "packaging" }, + { name = "pexpect" }, + { name = "platformdirs" }, + { name = "pyperclip" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomli-w" }, + { name = "tomlkit" }, + { name = "userpath" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/ff/d0dc75f39798af1d3d2258c82c5fdeca2817cbfadba7c41e8fb7cf0db984/hatch-1.7.0.tar.gz", hash = "sha256:7afc701fd5b33684a6650e1ecab8957e19685f824240ba7458dcacd66f90fb46", size = 312052 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/23/60cb647991cfd20101279f9e96a7a020afe06205df984891bcc733c3a2ae/hatch-1.7.0-py3-none-any.whl", hash = "sha256:efc84112fd02ca85b7bab54f5e2ef71393a98dc849eac9aca390504031f8a1a8", size = 90505 }, +] + +[[package]] +name = "hatchling" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/8a4a67a8174ce59cf49e816e38e9502900aea9b4af672d0127df8e10d3b0/hatchling-1.25.0.tar.gz", hash = "sha256:7064631a512610b52250a4d3ff1bd81551d6d1431c4eb7b72e734df6c74f4262", size = 64632 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/8b/90e80904fdc24ce33f6fc6f35ebd2232fe731a8528a22008458cf197bc4d/hatchling-1.25.0-py3-none-any.whl", hash = "sha256:b47948e45d4d973034584dd4cb39c14b6a70227cf287ab7ec0ad7983408a882c", size = 84077 }, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "hy" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "funcparserlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/7f/f8061ae737f6a564c3d4f80287bd5ff46afc1f606617a9cb5c797e6a974a/hy-1.0.0.tar.gz", hash = "sha256:3a00013e075ff5ce8f5d475ca2be47e4c871f09184ba3533787cb544d32d1f9e", size = 121792 } + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "immutables" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/41/0ccaa6ef9943c0609ec5aa663a3b3e681c1712c1007147b84590cec706a0/immutables-0.21.tar.gz", hash = "sha256:b55ffaf0449790242feb4c56ab799ea7af92801a0a43f9e2f4f8af2ab24dfc4a", size = 89008 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/f9/0c46f600702b815182212453f5514c0070ee168b817cdf7c3767554c8489/immutables-0.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1ed262094b755903122c3c3a83ad0e0d5c3ab7887cda12b2fe878769d1ee0d", size = 31885 }, + { url = "https://files.pythonhosted.org/packages/29/34/7608d2eab6179aa47e8f59ab0fbd5b3eeb2333d78c9dc2da0de8de4ed322/immutables-0.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce604f81d9d8f26e60b52ebcb56bb5c0462c8ea50fb17868487d15f048a2f13e", size = 31537 }, + { url = "https://files.pythonhosted.org/packages/f7/52/cb9e2bb7a69338155ffabbd2f993c968c750dd2d5c6c6eaa6ebb7bfcbdfa/immutables-0.21-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b48b116aaca4500398058b5a87814857a60c4cb09417fecc12d7da0f5639b73d", size = 104270 }, + { url = "https://files.pythonhosted.org/packages/0f/a4/25df835a9b9b372a4a869a8a1ac30a32199f2b3f581ad0e249f7e3d19eed/immutables-0.21-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad7c0c74b285cc0e555ec0e97acbdc6f1862fcd16b99abd612df3243732e741", size = 104864 }, + { url = "https://files.pythonhosted.org/packages/4a/51/b548fbc657134d658e179ee8d201ae82d9049aba5c3cb2d858ed2ecb7e3f/immutables-0.21-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e44346e2221a5a676c880ca8e0e6429fa24d1a4ae562573f5c04d7f2e759b030", size = 99733 }, + { url = "https://files.pythonhosted.org/packages/47/db/d7b1e0e88faf07fe9a88579a86f58078a9a37fff871f4b3dbcf28cad9a12/immutables-0.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b10139b529a460e53fe8be699ebd848c54c8a33ebe67763bcfcc809a475a26f", size = 101698 }, + { url = "https://files.pythonhosted.org/packages/69/2d/6fe42a1a053dd8cfb9f45e91d5246522637c7287dc6bd347f67aedf7aedb/immutables-0.21-cp312-cp312-win32.whl", hash = "sha256:fc512d808662614feb17d2d92e98f611d69669a98c7af15910acf1dc72737038", size = 30977 }, + { url = "https://files.pythonhosted.org/packages/63/45/d062aca6971e99454ce3ae42a7430037227fee961644ed1f8b6c9b99e0a5/immutables-0.21-cp312-cp312-win_amd64.whl", hash = "sha256:461dcb0f58a131045155e52a2c43de6ec2fe5ba19bdced6858a3abb63cee5111", size = 35088 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, +] + +[[package]] +name = "jeepney" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/f4/154cf374c2daf2020e05c3c6a03c91348d59b23c5366e968feb198306fdf/jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", size = 106005 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/72/2a1e2290f1ab1e06f71f3d0f1646c9e4634e70e1d37491535e19266e8dc9/jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755", size = 48435 }, +] + +[[package]] +name = "keyring" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/24/64447b13df6a0e2797b586dad715766d756c932ce8ace7f67bd384d76ae0/keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6", size = 62675 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/c9/353c156fa2f057e669106e5d6bcdecf85ef8d3536ce68ca96f18dc7b6d6f/keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741", size = 39096 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "microkanren" +version = "0.4.4" +source = { editable = "." } +dependencies = [ + { name = "fastcons" }, + { name = "immutables" }, + { name = "pyrsistent" }, +] + +[package.optional-dependencies] +build = [ + { name = "hatch" }, +] +hy = [ + { name = "hy" }, +] + +[package.dependency-groups] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-profiling" }, + { name = "ruff" }, + { name = "tox" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastcons", specifier = "==0.4.1" }, + { name = "hatch", marker = "extra == 'build'", specifier = "==1.7.0" }, + { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0" }, + { name = "immutables", specifier = ">=0.19,<1" }, + { name = "pyrsistent", specifier = ">=0.19,<1" }, +] + +[package.metadata.dependency-groups] +dev = [ + { name = "pyright", specifier = ">=1.1.377" }, + { name = "pytest", specifier = "==7.2.2" }, + { name = "pytest-profiling", specifier = "==1.7.0" }, + { name = "ruff", specifier = ">=0.7.1" }, + { name = "tox", specifier = "==4.11.3" }, +] + +[[package]] +name = "more-itertools" +version = "10.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/78/65922308c4248e0eb08ebcbe67c95d48615cc6f27854b6f2e57143e9178f/more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6", size = 121020 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7e/3a64597054a70f7c86eb0a7d4fc315b8c1ab932f64883a297bdffeb5f967/more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef", size = 60952 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pyperclip" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961 } + +[[package]] +name = "pyproject-api" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/19/441e0624a8afedd15bbcce96df1b80479dd0ff0d965f5ce8fde4f2f6ffad/pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496", size = 22340 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/f4/3c4ddfcc0c19c217c6de513842d286de8021af2f2ab79bbb86c00342d778/pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228", size = 13100 }, +] + +[[package]] +name = "pyright" +version = "1.1.386" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/50/1a57054b5585fa72a93a6244c1b4b5639f8f7a1cc60b2e807cc67da8f0bc/pyright-1.1.386.tar.gz", hash = "sha256:8e9975e34948ba5f8e07792a9c9d2bdceb2c6c0b61742b068d2229ca2bc4a9d9", size = 21949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/68/47fd6b3ffa27c99d7e0c866c618f07784b8806712059049daa492ca7e526/pyright-1.1.386-py3-none-any.whl", hash = "sha256:7071ac495593b2258ccdbbf495f1a5c0e5f27951f6b429bed4e8b296eb5cd21d", size = 18577 }, +] + +[[package]] +name = "pyrsistent" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/3a/5031723c09068e9c8c2f0bc25c3a9245f2b1d1aea8396c787a408f2b95ca/pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", size = 103642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/ee/ff2ed52032ac1ce2e7ba19e79bd5b05d152ebfb77956cf08fcd6e8d760ea/pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", size = 83537 }, + { url = "https://files.pythonhosted.org/packages/80/f1/338d0050b24c3132bcfc79b68c3a5f54bce3d213ecef74d37e988b971d8a/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", size = 122615 }, + { url = "https://files.pythonhosted.org/packages/07/3a/e56d6431b713518094fae6ff833a04a6f49ad0fbe25fb7c0dc7408e19d20/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", size = 122335 }, + { url = "https://files.pythonhosted.org/packages/4a/bb/5f40a4d5e985a43b43f607250e766cdec28904682c3505eb0bd343a4b7db/pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", size = 118510 }, + { url = "https://files.pythonhosted.org/packages/1c/13/e6a22f40f5800af116c02c28e29f15c06aa41cb2036f6a64ab124647f28b/pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", size = 60865 }, + { url = "https://files.pythonhosted.org/packages/75/ef/2fa3b55023ec07c22682c957808f9a41836da4cd006b5f55ec76bf0fbfa6/pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", size = 63239 }, + { url = "https://files.pythonhosted.org/packages/23/88/0acd180010aaed4987c85700b7cc17f9505f3edb4e5873e4dc67f613e338/pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", size = 58106 }, +] + +[[package]] +name = "pytest" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/29/311895d9cd3f003dd58e8fdea36dd895ba2da5c0c90601836f7de79f76fe/pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4", size = 1320028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/68/5321b5793bd506961bd40bdbdd0674e7de4fb873ee7cab33dd27283ad513/pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", size = 317207 }, +] + +[[package]] +name = "pytest-profiling" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gprof2dot" }, + { name = "pytest" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/70/22a4b33739f07f1732a63e33bbfbf68e0fa58cfba9d200e76d01921eddbf/pytest-profiling-1.7.0.tar.gz", hash = "sha256:93938f147662225d2b8bd5af89587b979652426a8a6ffd7e73ec4a23e24b7f29", size = 30985 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/71/cdb746eaee0d3be65fd777b4ac821f5f051063f3084d4a200ecfd7f7ab40/pytest_profiling-1.7.0-py2.py3-none-any.whl", hash = "sha256:999cc9ac94f2e528e3f5d43465da277429984a1c237ae9818f8cfd0b06acb019", size = 8255 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "rich" +version = "13.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e9/cf9ef5245d835065e6673781dbd4b8911d352fb770d56cf0879cf11b7ee1/rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e", size = 222889 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/e2/10e9819cf4a20bd8ea2f5dabafc2e6bf4a78d6a0965daeb60a4b34d1c11f/rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283", size = 242157 }, +] + +[[package]] +name = "ruff" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/21/5c6e05e0fd3fbb41be4fb92edbc9a04de70baf60adb61435ce0c6b8c3d55/ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4", size = 3181670 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/45/8a20a9920175c9c4892b2420f80ff3cf14949cf3067118e212f9acd9c908/ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89", size = 10389268 }, + { url = "https://files.pythonhosted.org/packages/1b/d3/2f8382db2cf4f9488e938602e33e36287f9d26cb283aa31f11c31297ce79/ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35", size = 10188348 }, + { url = "https://files.pythonhosted.org/packages/a2/31/7d14e2a88da351200f844b7be889a0845d9e797162cf76b136d21b832a23/ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99", size = 9841448 }, + { url = "https://files.pythonhosted.org/packages/db/99/738cafdc768eceeca0bd26c6f03e213aa91203d2278e1d95b1c31c4ece41/ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca", size = 10674864 }, + { url = "https://files.pythonhosted.org/packages/fe/12/bcf2836b50eab53c65008383e7d55201e490d75167c474f14a16e1af47d2/ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250", size = 10192105 }, + { url = "https://files.pythonhosted.org/packages/2b/71/261d5d668bf98b6c44e89bfb5dfa4cb8cb6c8b490a201a3d8030e136ea4f/ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c", size = 11194144 }, + { url = "https://files.pythonhosted.org/packages/90/1f/0926d18a3b566fa6e7b3b36093088e4ffef6b6ba4ea85a462d9a93f7e35c/ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565", size = 11917066 }, + { url = "https://files.pythonhosted.org/packages/cd/a8/9fac41f128b6a44ab4409c1493430b4ee4b11521e8aeeca19bfe1ce851f9/ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7", size = 11458821 }, + { url = "https://files.pythonhosted.org/packages/25/cd/59644168f086ab13fe4e02943b9489a0aa710171f66b178e179df5383554/ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a", size = 12700379 }, + { url = "https://files.pythonhosted.org/packages/fb/30/3bac63619eb97174661829c07fc46b2055a053dee72da29d7c304c1cd2c0/ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad", size = 11019813 }, + { url = "https://files.pythonhosted.org/packages/4b/af/f567b885b5cb3bcdbcca3458ebf210cc8c9c7a9f61c332d3c2a050c3b21e/ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112", size = 10662146 }, + { url = "https://files.pythonhosted.org/packages/bc/ad/eb930d3ad117a9f2f7261969c21559ebd82bb13b6e8001c7caed0d44be5f/ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378", size = 10256911 }, + { url = "https://files.pythonhosted.org/packages/20/d5/af292ce70a016fcec792105ca67f768b403dd480a11888bc1f418fed0dd5/ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8", size = 10767488 }, + { url = "https://files.pythonhosted.org/packages/24/85/cc04a3bd027f433bebd2a097e63b3167653c079f7f13d8f9a1178e693412/ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd", size = 11093368 }, + { url = "https://files.pythonhosted.org/packages/0b/fb/c39cbf32d1f3e318674b8622f989417231794926b573f76dd4d0ca49f0f1/ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9", size = 8594180 }, + { url = "https://files.pythonhosted.org/packages/5a/71/ec8cdea34ecb90c830ca60d54ac7b509a7b5eab50fae27e001d4470fe813/ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307", size = 9419751 }, + { url = "https://files.pythonhosted.org/packages/79/7b/884553415e9f0a9bf358ed52fb68b934e67ef6c5a62397ace924a1afdf9a/ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37", size = 8717402 }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "tomli-w" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440 }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, +] + +[[package]] +name = "tox" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/48/7744b1a3ebf2ae5e3bf6a23e8f9775476ae770061fa912db1c966a720fbf/tox-4.11.3.tar.gz", hash = "sha256:5039f68276461fae6a9452a3b2c7295798f00a0e92edcd9a3b78ba1a73577951", size = 175528 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/f9/963052e8b825645c54262dce7b7c88691505e3b9ee10a3e3667711eaaf21/tox-4.11.3-py3-none-any.whl", hash = "sha256:599af5e5bb0cad0148ac1558a0b66f8fff219ef88363483b8d92a81e4246f28f", size = 153836 }, +] + +[[package]] +name = "trove-classifiers" +version = "2024.10.21.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/85/92c2667cf221b37648041ce9319427f92fa76cbec634aad844e67e284706/trove_classifiers-2024.10.21.16.tar.gz", hash = "sha256:17cbd055d67d5e9d9de63293a8732943fabc21574e4c7b74edf112b4928cf5f3", size = 16153 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/35/5055ab8d215af853d07bbff1a74edf48f91ed308f037380a5ca52dd73348/trove_classifiers-2024.10.21.16-py3-none-any.whl", hash = "sha256:0fb11f1e995a757807a8ef1c03829fbd4998d817319abcef1f33165750f103be", size = 13546 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "userpath" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065 }, +] + +[[package]] +name = "virtualenv" +version = "20.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/7f/192dd6ab6d91ebea7adf6c030eaf549b1ec0badda9f67a77b633602f66ac/virtualenv-20.27.0.tar.gz", hash = "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2", size = 6483858 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/15/828ec11907aee2349a9342fa71fba4ba7f3af938162a382dd7da339dea16/virtualenv-20.27.0-py3-none-any.whl", hash = "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655", size = 3110969 }, +] From 1843f4468978fb51319397be3d5ec9b185525f0a Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 11:30:58 +0000 Subject: [PATCH 02/35] Format core --- src/microkanren/core.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/microkanren/core.py b/src/microkanren/core.py index d924464..3c61f0a 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -1,3 +1,4 @@ +from collections import namedtuple from collections.abc import Callable, Generator from dataclasses import dataclass from functools import partial, reduce, wraps @@ -12,15 +13,10 @@ NOT_FOUND = object() -@dataclass(slots=True, frozen=True) -class Var: - i: int - +class Var(namedtuple("Var", ("i",))): ... -@dataclass(slots=True, frozen=True) -class ReifiedVar: - i: int +class ReifiedVar(namedtuple("ReifiedVar", ("i",))): def __repr__(self): return f"_.{self.i}" @@ -67,8 +63,7 @@ def get_domain(self, x: Var) -> set[int] | None: class ConstraintProto(Protocol): - def __call__(self, *args: Any) -> ConstraintFunction: - ... + def __call__(self, *args: Any) -> ConstraintFunction: ... @dataclass(slots=True, frozen=True) @@ -84,13 +79,11 @@ def __call__(self, state: State) -> State | None: class GoalProto(Protocol): - def __call__(self, state: State) -> Stream: - ... + def __call__(self, state: State) -> Stream: ... class GoalConstructorProto(Protocol): - def __call__(self, *args: Value) -> GoalProto: - ... + def __call__(self, *args: Value) -> GoalProto: ... class Goal: @@ -200,7 +193,7 @@ def unify(u: Value, v: Value, s: Substitution) -> Substitution | None: s1 = unify(x, y, s) return unify(xs, ys, s1) if s1 is not None else None case (_, *_) as xs, (_, *_) as ys if ( - len(xs) == len(ys) and type(xs) == type(ys) + len(xs) == len(ys) and type(xs) is type(ys) ): for x, y in zip(xs, ys): # NOQA: B905 s = unify(x, y, s) @@ -619,12 +612,12 @@ def __getattribute__(cls, attr): class Hooks(metaclass=HooksMeta): - process_prefix: Callable[ - [Substitution, ConstraintStore], ConstraintFunction - ] = default_process_prefix + process_prefix: Callable[[Substitution, ConstraintStore], ConstraintFunction] = ( + default_process_prefix + ) enforce_constraints: Callable[[Var], GoalProto] = default_enforce_constraints - reify_constraints: Callable[ - [Value, Substitution, State], Any - ] = default_reify_constraints + reify_constraints: Callable[[Value, Substitution, State], Any] = ( + default_reify_constraints + ) reify_var: Callable[[Var, Substitution], Any] = default_reify_var reify_value: Callable[[Any], Any] = default_reify_value From 302acee2e061fcfd1a8278d377858b3c91fe8a85 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 11:31:09 +0000 Subject: [PATCH 03/35] Add new core and hy interface --- src/microkanren/core_2.py | 510 ++++++++++++++++++++++++++++++++++++++ src/microkanren/hy.hy | 38 +++ 2 files changed, 548 insertions(+) create mode 100644 src/microkanren/core_2.py create mode 100644 src/microkanren/hy.hy diff --git a/src/microkanren/core_2.py b/src/microkanren/core_2.py new file mode 100644 index 0000000..9a37211 --- /dev/null +++ b/src/microkanren/core_2.py @@ -0,0 +1,510 @@ +from collections.abc import Callable +from functools import reduce +from typing import ( + Any, + ClassVar, + NamedTuple, + Self, + SupportsIndex, + cast, + overload, +) + +import immutables +from fastcons import cons + + +class OccursError(Exception): ... + + +class Sentinel: ... + + +SENTINEL = Sentinel() + + +class Var: + i: int + _cache: ClassVar[dict[int, Self] | None] = None + __match_args__ = ("i",) + + def __new__(cls, i) -> Self: + if (cache := cls._cache) is None: + cache = {} + cls._cache = cache + + if i in cache: + return cache[i] + + instance = super().__new__(cls) + cache[i] = instance + return instance + + def __init__(self, i: int): + self.i = i + + def __repr__(self) -> str: + return f"Var({self.i})" + + def __hash__(self): + return hash((self.__class__, self.i)) + + def __eq__(self, other): + return other is self + + +class Symbol: + name: str + _cache: ClassVar[dict[str, Self] | None] = None + + def __new__(cls, name: str) -> Self: + if (cache := cls._cache) is None: + cache = {} + cls._cache = cache + + if name in cache: + return cache[name] + + instance = super().__new__(cls) + cache[name] = instance + return instance + + def __init__(self, name: str): + self.name = name + + def __repr__(self) -> str: + return str(self) + + def __str__(self) -> str: + return self.name + + def __hash__(self): + return hash((self.__class__, self.name)) + + def __eq__(self, other): + return other is self + + +type Substitution = immutables.Map[Var, Any] + + +def empty_sub() -> Substitution: + return immutables.Map() + + +class State(NamedTuple): + counter: int + sub: Substitution + + +type TableCache = list + + +class SuspendedStream(NamedTuple): + cache: TableCache + + # A suffix of the tabled goal's cached answers. Indicates which of the + # primary call's answer terms this stream has already processed. + answer_terms: list + + # Produces the remainder of the stream. + thunk: Callable[[], "Stream"] + + +def ready_stream(ss: SuspendedStream) -> bool: + """ + Does the cache contain new answer terms not yet consumed by the stream? + """ + return ss.cache != ss.answer_terms + + +class WaitingStream(list[SuspendedStream]): + @overload + def __getitem__(self, key: SupportsIndex) -> SuspendedStream: ... + + @overload + def __getitem__(self, key: slice) -> Self: ... + + def __getitem__(self, key): + if isinstance(key, slice): + return self.__class__(super().__getitem__(key)) + else: + return super().__getitem__(key) + + def __add__(self, value) -> "WaitingStream": + if not isinstance(value, self.__class__): + raise TypeError( + "WaitingStream can only be concatenated with another WaitingStream instance" + ) + return WaitingStream([*self, *value]) + + def __repr__(self) -> str: + return f"WaitingStream({super().__repr__()})" + + def __str__(self) -> str: + return f"WaitingStream({super().__str__()})" + + +type EmptyStream = tuple[()] +type ReadyStream = tuple[State, "Stream"] +type ThunkStream = Callable[[], "Stream"] +type Stream = EmptyStream | ReadyStream | ThunkStream | WaitingStream +type Goal = Callable[[State], Stream] +type GoalConstructor = Callable[..., Goal] + + +def empty(stream: Stream): + return stream == () + + +def empty_state(): + return State(0, empty_sub()) + + +def walk(candidate: Any, sub: Substitution) -> Any: + while isinstance(candidate, Var): + result = sub.get(candidate, SENTINEL) + if result is SENTINEL: + return candidate + candidate = result + return candidate + + +def deep_walk(candidate: Any, sub: Substitution) -> Any: + """ + Like `walk', but reify elements of lists/tuples. + """ + candidate = walk(candidate, sub) + if isinstance(candidate, list | tuple): + container = type(candidate) + return container(deep_walk(x, sub) for x in candidate) + else: + return candidate + + +def extend_substitution( + x: Var, v: Any, sub: Substitution, occurs_check: bool = True +) -> Substitution: + if occurs_check and occurs(x, v, sub): + raise OccursError(f"occurs_check failed ({x=}, {v=}, {sub=})") + return sub.set(x, v) + + +def occurs(x: Var, v: Any, sub: Substitution) -> bool: + v = walk(v, sub) + if isinstance(v, Var): + return v == x + elif isinstance(v, list | tuple): + return any(occurs(x, term, sub) for term in v) + else: + return False + + +def unit(state: State) -> ReadyStream: + return (state, mzero()) + + +def succeed(state: State) -> ReadyStream: + return unit(state) + + +def mzero() -> EmptyStream: + return () + + +def eq(u: Any, v: Any) -> Goal: + def _eq(state: State) -> EmptyStream | ReadyStream: + maybe_sub: Substitution | Sentinel = unify(u, v, state.sub) + if isinstance(maybe_sub, Sentinel): + return mzero() + return unit(State(state.counter, maybe_sub)) + + return _eq + + +def unify(u: Any, v: Any, s: Substitution) -> Substitution | Sentinel: + """ + Unify u and v in the Substitution s. + """ + match walk(u, s), walk(v, s): + case Var(_) as x, Var(_) as y if x is y: + return s + case Var(_) as x, y: + return extend_substitution(x, y, s) + case x, Var(_) as y: + return extend_substitution(y, x, s) + case cons(x, xs), cons(y, ys): + s1 = unify(x, y, s) + return SENTINEL if isinstance(s1, Sentinel) else unify(xs, ys, s1) + case (x, *xs) as m, (y, *ys) as n if len(m) == len(n) and type(m) is type(n): + result = unify(x, y, s) + if isinstance(result, Sentinel): + return SENTINEL + return unify(xs, ys, result) + case x, y if x == y: + return s + case _: + return SENTINEL + + +def call_fresh(f: Callable[[Var], Goal]) -> Goal: + def _goal(state: State) -> Stream: + i, sub = state + return f(Var(i))(State(i + 1, sub)) + + return _goal + + +def bind(stream: Stream, g: Goal) -> Stream: + if empty(stream): + return mzero() + elif callable(stream): + return lambda: bind(stream(), g) + elif isinstance(stream, WaitingStream): + return w_check( + stream, + lambda x: lambda: bind(x(), g), + lambda: WaitingStream( + SuspendedStream( + x.cache, + x.answer_terms, + lambda x=x: bind(x.thunk(), g), + ) + for x in stream + ), + ) + else: + head, tail = cast(tuple[State, Stream], stream) + return mplus(g(head), bind(tail, g)) + + +def mplus(left: Stream, right: Stream) -> Stream: + if empty(left): + return right + elif callable(left): + return lambda: mplus(right, left()) + elif isinstance(left, WaitingStream): + return w_check( + left, + lambda x: lambda: mplus(right, x), + lambda: right + left + if isinstance(right, WaitingStream) + else mplus(right, lambda: left), + ) + else: + head, tail = cast(tuple[State, Stream], left) + return (head, mplus(tail, right)) + + +def disj(g1: Goal, g2: Goal) -> Goal: + def _disj(state: State): + return mplus(g1(state), g2(state)) + + return _disj + + +def conj(g1: Goal, g2: Goal) -> Goal: + def _conj(state: State): + return bind(g1(state), g2) + + return _conj + + +### Reification + + +def pull(x): + while callable(x): + x = x() + return x + + +def raise_(exc): + raise exc + + +def take(n: int, stream: Stream): + if n == 0: + return [] + s = pull(stream) + if empty(s): + return [] + elif isinstance(s, WaitingStream): + return take( + n, + w_check( + s, + lambda x: x, + lambda: mzero(), + ), + ) + else: + a, d = cast(ReadyStream, s) + return [a, *take(n - 1, d)] + + +def reify_symbol(i: int) -> Symbol: + return Symbol(f"_.{i}") + + +def make_reify(representation): + def reify(v, s): + v = deep_walk(v, s) + return deep_walk(v, reify_sub(representation, v, empty_sub())) + + return reify + + +def reify_sub(representation: Callable, v: Any, sub: Substitution) -> Substitution: + v = walk(v, sub) + if isinstance(v, Var): + return extend_substitution(v, representation(len(sub)), sub, occurs_check=False) + elif isinstance(v, list | tuple): + return reduce(lambda s, x: reify_sub(representation, x, s), v, sub) + else: + return sub + + +# Reify unbound logic variables as Symbols +reify = make_reify(reify_symbol) + +# Reify unbound logic variables as fresh logic variables +reify_var = make_reify(Var) + +# Reify as like reify_var, but transform i with f(i) = -(1+i). This +# prevents circular mappings (e.g. {Var(0) → Var(0)}). +reify_tabled_var = make_reify(lambda i: Var(-(1 + i))) + + +### Tabling + + +class Table(dict[tuple, TableCache]): ... + + +def tabled(gc: GoalConstructor) -> GoalConstructor: + table = Table() + + def tabled_gc(*args): + def tabled_goal(state: State) -> Stream: + # Reify `args' in the current substitution, use this as + # the cache key for a master call. + key = reify(args, state.sub) + if key not in table: + table[key] = [] + return conj( + gc(*args), + primary_tabled_call(args, table[key]), + )(state) + + # reuse_tabled_results gets a pointer to the cache, not a + # copy, so the suspended streams have access to newly + # cached results. + return reuse_tabled_results(args, table[key], state) + + return tabled_goal + + return tabled_gc + + +def primary_tabled_call(args: tuple, cache: TableCache) -> Goal: + def _goal(state: State) -> Stream: + _, sub = state + reified_args = reify(args, sub) + + # Check if the result is alpha-equivalent to any previous cached result. + if any(reified_args == reify(result, sub) for result in cache): + # If so, contribute no state. + return mzero() + + # Otherwise, we have a new state to cache and contribute to the result. + cache.append(reify_tabled_var(args, sub)) + return unit(state) + + return _goal + + +def alpha_equivalent(x: Any, y: Any, s: Substitution) -> bool: + return reify(x, s) == reify(y, s) + + +def reuse_tabled_results(args: tuple, cache: TableCache, state: State) -> Stream: + # Fix is called at the beginning of the reuse call, with the whole cache. + # Fix is called as a SS's thunk in w_check, in the success continuation. + def fix(start, end) -> Stream: + def loop(cached_results): + if cached_results == end: + # This will run on the first iteration of `loop'. + return WaitingStream( + # TODO: Should `cache' be a copy of the tabled + # cache, or a pointer to it? + [SuspendedStream(cache, start, lambda: fix(cache, start))] + ) + else: + head, *tail = cached_results + counter, sub = state + + # Produce a new state that is the result of unifying + # the secondary call's args with the first cached + # result. + next_state = State( + counter, + subunify(args, reify_tabled_var(head, sub), sub), + ) + + # Concat the new state with the result of unifying the + # secondary call's args with the rest of the cached + # results. + return mplus( + unit(next_state), + lambda: loop(tail), + ) + + return loop(start) + + return fix(cache, []) + + +def subunify(args, cached_result, sub: Substitution) -> Substitution: + args = walk(args, sub) + if args == cached_result: + return sub + elif isinstance(args, Var): + return extend_substitution(args, cached_result, sub, occurs_check=False) + elif isinstance(args, list | tuple): + a, *d = args + b, *e = cached_result + return subunify(d, e, subunify(a, b, sub)) + else: + return sub + + +def w_check( + w: WaitingStream, sk: Callable[[Any], Stream], fk: Callable[[], Stream] +) -> Stream: + # Find the first suspended stream in `w' whose cache contains new + # answer terms. + + def loop(w: WaitingStream, a: WaitingStream) -> Stream: + if not w: + return fk() + elif ready_stream(w[0]): + # The first suspended is can contribute results. Invoke + # its thunk, followed by any remaining suspended streams. + head, *tail = w + _, _, thunk = head + rest_suspended_streams = WaitingStream(a[::-1] + tail) + return sk( + lambda: thunk() + if not w + else mplus(thunk(), lambda: rest_suspended_streams) + ) + else: + return loop(w[1:], WaitingStream([w[0], *a])) + + return loop(w, WaitingStream()) + + +@tabled +def fives(x): + return disj(eq(x, 5), lambda sc: lambda: fives(x)(sc)) diff --git a/src/microkanren/hy.hy b/src/microkanren/hy.hy new file mode 100644 index 0000000..520c36b --- /dev/null +++ b/src/microkanren/hy.hy @@ -0,0 +1,38 @@ +(import mk-tabling [core]) + +(setv call/fresh core.call-fresh) +(setv == core.eq) + +(defmacro Zzz [g] + `(fn [s/c] (fn [] (~g s/c)))) + +(defmacro conj+ [goal #* goals] + (if (not goals) + `(Zzz ~goal) + `(core.conj (Zzz ~goal) (conj+ ~@goals)))) + +(defmacro disj+ [goal #* goals] + (if (not goals) + `(Zzz ~goal) + `(core.disj (Zzz ~goal) (disj+ ~@goals)))) + +(defmacro conde [#* goals] + (match goals + [] 'core.fail + [gs] `(conj+ ~@gs) + [gs #* gs^] `(disj+ (conj+ ~@gs) ~@(map (fn [xs] `(conj+ ~@xs)) gs^)))) + +(defmacro fresh [lvars #* goals] + (match lvars + [] `(conj+ ~@goals) + [v #* vs] `(call/fresh (fn [~v] (fresh ~vs ~@goals))))) + +(defmacro exist [lvars #* goals] + `(fresh ~lvars (&& #* goals))) + +(defmacro run [n lvars #* goals] + `(lfor state (core.take ~n ((fresh ~lvars ~@goals)(core.empty-state))) + (core.reify (tuple (gfor i (range ~(len lvars)) (core.Var i))) state.sub))) + +(defmacro run* [lvars #* goals] + (hy.macroexpand `(run -1 ~lvars ~@goals))) From bf08ccc6568d286e29a004cfcbece5dc7517d6d8 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 17:37:44 +0000 Subject: [PATCH 04/35] Replace old core with new, remove fd and goals --- src/microkanren/core.py | 881 +++++++++++++++++--------------------- src/microkanren/core_2.py | 510 ---------------------- src/microkanren/fd.py | 347 --------------- src/microkanren/goals.py | 191 --------- src/microkanren/utils.py | 23 - tests/test_fd.py | 267 ------------ 6 files changed, 384 insertions(+), 1835 deletions(-) delete mode 100644 src/microkanren/core_2.py delete mode 100644 src/microkanren/fd.py delete mode 100644 src/microkanren/goals.py delete mode 100644 src/microkanren/utils.py delete mode 100644 tests/test_fd.py diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 3c61f0a..9a37211 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -1,623 +1,510 @@ -from collections import namedtuple -from collections.abc import Callable, Generator -from dataclasses import dataclass -from functools import partial, reduce, wraps -from typing import Any, Optional, Protocol, TypeAlias, TypeVar - -import immutables -from fastcons import cons, nil -from pyrsistent import PClass, field - -from microkanren.utils import foldr, identity - -NOT_FOUND = object() - - -class Var(namedtuple("Var", ("i",))): ... - - -class ReifiedVar(namedtuple("ReifiedVar", ("i",))): - def __repr__(self): - return f"_.{self.i}" - - -Value: TypeAlias = ( - Var | int | str | bool | tuple["Value", ...] | list["Value"] | cons | nil +from collections.abc import Callable +from functools import reduce +from typing import ( + Any, + ClassVar, + NamedTuple, + Self, + SupportsIndex, + cast, + overload, ) -Substitution: TypeAlias = immutables.Map[Var, Value] -NeqStore: TypeAlias = list[list[tuple[Var, Value]]] -DomainStore: TypeAlias = immutables.Map[Var, set[int]] -ConstraintFunction: TypeAlias = Callable[["State"], Optional["State"]] -ConstraintStore: TypeAlias = list["Constraint"] - - -def empty_sub() -> Substitution: - return immutables.Map() - - -def empty_domain_store() -> DomainStore: - return immutables.Map() - - -def empty_constraint_store() -> ConstraintStore: - return [] - - -class State(PClass): - sub = field(mandatory=True) - domains = field(mandatory=True) - constraints = field(mandatory=True) - var_count = field(mandatory=True) - - @staticmethod - def empty(): - return State( - sub=empty_sub(), - domains=empty_domain_store(), - constraints=empty_constraint_store(), - var_count=0, - ) - - def get_domain(self, x: Var) -> set[int] | None: - return self.domains.get(x, None) - -class ConstraintProto(Protocol): - def __call__(self, *args: Any) -> ConstraintFunction: ... - - -@dataclass(slots=True, frozen=True) -class Constraint: - func: ConstraintProto - operands: tuple[Value] - - def __call__(self, state: State) -> State | None: - return self.func(*self.operands)(state) - - -Stream: TypeAlias = tuple[()] | Callable[[], "Stream"] | tuple[State, "Stream"] - - -class GoalProto(Protocol): - def __call__(self, state: State) -> Stream: ... - - -class GoalConstructorProto(Protocol): - def __call__(self, *args: Value) -> GoalProto: ... - - -class Goal: - def __init__(self, goal: GoalProto): - self.goal = goal - - def __call__(self, state: State) -> Stream: - # Inverse η-delay happens here - return lambda: self.goal(state) - - def __or__(self, other): - return disj(self, other) - - def __and__(self, other): - return conj(self, other) - - -def goal_from_constraint(constraint: ConstraintFunction) -> GoalProto: - def _goal(state: State) -> Stream: - match constraint(state): - case None: - return mzero - case _ as next_state: - return unit(next_state) - - return Goal(_goal) - - -class InvalidStream(Exception): - pass +import immutables +from fastcons import cons -mzero = () +class OccursError(Exception): ... -def unit(state: State) -> Stream: - return (state, mzero) +class Sentinel: ... -def mplus(s1: Stream, s2: Stream) -> Stream: - match s1: - case (): - return lambda: s2 - case f if callable(f): - return lambda: mplus(s2, f()) - case (head, tail): - return lambda: (head, mplus(s2, tail)) - case _: - raise InvalidStream +SENTINEL = Sentinel() -def bind(stream: Stream, g: GoalProto) -> Stream: - match stream: - case (): - return mzero - case f if callable(f): - return lambda: bind(f(), g) - case (s1, s2): - return lambda: mplus(g(s1), bind(s2, g)) - case _: - raise InvalidStream +class Var: + i: int + _cache: ClassVar[dict[int, Self] | None] = None + __match_args__ = ("i",) + def __new__(cls, i) -> Self: + if (cache := cls._cache) is None: + cache = {} + cls._cache = cache -def walk(u: Value, s: Substitution) -> Value: - if isinstance(u, Var): - bound = s.get(u, NOT_FOUND) - if bound is NOT_FOUND: - return u - return walk(bound, s) - return u + if i in cache: + return cache[i] + instance = super().__new__(cls) + cache[i] = instance + return instance -def extend_substitution(x: Var, v: Value, s: Substitution) -> Substitution: - return s.set(x, v) + def __init__(self, i: int): + self.i = i + def __repr__(self) -> str: + return f"Var({self.i})" -def extend_domain_store(x: Var, fd: set[int], d: DomainStore) -> DomainStore: - return d.set(x, fd) + def __hash__(self): + return hash((self.__class__, self.i)) + def __eq__(self, other): + return other is self -def extend_constraint_store( - constraint: Constraint, c: ConstraintStore -) -> ConstraintStore: - return [constraint, *c] +class Symbol: + name: str + _cache: ClassVar[dict[str, Self] | None] = None -def occurs(x, v, s): - v = walk(v, s) - match v: - case Var(_) as var: - return var == x - case (a, d) | cons(a, d): - return occurs(x, a, s) or occurs(x, d, s) - case _: - return False + def __new__(cls, name: str) -> Self: + if (cache := cls._cache) is None: + cache = {} + cls._cache = cache + if name in cache: + return cache[name] -def unify(u: Value, v: Value, s: Substitution) -> Substitution | None: - match walk(u, s), walk(v, s): - case (Var(_) as vi, Var(_) as vj) if vi == vj: - return s - case (Var(_) as var, val): - return extend_substitution(var, val, s) - case (val, Var(_) as var): - return extend_substitution(var, val, s) - case cons(x, xs), cons(y, ys): - s1 = unify(x, y, s) - return unify(xs, ys, s1) if s1 is not None else None - case (_, *_) as xs, (_, *_) as ys if ( - len(xs) == len(ys) and type(xs) is type(ys) - ): - for x, y in zip(xs, ys): # NOQA: B905 - s = unify(x, y, s) - if s is None: - return None - return s - case x, y if x == y: - return s - case _: - return None + instance = super().__new__(cls) + cache[name] = instance + return instance + def __init__(self, name: str): + self.name = name -def succeed() -> GoalProto: - def _succeed(state): - return eq(True, True)(state) + def __repr__(self) -> str: + return str(self) - return Goal(_succeed) + def __str__(self) -> str: + return self.name + def __hash__(self): + return hash((self.__class__, self.name)) -def fail() -> GoalProto: - def _fail(state): - return eq(False, True)(state) + def __eq__(self, other): + return other is self - return Goal(_fail) +type Substitution = immutables.Map[Var, Any] -def snooze(g: GoalConstructorProto, *args: Value) -> GoalProto: - def delayed_goal(state) -> Stream: - return g(*args)(state) - return Goal(delayed_goal) +def empty_sub() -> Substitution: + return immutables.Map() -def delay(g: GoalProto) -> GoalProto: - return Goal(lambda state: g(state)) +class State(NamedTuple): + counter: int + sub: Substitution -def disj(*goals: GoalProto) -> GoalProto: - return foldr(_disj, fail(), goals) +type TableCache = list -def _disj(g1: GoalProto, g2: GoalProto) -> GoalProto: - def __disj(state: State) -> Stream: - return mplus(g1(state), g2(state)) +class SuspendedStream(NamedTuple): + cache: TableCache - return Goal(__disj) + # A suffix of the tabled goal's cached answers. Indicates which of the + # primary call's answer terms this stream has already processed. + answer_terms: list + # Produces the remainder of the stream. + thunk: Callable[[], "Stream"] -def conj(*goals: GoalProto) -> GoalProto: - return foldr(_conj, succeed(), goals) +def ready_stream(ss: SuspendedStream) -> bool: + """ + Does the cache contain new answer terms not yet consumed by the stream? + """ + return ss.cache != ss.answer_terms -def _conj(g1: GoalProto, g2: GoalProto) -> GoalProto: - def __conj(state: State) -> Stream: - return bind(g1(state), g2) - return Goal(__conj) +class WaitingStream(list[SuspendedStream]): + @overload + def __getitem__(self, key: SupportsIndex) -> SuspendedStream: ... + @overload + def __getitem__(self, key: slice) -> Self: ... -def eq(u: Value, v: Value) -> GoalProto: - def _eqc(state: State) -> State | None: - new_sub = unify(u, v, state.sub) - if new_sub is None: - # Unification failed - return None - elif new_sub == state.sub: - # Unification succeeded without new associations - return state + def __getitem__(self, key): + if isinstance(key, slice): + return self.__class__(super().__getitem__(key)) else: - prefix = get_sub_prefix(new_sub, state.sub) - return Hooks.process_prefix(prefix, state.constraints)( - state.set(sub=new_sub) - ) - - return goal_from_constraint(_eqc) + return super().__getitem__(key) - -def unify_all( - constraint: list[tuple[Var, Value]], sub: Substitution -) -> Substitution | None: - match constraint: - case []: - return sub - case [(a, d), *rest]: - s = unify(a, d, sub) - if s is not None: - return unify_all(rest, s) - return None - case _: - return None - - -def pairs(xs): - _xs = iter(xs) - while True: - try: - a = next(_xs) - except StopIteration: - break - try: - b = next(_xs) - yield (a, b) - except StopIteration as err: - raise ValueError("got sequence with uneven length") from err - - -def unpairs(xs): - for a, b in xs: - yield a - yield b - - -def maybe_unify( - pair: tuple[Value, Value], sub: Substitution | None -) -> Substitution | None: - if sub is None: - return None - u, v = pair - return unify(u, v, sub) - - -def flip(f): - @wraps(f) - def _flipped(x, y): - return f(y, x) - - return _flipped - - -def neq(u, v, /, *rest) -> GoalProto: - return goal_from_constraint(neqc(u, v, *rest)) - - -def neqc(u, v, *rest) -> ConstraintFunction: - def _neqc(state: State) -> State | None: - new_sub = reduce(flip(maybe_unify), pairs(rest), unify(u, v, state.sub)) - if new_sub is None: - return state - elif new_sub == state.sub: - return None - prefix = get_sub_prefix(new_sub, state.sub) - remaining_pairs = tuple(prefix.items()) - return state.set( - constraints=extend_constraint_store( - Constraint(neqc, tuple(unpairs(remaining_pairs))), state.constraints + def __add__(self, value) -> "WaitingStream": + if not isinstance(value, self.__class__): + raise TypeError( + "WaitingStream can only be concatenated with another WaitingStream instance" ) - ) + return WaitingStream([*self, *value]) - return _neqc + def __repr__(self) -> str: + return f"WaitingStream({super().__repr__()})" + def __str__(self) -> str: + return f"WaitingStream({super().__str__()})" -def any_relevant_vars(operands, values): - match operands: - case Var(_) as v: - return v in values - case [first, *rest]: - return any_relevant_vars(first, values) or any_relevant_vars(rest, values) - case _: - return False +type EmptyStream = tuple[()] +type ReadyStream = tuple[State, "Stream"] +type ThunkStream = Callable[[], "Stream"] +type Stream = EmptyStream | ReadyStream | ThunkStream | WaitingStream +type Goal = Callable[[State], Stream] +type GoalConstructor = Callable[..., Goal] -def bind_constraints(f, g) -> ConstraintFunction: - def _bind_constraints(state: State) -> State | None: - maybe_state = f(state) - return g(maybe_state) if maybe_state is not None else None - return _bind_constraints +def empty(stream: Stream): + return stream == () -def compose_constraints(f, g) -> ConstraintFunction: - return bind_constraints(f, g) +def empty_state(): + return State(0, empty_sub()) -def run_constraints( - xs: list[Var] | set[Var], constraints: ConstraintStore -) -> ConstraintFunction: - match constraints: - case []: - return identity - case [first, *rest]: - if any_relevant_vars(first.operands, xs): - return compose_constraints( - remove_and_run(first), - run_constraints(xs, rest), - ) - else: - return run_constraints(xs, rest) - case _: - raise ValueError("Invalid constraint store") +def walk(candidate: Any, sub: Substitution) -> Any: + while isinstance(candidate, Var): + result = sub.get(candidate, SENTINEL) + if result is SENTINEL: + return candidate + candidate = result + return candidate -def remove_and_run(constraint: Constraint) -> ConstraintFunction: - def _remove_and_run(state: State) -> State | None: - if constraint in state.constraints: - constraints = [x for x in state.constraints if x != constraint] - return constraint(state.set(constraints=constraints)) - else: - return state +def deep_walk(candidate: Any, sub: Substitution) -> Any: + """ + Like `walk', but reify elements of lists/tuples. + """ + candidate = walk(candidate, sub) + if isinstance(candidate, list | tuple): + container = type(candidate) + return container(deep_walk(x, sub) for x in candidate) + else: + return candidate - return _remove_and_run +def extend_substitution( + x: Var, v: Any, sub: Substitution, occurs_check: bool = True +) -> Substitution: + if occurs_check and occurs(x, v, sub): + raise OccursError(f"occurs_check failed ({x=}, {v=}, {sub=})") + return sub.set(x, v) -def process_prefix_neq( - prefix: Substitution, constraints: ConstraintStore -) -> ConstraintFunction: - prefix_vars = {x for x in prefix.keys() if isinstance(x, Var)} - prefix_vars.update({x for x in prefix.values() if isinstance(x, Var)}) - return run_constraints(prefix_vars, constraints) +def occurs(x: Var, v: Any, sub: Substitution) -> bool: + v = walk(v, sub) + if isinstance(v, Var): + return v == x + elif isinstance(v, list | tuple): + return any(occurs(x, term, sub) for term in v) + else: + return False -def enforce_constraints_neq(_: Var) -> GoalProto: - return lambda state: unit(state) +def unit(state: State) -> ReadyStream: + return (state, mzero()) -def reify_constraints_neq(_: Var, __: Substitution) -> ConstraintFunction: - return identity +def succeed(state: State) -> ReadyStream: + return unit(state) -class UnboundVariables(Exception): - pass +def mzero() -> EmptyStream: + return () -def force_answer(x: Var | list[Var]) -> GoalProto: - def _force_answer(state: State) -> Stream: - match walk(x, state.sub): - case Var(_) as var if (d := state.get_domain(var)) is not None: - return map_sum(lambda val: eq(x, val), d)(state) - case (first, *rest) | cons(first, rest): - return conj(force_answer(first), force_answer(rest))(state) - case _: - return succeed()(state) - return _force_answer +def eq(u: Any, v: Any) -> Goal: + def _eq(state: State) -> EmptyStream | ReadyStream: + maybe_sub: Substitution | Sentinel = unify(u, v, state.sub) + if isinstance(maybe_sub, Sentinel): + return mzero() + return unit(State(state.counter, maybe_sub)) + return _eq -A = TypeVar("A") +def unify(u: Any, v: Any, s: Substitution) -> Substitution | Sentinel: + """ + Unify u and v in the Substitution s. + """ + match walk(u, s), walk(v, s): + case Var(_) as x, Var(_) as y if x is y: + return s + case Var(_) as x, y: + return extend_substitution(x, y, s) + case x, Var(_) as y: + return extend_substitution(y, x, s) + case cons(x, xs), cons(y, ys): + s1 = unify(x, y, s) + return SENTINEL if isinstance(s1, Sentinel) else unify(xs, ys, s1) + case (x, *xs) as m, (y, *ys) as n if len(m) == len(n) and type(m) is type(n): + result = unify(x, y, s) + if isinstance(result, Sentinel): + return SENTINEL + return unify(xs, ys, result) + case x, y if x == y: + return s + case _: + return SENTINEL -def map_sum(goal_constructor: Callable[[A], GoalProto], xs: list[A]) -> GoalProto: - return reduce(lambda accum, x: disj(accum, goal_constructor(x)), xs, fail()) +def call_fresh(f: Callable[[Var], Goal]) -> Goal: + def _goal(state: State) -> Stream: + i, sub = state + return f(Var(i))(State(i + 1, sub)) + + return _goal + + +def bind(stream: Stream, g: Goal) -> Stream: + if empty(stream): + return mzero() + elif callable(stream): + return lambda: bind(stream(), g) + elif isinstance(stream, WaitingStream): + return w_check( + stream, + lambda x: lambda: bind(x(), g), + lambda: WaitingStream( + SuspendedStream( + x.cache, + x.answer_terms, + lambda x=x: bind(x.thunk(), g), + ) + for x in stream + ), + ) + else: + head, tail = cast(tuple[State, Stream], stream) + return mplus(g(head), bind(tail, g)) + + +def mplus(left: Stream, right: Stream) -> Stream: + if empty(left): + return right + elif callable(left): + return lambda: mplus(right, left()) + elif isinstance(left, WaitingStream): + return w_check( + left, + lambda x: lambda: mplus(right, x), + lambda: right + left + if isinstance(right, WaitingStream) + else mplus(right, lambda: left), + ) + else: + head, tail = cast(tuple[State, Stream], left) + return (head, mplus(tail, right)) -def get_sub_prefix(new_sub: Substitution, old_sub: Substitution) -> Substitution: - mutation = new_sub.mutate() - for k in new_sub: - if k in old_sub: - del mutation[k] - return mutation.finish() +def disj(g1: Goal, g2: Goal) -> Goal: + def _disj(state: State): + return mplus(g1(state), g2(state)) -def fresh(fp: Callable) -> GoalProto: - n = fp.__code__.co_argcount + return _disj - def _fresh(state: State) -> Stream: - i = state.var_count - vs = (Var(j) for j in range(i, i + n)) - return fp(*vs)(state.set(var_count=i + n)) - return Goal(_fresh) +def conj(g1: Goal, g2: Goal) -> Goal: + def _conj(state: State): + return bind(g1(state), g2) + return _conj -def freshn(n: int, fp: Callable) -> GoalProto: - def _fresh(state: State) -> Stream: - i = state.var_count - vs = (Var(j) for j in range(i, i + n)) - return fp(*vs)(state.set(var_count=i + n)) - return Goal(_fresh) +### Reification -def pull(s: Stream): - while callable(s): - s = s() - return s +def pull(x): + while callable(x): + x = x() + return x -def take_all(s: Stream) -> list[State]: - result = [] - rest = s - while (s := pull(rest)) != (): - first, rest = s - result.append(first) - return result +def raise_(exc): + raise exc -def take(n, s: Stream) -> list[State]: - i = 0 - result = [] - rest = s - while i < n and (s := pull(rest)) != (): - first, rest = s - result.append(first) - i += 1 - return result +def take(n: int, stream: Stream): + if n == 0: + return [] + s = pull(stream) + if empty(s): + return [] + elif isinstance(s, WaitingStream): + return take( + n, + w_check( + s, + lambda x: x, + lambda: mzero(), + ), + ) + else: + a, d = cast(ReadyStream, s) + return [a, *take(n - 1, d)] -def itake(s: Stream) -> list[State]: - rest = s - while (s := pull(rest)) != (): - first, rest = s - yield first +def reify_symbol(i: int) -> Symbol: + return Symbol(f"_.{i}") -def reify(states: list[State], var: Var): - return [reify_state(s, var) for s in states] +def make_reify(representation): + def reify(v, s): + v = deep_walk(v, s) + return deep_walk(v, reify_sub(representation, v, empty_sub())) + return reify -def ireify(states: Generator[State, None, None], var: Var): - yield from (reify_state(s, var) for s in states) +def reify_sub(representation: Callable, v: Any, sub: Substitution) -> Substitution: + v = walk(v, sub) + if isinstance(v, Var): + return extend_substitution(v, representation(len(sub)), sub, occurs_check=False) + elif isinstance(v, list | tuple): + return reduce(lambda s, x: reify_sub(representation, x, s), v, sub) + else: + return sub -def reify_state(state: State, v: Var) -> Value: - v = walk_all(v, state.sub) - reified_sub = reify_sub(v, empty_sub()) - v = walk_all(v, reified_sub) - return Hooks.reify_value(Hooks.reify_constraints(v, reified_sub, state)) +# Reify unbound logic variables as Symbols +reify = make_reify(reify_symbol) -def walk_all(v: Value, s: Substitution) -> Value: - v = walk(v, s) - match v: - case _ if isinstance(v, Var): - return v - case cons(a, d): - return cons(walk_all(a, s), walk_all(d, s)) - case (first, *rest) as xs: - if isinstance(xs, list): - return [walk_all(first, s), *walk_all(rest, s)] - return (walk_all(first, s), *walk_all(rest, s)) - case _: - return v +# Reify unbound logic variables as fresh logic variables +reify_var = make_reify(Var) +# Reify as like reify_var, but transform i with f(i) = -(1+i). This +# prevents circular mappings (e.g. {Var(0) → Var(0)}). +reify_tabled_var = make_reify(lambda i: Var(-(1 + i))) -def reify_sub(v: Value, s: Substitution) -> Substitution: - # Allows us to control how fresh Vars are reified - v = walk(v, s) - match v: - case _ if isinstance(v, Var): - return extend_substitution(v, Hooks.reify_var(v, s), s) - case (a, *d) | cons(a, d): - return reify_sub(d, reify_sub(a, s)) - case _: - return s +### Tabling -def call_with_empty_state(g: GoalProto) -> Stream: - return g(State.empty()) +class Table(dict[tuple, TableCache]): ... -def run(n: int, f_fresh_vars: Callable[..., GoalProto]): - return _run(f_fresh_vars, partial(take, n), reify) +def tabled(gc: GoalConstructor) -> GoalConstructor: + table = Table() -def run_all(f_fresh_vars: Callable[..., GoalProto]): - return _run(f_fresh_vars, take_all, reify) + def tabled_gc(*args): + def tabled_goal(state: State) -> Stream: + # Reify `args' in the current substitution, use this as + # the cache key for a master call. + key = reify(args, state.sub) + if key not in table: + table[key] = [] + return conj( + gc(*args), + primary_tabled_call(args, table[key]), + )(state) + # reuse_tabled_results gets a pointer to the cache, not a + # copy, so the suspended streams have access to newly + # cached results. + return reuse_tabled_results(args, table[key], state) -def irun(f_fresh_vars: Callable[..., GoalProto]): - return _run(f_fresh_vars, itake, ireify) + return tabled_goal + return tabled_gc -def _run(f_fresh_vars, take, reify): - n_vars = f_fresh_vars.__code__.co_argcount - fresh_vars = tuple(Var(i) for i in range(1, n_vars + 1)) - state = State.empty().set(var_count=n_vars + 1) - # We set up `q' and associate it with `fresh_vars' as the first goal - # so all of the top-level vars requested by the user are reified wrt - # the same substitution, giving accurate variable "numbers". Without - # this, we may get incorrect multiple appearances of `_.0', for - # instance. - q = Var(0) - goal = conj( - eq(q, fresh_vars if len(fresh_vars) > 1 else fresh_vars[0]), - f_fresh_vars(*fresh_vars), - *map(Hooks.enforce_constraints, fresh_vars), - ) - return reify(take(goal(state)), q) +def primary_tabled_call(args: tuple, cache: TableCache) -> Goal: + def _goal(state: State) -> Stream: + _, sub = state + reified_args = reify(args, sub) + # Check if the result is alpha-equivalent to any previous cached result. + if any(reified_args == reify(result, sub) for result in cache): + # If so, contribute no state. + return mzero() -def default_process_prefix(*_): - return identity + # Otherwise, we have a new state to cache and contribute to the result. + cache.append(reify_tabled_var(args, sub)) + return unit(state) + return _goal -def default_enforce_constraints(*_): - return succeed() +def alpha_equivalent(x: Any, y: Any, s: Substitution) -> bool: + return reify(x, s) == reify(y, s) -def default_reify_constraints(value, *_): - return value +def reuse_tabled_results(args: tuple, cache: TableCache, state: State) -> Stream: + # Fix is called at the beginning of the reuse call, with the whole cache. + # Fix is called as a SS's thunk in w_check, in the success continuation. + def fix(start, end) -> Stream: + def loop(cached_results): + if cached_results == end: + # This will run on the first iteration of `loop'. + return WaitingStream( + # TODO: Should `cache' be a copy of the tabled + # cache, or a pointer to it? + [SuspendedStream(cache, start, lambda: fix(cache, start))] + ) + else: + head, *tail = cached_results + counter, sub = state + + # Produce a new state that is the result of unifying + # the secondary call's args with the first cached + # result. + next_state = State( + counter, + subunify(args, reify_tabled_var(head, sub), sub), + ) -def default_reify_var(_, substitution): - return ReifiedVar(len(substitution)) + # Concat the new state with the result of unifying the + # secondary call's args with the rest of the cached + # results. + return mplus( + unit(next_state), + lambda: loop(tail), + ) + return loop(start) + + return fix(cache, []) + + +def subunify(args, cached_result, sub: Substitution) -> Substitution: + args = walk(args, sub) + if args == cached_result: + return sub + elif isinstance(args, Var): + return extend_substitution(args, cached_result, sub, occurs_check=False) + elif isinstance(args, list | tuple): + a, *d = args + b, *e = cached_result + return subunify(d, e, subunify(a, b, sub)) + else: + return sub + + +def w_check( + w: WaitingStream, sk: Callable[[Any], Stream], fk: Callable[[], Stream] +) -> Stream: + # Find the first suspended stream in `w' whose cache contains new + # answer terms. + + def loop(w: WaitingStream, a: WaitingStream) -> Stream: + if not w: + return fk() + elif ready_stream(w[0]): + # The first suspended is can contribute results. Invoke + # its thunk, followed by any remaining suspended streams. + head, *tail = w + _, _, thunk = head + rest_suspended_streams = WaitingStream(a[::-1] + tail) + return sk( + lambda: thunk() + if not w + else mplus(thunk(), lambda: rest_suspended_streams) + ) + else: + return loop(w[1:], WaitingStream([w[0], *a])) -def default_reify_value(value): - return value + return loop(w, WaitingStream()) -class HooksMeta(type): - def _set_hook(cls, name): - def update_hooks(hook_function): - if not hasattr(cls, name): - raise AttributeError( - f"Cannot set unknown hook '{name}' on registry '{cls.__name__}'" - ) - setattr(cls, name, hook_function) - - return update_hooks - - def __getattribute__(cls, attr): - if attr.startswith("set_"): - return cls._set_hook(attr[4:]) - return super().__getattribute__(attr) - - -class Hooks(metaclass=HooksMeta): - process_prefix: Callable[[Substitution, ConstraintStore], ConstraintFunction] = ( - default_process_prefix - ) - enforce_constraints: Callable[[Var], GoalProto] = default_enforce_constraints - reify_constraints: Callable[[Value, Substitution, State], Any] = ( - default_reify_constraints - ) - reify_var: Callable[[Var, Substitution], Any] = default_reify_var - reify_value: Callable[[Any], Any] = default_reify_value +@tabled +def fives(x): + return disj(eq(x, 5), lambda sc: lambda: fives(x)(sc)) diff --git a/src/microkanren/core_2.py b/src/microkanren/core_2.py deleted file mode 100644 index 9a37211..0000000 --- a/src/microkanren/core_2.py +++ /dev/null @@ -1,510 +0,0 @@ -from collections.abc import Callable -from functools import reduce -from typing import ( - Any, - ClassVar, - NamedTuple, - Self, - SupportsIndex, - cast, - overload, -) - -import immutables -from fastcons import cons - - -class OccursError(Exception): ... - - -class Sentinel: ... - - -SENTINEL = Sentinel() - - -class Var: - i: int - _cache: ClassVar[dict[int, Self] | None] = None - __match_args__ = ("i",) - - def __new__(cls, i) -> Self: - if (cache := cls._cache) is None: - cache = {} - cls._cache = cache - - if i in cache: - return cache[i] - - instance = super().__new__(cls) - cache[i] = instance - return instance - - def __init__(self, i: int): - self.i = i - - def __repr__(self) -> str: - return f"Var({self.i})" - - def __hash__(self): - return hash((self.__class__, self.i)) - - def __eq__(self, other): - return other is self - - -class Symbol: - name: str - _cache: ClassVar[dict[str, Self] | None] = None - - def __new__(cls, name: str) -> Self: - if (cache := cls._cache) is None: - cache = {} - cls._cache = cache - - if name in cache: - return cache[name] - - instance = super().__new__(cls) - cache[name] = instance - return instance - - def __init__(self, name: str): - self.name = name - - def __repr__(self) -> str: - return str(self) - - def __str__(self) -> str: - return self.name - - def __hash__(self): - return hash((self.__class__, self.name)) - - def __eq__(self, other): - return other is self - - -type Substitution = immutables.Map[Var, Any] - - -def empty_sub() -> Substitution: - return immutables.Map() - - -class State(NamedTuple): - counter: int - sub: Substitution - - -type TableCache = list - - -class SuspendedStream(NamedTuple): - cache: TableCache - - # A suffix of the tabled goal's cached answers. Indicates which of the - # primary call's answer terms this stream has already processed. - answer_terms: list - - # Produces the remainder of the stream. - thunk: Callable[[], "Stream"] - - -def ready_stream(ss: SuspendedStream) -> bool: - """ - Does the cache contain new answer terms not yet consumed by the stream? - """ - return ss.cache != ss.answer_terms - - -class WaitingStream(list[SuspendedStream]): - @overload - def __getitem__(self, key: SupportsIndex) -> SuspendedStream: ... - - @overload - def __getitem__(self, key: slice) -> Self: ... - - def __getitem__(self, key): - if isinstance(key, slice): - return self.__class__(super().__getitem__(key)) - else: - return super().__getitem__(key) - - def __add__(self, value) -> "WaitingStream": - if not isinstance(value, self.__class__): - raise TypeError( - "WaitingStream can only be concatenated with another WaitingStream instance" - ) - return WaitingStream([*self, *value]) - - def __repr__(self) -> str: - return f"WaitingStream({super().__repr__()})" - - def __str__(self) -> str: - return f"WaitingStream({super().__str__()})" - - -type EmptyStream = tuple[()] -type ReadyStream = tuple[State, "Stream"] -type ThunkStream = Callable[[], "Stream"] -type Stream = EmptyStream | ReadyStream | ThunkStream | WaitingStream -type Goal = Callable[[State], Stream] -type GoalConstructor = Callable[..., Goal] - - -def empty(stream: Stream): - return stream == () - - -def empty_state(): - return State(0, empty_sub()) - - -def walk(candidate: Any, sub: Substitution) -> Any: - while isinstance(candidate, Var): - result = sub.get(candidate, SENTINEL) - if result is SENTINEL: - return candidate - candidate = result - return candidate - - -def deep_walk(candidate: Any, sub: Substitution) -> Any: - """ - Like `walk', but reify elements of lists/tuples. - """ - candidate = walk(candidate, sub) - if isinstance(candidate, list | tuple): - container = type(candidate) - return container(deep_walk(x, sub) for x in candidate) - else: - return candidate - - -def extend_substitution( - x: Var, v: Any, sub: Substitution, occurs_check: bool = True -) -> Substitution: - if occurs_check and occurs(x, v, sub): - raise OccursError(f"occurs_check failed ({x=}, {v=}, {sub=})") - return sub.set(x, v) - - -def occurs(x: Var, v: Any, sub: Substitution) -> bool: - v = walk(v, sub) - if isinstance(v, Var): - return v == x - elif isinstance(v, list | tuple): - return any(occurs(x, term, sub) for term in v) - else: - return False - - -def unit(state: State) -> ReadyStream: - return (state, mzero()) - - -def succeed(state: State) -> ReadyStream: - return unit(state) - - -def mzero() -> EmptyStream: - return () - - -def eq(u: Any, v: Any) -> Goal: - def _eq(state: State) -> EmptyStream | ReadyStream: - maybe_sub: Substitution | Sentinel = unify(u, v, state.sub) - if isinstance(maybe_sub, Sentinel): - return mzero() - return unit(State(state.counter, maybe_sub)) - - return _eq - - -def unify(u: Any, v: Any, s: Substitution) -> Substitution | Sentinel: - """ - Unify u and v in the Substitution s. - """ - match walk(u, s), walk(v, s): - case Var(_) as x, Var(_) as y if x is y: - return s - case Var(_) as x, y: - return extend_substitution(x, y, s) - case x, Var(_) as y: - return extend_substitution(y, x, s) - case cons(x, xs), cons(y, ys): - s1 = unify(x, y, s) - return SENTINEL if isinstance(s1, Sentinel) else unify(xs, ys, s1) - case (x, *xs) as m, (y, *ys) as n if len(m) == len(n) and type(m) is type(n): - result = unify(x, y, s) - if isinstance(result, Sentinel): - return SENTINEL - return unify(xs, ys, result) - case x, y if x == y: - return s - case _: - return SENTINEL - - -def call_fresh(f: Callable[[Var], Goal]) -> Goal: - def _goal(state: State) -> Stream: - i, sub = state - return f(Var(i))(State(i + 1, sub)) - - return _goal - - -def bind(stream: Stream, g: Goal) -> Stream: - if empty(stream): - return mzero() - elif callable(stream): - return lambda: bind(stream(), g) - elif isinstance(stream, WaitingStream): - return w_check( - stream, - lambda x: lambda: bind(x(), g), - lambda: WaitingStream( - SuspendedStream( - x.cache, - x.answer_terms, - lambda x=x: bind(x.thunk(), g), - ) - for x in stream - ), - ) - else: - head, tail = cast(tuple[State, Stream], stream) - return mplus(g(head), bind(tail, g)) - - -def mplus(left: Stream, right: Stream) -> Stream: - if empty(left): - return right - elif callable(left): - return lambda: mplus(right, left()) - elif isinstance(left, WaitingStream): - return w_check( - left, - lambda x: lambda: mplus(right, x), - lambda: right + left - if isinstance(right, WaitingStream) - else mplus(right, lambda: left), - ) - else: - head, tail = cast(tuple[State, Stream], left) - return (head, mplus(tail, right)) - - -def disj(g1: Goal, g2: Goal) -> Goal: - def _disj(state: State): - return mplus(g1(state), g2(state)) - - return _disj - - -def conj(g1: Goal, g2: Goal) -> Goal: - def _conj(state: State): - return bind(g1(state), g2) - - return _conj - - -### Reification - - -def pull(x): - while callable(x): - x = x() - return x - - -def raise_(exc): - raise exc - - -def take(n: int, stream: Stream): - if n == 0: - return [] - s = pull(stream) - if empty(s): - return [] - elif isinstance(s, WaitingStream): - return take( - n, - w_check( - s, - lambda x: x, - lambda: mzero(), - ), - ) - else: - a, d = cast(ReadyStream, s) - return [a, *take(n - 1, d)] - - -def reify_symbol(i: int) -> Symbol: - return Symbol(f"_.{i}") - - -def make_reify(representation): - def reify(v, s): - v = deep_walk(v, s) - return deep_walk(v, reify_sub(representation, v, empty_sub())) - - return reify - - -def reify_sub(representation: Callable, v: Any, sub: Substitution) -> Substitution: - v = walk(v, sub) - if isinstance(v, Var): - return extend_substitution(v, representation(len(sub)), sub, occurs_check=False) - elif isinstance(v, list | tuple): - return reduce(lambda s, x: reify_sub(representation, x, s), v, sub) - else: - return sub - - -# Reify unbound logic variables as Symbols -reify = make_reify(reify_symbol) - -# Reify unbound logic variables as fresh logic variables -reify_var = make_reify(Var) - -# Reify as like reify_var, but transform i with f(i) = -(1+i). This -# prevents circular mappings (e.g. {Var(0) → Var(0)}). -reify_tabled_var = make_reify(lambda i: Var(-(1 + i))) - - -### Tabling - - -class Table(dict[tuple, TableCache]): ... - - -def tabled(gc: GoalConstructor) -> GoalConstructor: - table = Table() - - def tabled_gc(*args): - def tabled_goal(state: State) -> Stream: - # Reify `args' in the current substitution, use this as - # the cache key for a master call. - key = reify(args, state.sub) - if key not in table: - table[key] = [] - return conj( - gc(*args), - primary_tabled_call(args, table[key]), - )(state) - - # reuse_tabled_results gets a pointer to the cache, not a - # copy, so the suspended streams have access to newly - # cached results. - return reuse_tabled_results(args, table[key], state) - - return tabled_goal - - return tabled_gc - - -def primary_tabled_call(args: tuple, cache: TableCache) -> Goal: - def _goal(state: State) -> Stream: - _, sub = state - reified_args = reify(args, sub) - - # Check if the result is alpha-equivalent to any previous cached result. - if any(reified_args == reify(result, sub) for result in cache): - # If so, contribute no state. - return mzero() - - # Otherwise, we have a new state to cache and contribute to the result. - cache.append(reify_tabled_var(args, sub)) - return unit(state) - - return _goal - - -def alpha_equivalent(x: Any, y: Any, s: Substitution) -> bool: - return reify(x, s) == reify(y, s) - - -def reuse_tabled_results(args: tuple, cache: TableCache, state: State) -> Stream: - # Fix is called at the beginning of the reuse call, with the whole cache. - # Fix is called as a SS's thunk in w_check, in the success continuation. - def fix(start, end) -> Stream: - def loop(cached_results): - if cached_results == end: - # This will run on the first iteration of `loop'. - return WaitingStream( - # TODO: Should `cache' be a copy of the tabled - # cache, or a pointer to it? - [SuspendedStream(cache, start, lambda: fix(cache, start))] - ) - else: - head, *tail = cached_results - counter, sub = state - - # Produce a new state that is the result of unifying - # the secondary call's args with the first cached - # result. - next_state = State( - counter, - subunify(args, reify_tabled_var(head, sub), sub), - ) - - # Concat the new state with the result of unifying the - # secondary call's args with the rest of the cached - # results. - return mplus( - unit(next_state), - lambda: loop(tail), - ) - - return loop(start) - - return fix(cache, []) - - -def subunify(args, cached_result, sub: Substitution) -> Substitution: - args = walk(args, sub) - if args == cached_result: - return sub - elif isinstance(args, Var): - return extend_substitution(args, cached_result, sub, occurs_check=False) - elif isinstance(args, list | tuple): - a, *d = args - b, *e = cached_result - return subunify(d, e, subunify(a, b, sub)) - else: - return sub - - -def w_check( - w: WaitingStream, sk: Callable[[Any], Stream], fk: Callable[[], Stream] -) -> Stream: - # Find the first suspended stream in `w' whose cache contains new - # answer terms. - - def loop(w: WaitingStream, a: WaitingStream) -> Stream: - if not w: - return fk() - elif ready_stream(w[0]): - # The first suspended is can contribute results. Invoke - # its thunk, followed by any remaining suspended streams. - head, *tail = w - _, _, thunk = head - rest_suspended_streams = WaitingStream(a[::-1] + tail) - return sk( - lambda: thunk() - if not w - else mplus(thunk(), lambda: rest_suspended_streams) - ) - else: - return loop(w[1:], WaitingStream([w[0], *a])) - - return loop(w, WaitingStream()) - - -@tabled -def fives(x): - return disj(eq(x, 5), lambda sc: lambda: fives(x)(sc)) diff --git a/src/microkanren/fd.py b/src/microkanren/fd.py deleted file mode 100644 index 433e3fa..0000000 --- a/src/microkanren/fd.py +++ /dev/null @@ -1,347 +0,0 @@ -from functools import reduce - -from microkanren.core import ( - Constraint, - ConstraintFunction, - ConstraintStore, - Goal, - GoalProto, - State, - Stream, - Substitution, - Value, - Var, - compose_constraints, - conj, - extend_constraint_store, - extend_domain_store, - extend_substitution, - force_answer, - goal_from_constraint, - run_constraints, - walk, -) -from microkanren.goals import onceo -from microkanren.utils import identity, partition - - -class UnboundConstrainedVariable(Exception): - pass - - -def make_domain(*values: int) -> set[int]: - return set(values) - - -def mkrange(start: int, end: int) -> set[int]: - return make_domain(*range(start, end + 1)) - - -def domfd(x: Value, domain: set[int]) -> GoalProto: - return goal_from_constraint(domfdc(x, domain)) - - -def domfdc(x: Value, domain: set[int]) -> ConstraintFunction: - def _domfdc(state: State) -> State | None: - process_domain_goal = process_domain(x, domain) - return process_domain_goal(state) - - return _domfdc - - -def infd(values: tuple[Value], domain, /) -> GoalProto: - infdc = reduce( - lambda c, v: compose_constraints(c, domfdc(v, domain)), - values, - identity, - ) - return goal_from_constraint(infdc) - - -def ltfd(u: Value, v: Value) -> GoalProto: - return goal_from_constraint(ltfdc(u, v)) - - -def ltfdc(u: Value, v: Value) -> ConstraintFunction: - def _ltfdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(ltfdc, (_u, _v)), state.constraints - ) - ) - if not dom_u or not dom_v: - return next_state - - max_v = max(dom_v) - min_u = min(dom_u) - return compose_constraints( - process_domain(_u, make_domain(*(i for i in dom_u if i < max_v))), - process_domain(_v, make_domain(*(i for i in dom_v if i > min_u))), - )(next_state) - - return _ltfdc - - -def ltefd(u: Value, v: Value) -> GoalProto: - return goal_from_constraint(ltefdc(u, v)) - - -def ltefdc(u: Value, v: Value) -> ConstraintFunction: - def _ltefdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(ltefdc, (_u, _v)), state.constraints - ) - ) - if not dom_u or not dom_v: - return next_state - - max_v = max(dom_v) - min_u = min(dom_u) - return compose_constraints( - process_domain(_u, make_domain(*(i for i in dom_u if i <= max_v))), - process_domain(_v, make_domain(*(i for i in dom_v if i >= min_u))), - )(next_state) - - return _ltefdc - - -def plusfd(u: Value, v: Value, w: Value): - return goal_from_constraint(plusfdc(u, v, w)) - - -def plusfdc(u: Value, v: Value, w: Value) -> ConstraintFunction: - def _plusfdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - _w = walk(w, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - dom_w = state.get_domain(_w) if isinstance(_w, Var) else make_domain(_w) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(plusfdc, (_u, _v, _w)), state.constraints - ) - ) - if not all((dom_u, dom_v, dom_w)): - return next_state - - min_u = min(dom_u) - max_u = max(dom_u) - min_v = min(dom_v) - max_v = max(dom_v) - min_w = min(dom_w) - max_w = max(dom_w) - return compose_constraints( - process_domain(_w, mkrange(min_u + min_v, max_u + max_v)), - compose_constraints( - process_domain(_u, mkrange(min_w - max_v, max_w - min_v)), - process_domain(_v, mkrange(min_w - max_u, max_w - min_u)), - ), - )(next_state) - - return _plusfdc - - -def neqfd(u: Value, v: Value) -> GoalProto: - return goal_from_constraint(neqfdc(u, v)) - - -def neqfdc(u: Value, v: Value) -> ConstraintFunction: - def _neqfdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - if dom_u is None or dom_v is None: - return state.set( - constraints=extend_constraint_store( - Constraint(neqfdc, (_u, _v)), state.constraints - ) - ) - elif len(dom_u) == 1 and len(dom_v) == 1 and dom_u == dom_v: - return None - elif dom_u.isdisjoint(dom_v): - return state - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(neqfdc, (_u, _v)), state.constraints - ) - ) - if len(dom_u) == 1: - return process_domain(_v, dom_v - dom_u)(next_state) - elif len(dom_v) == 1: - return process_domain(_u, dom_u - dom_v)(next_state) - else: - return next_state - - return _neqfdc - - -def alldifffd(*vs: Value) -> GoalProto: - return goal_from_constraint(alldifffdc(*vs)) - - -def alldifffdc(*vs: Value) -> ConstraintFunction: - def _alldifffdc(state: State) -> State | None: - unresolved, values = partition(lambda v: isinstance(v, Var), vs) - unresolved = tuple(unresolved) - values = tuple(values) - values_domain = make_domain(*values) - if len(values) == len(values_domain): - return alldifffdc_resolve(unresolved, values_domain)(state) - return None - - return _alldifffdc - - -def alldifffdc_resolve( - unresolved: tuple[Var], values: set[Value] -) -> ConstraintFunction: - def _alldifffdc_resolve(state: State) -> State | None: - nonlocal values - values = set(values) - remains_unresolved = [] - for var in unresolved: - v = walk(var, state.sub) - if isinstance(v, Var): - remains_unresolved.append(v) - elif is_domain_member(v, values): - return None - else: - values.add(v) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint( - alldifffdc_resolve, (tuple(remains_unresolved), tuple(values)) - ), - state.constraints, - ) - ) - return exclude_from_domains(remains_unresolved, values)(next_state) - - return _alldifffdc_resolve - - -def exclude_from_domains(vs: list[Var], values: set[Value]) -> ConstraintFunction: - """ - For each Var in vs, remove all values in values from its domain. - """ - - def _exclude_from_domains(state: State) -> State | None: - with_domains = ( - (var, dom) for var in vs if (dom := state.get_domain(var)) is not None - ) - constraint = reduce( - compose_constraints, - (process_domain(var, dom - values) for var, dom in with_domains), - identity, - ) - return constraint(state) - - return _exclude_from_domains - - -def is_domain_member(v: Value, dom: set[int]) -> bool: - return isinstance(v, int) and v in dom - - -def process_domain(x: Value, domain: set[int]) -> ConstraintFunction: - def _process_domain(state): - match walk(x, state.sub): - case Var(_): - # x is not associated with any concrete value, update its domain - return update_var_domain(x, domain, state) - case val if val in domain: - # We already have a concrete value, check if it's in the domain - return state - case _: - return None - - return _process_domain - - -def update_var_domain(x: Var, domain: set[int], state: State) -> State | None: - match state.get_domain(x): - case fd if isinstance(fd, set): - i = domain & fd - if i: - return resolve_storable_domain(i, x, state) - else: - return None - case _: - return resolve_storable_domain(domain, x, state) - - -def resolve_storable_domain(domain: set[int], x: Var, state: State) -> State | None: - if len(domain) == 1: - n = domain.copy().pop() - next_state = state.set(sub=extend_substitution(x, n, state.sub)) - return run_constraints([x], state.constraints)(next_state) - return state.set(domains=extend_domain_store(x, domain, state.domains)) - - -def process_prefix_fd( - prefix: Substitution, constraints: ConstraintStore -) -> ConstraintFunction: - if not prefix: - return identity - (x, v), *_ = prefix.items() - t = compose_constraints( - run_constraints([x], constraints), - process_prefix_fd(prefix.delete(x), constraints), - ) - - def _process_prefix_fd(state: State): - domain_x = state.get_domain(x) - if domain_x is not None: - # We have a new association for x (as x is in prefix), and we found an - # existing domain for x. Check that the new association does not violate - # the fd constraint - return compose_constraints(process_domain(v, domain_x), t)(state) - return t(state) - - return _process_prefix_fd - - -def enforce_constraints_fd(x: Var) -> GoalProto: - def _enforce_constraints(state: State) -> Stream: - bound_vars = state.domains.keys() - verify_all_bound(state.constraints, bound_vars) - return onceo(force_answer(bound_vars))(state) - - return conj(force_answer(x), Goal(_enforce_constraints)) - - -def verify_all_bound(constraints: ConstraintStore, bound_vars: list[Var]): - if len(constraints) > 0: - first, *rest = constraints - var_operands = [x for x in first.operands if isinstance(x, Var)] - for var in var_operands: - if var not in bound_vars: - raise UnboundConstrainedVariable( - f"Constrained variable {var} has no domain" - ) - verify_all_bound(rest, bound_vars) - - -# TODO -def reify_constraints_fd(_: Var, __: Substitution) -> ConstraintFunction: - def _reify_constraints_fd(state: State) -> State | None: - return state - # raise UnboundVariables() - - return _reify_constraints_fd diff --git a/src/microkanren/goals.py b/src/microkanren/goals.py deleted file mode 100644 index 1af7946..0000000 --- a/src/microkanren/goals.py +++ /dev/null @@ -1,191 +0,0 @@ -from fastcons import cons, nil - -from microkanren.core import ( - Goal, - GoalProto, - State, - Stream, - Var, - bind, - conj, - disj, - eq, - fail, - fresh, - mzero, - neq, - succeed, - unit, -) - - -def conda(*cases) -> GoalProto: - _cases = [] - for case in cases: - if isinstance(case, list | tuple): - _cases.append((case[0], succeed) if len(case) < 2 else case) - else: - _cases.append((case, succeed())) - - def _conda(state: State) -> Stream: - return starfoldr(ifte, _cases, fail())(state) - - return Goal(_conda) - - -def starfoldr(f, xs, initial): - sentinel = object() - accum = sentinel - for args in reversed(xs): - if accum is sentinel: - _args = (*args, initial) - else: - _args = (*args, accum) - accum = f(*_args) - return accum - - -def ifte(g1, g2, g3=None) -> GoalProto: - g3 = g3 or fail() - - def _ifte(state: State) -> Stream: - # TODO: rewrite iteratively - def ifte_loop(stream: Stream) -> Stream: - match stream: - case (): - return g3(state) - case (_, _): - return bind(stream, g2) - case _: - return lambda: ifte_loop(stream()) - - return ifte_loop(g1(state)) - - return Goal(_ifte) - - -def onceo(g: GoalProto) -> GoalProto: - def _onceo(state: State): - stream = g(state) - while stream: - match stream: - case (s1, _): - return unit(s1) - case _: - stream = stream() - return mzero - - return _onceo - - -def appendo(xs: cons | Var, ys: cons | Var, zs: cons | Var) -> Goal: - return disj( - nullo(xs) & eq(ys, zs), - fresh( - lambda a, d, res: conj( - conso(a, d, xs), - conso(a, res, zs), - appendo(d, ys, res), - ) - ), - ) - - -def membero(x, xs): - return fresh( - lambda a, d: conj( - conso(a, d, xs), - disj(eq(a, x), membero(x, d)), - ) - ) - - -def nullo(x): - return eq(x, nil()) - - -def notnullo(x): - return neq(x, nil()) - - -def conso(a, d, ls): - return eq(cons(a, d), ls) - - -def caro(a, xs): - return fresh(lambda d: conso(a, d, xs)) - - -def cdro(d, xs): - return fresh(lambda a: conso(a, d, xs)) - - -def listo(xs): - return nullo(xs) | fresh(lambda d: cdro(d, xs) & listo(d)) - - -def inserto(x, ys, zs): - def _inserto(state: State) -> Stream: - return disj( - appendo(cons(x, nil()), ys, zs), - fresh( - lambda a, d, res: conj( - eq((a, d), ys), - eq((a, res), zs), - inserto(x, d, res), - ), - ), - )(state) - - return Goal(_inserto) - - -def assoco(x, xs, y): - return ifte( - eq(xs, nil()), - fail(), - fresh( - lambda key, val, rest: disj( - conj( - conso((key, val), rest, xs), - disj( - eq(key, x) & eq((key, val), y), - assoco(x, rest, y), - ), - ) - ), - ), - ) - - -def rembero(x, xs, out): - def _rembero(state: State) -> Stream: - return disj( - nullo(xs) & nullo(out), - fresh( - lambda a, d, res: disj( - conso(x, d, xs) & eq(d, out), - conj( - neq(a, x), - conso(a, d, xs), - conso(a, res, out), - rembero(x, d, res), - ), - ) - ), - )(state) - - return Goal(_rembero) - - -def joino(prefix, suffix, sep, out): - return disj( - nullo(prefix) & eq(suffix, out), - nullo(suffix) & eq(prefix, out), - fresh( - lambda tmp: notnullo(prefix) - & notnullo(suffix) - & appendo(prefix, sep, tmp) - & appendo(tmp, suffix, out) - ), - ) diff --git a/src/microkanren/utils.py b/src/microkanren/utils.py deleted file mode 100644 index 776c75c..0000000 --- a/src/microkanren/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -from itertools import filterfalse, tee - -from fastcons import cons - - -def identity(x): - return x - - -def partition(pred, iterable): - t1, t2 = tee(iterable) - return filter(pred, t1), filterfalse(pred, t2) - - -def foldr(f, b, xs): - if not xs: - return b - x, *xs = xs - return f(x, foldr(f, b, xs)) - - -def _(*xs): - return cons.from_xs(xs) diff --git a/tests/test_fd.py b/tests/test_fd.py deleted file mode 100644 index f271a8b..0000000 --- a/tests/test_fd.py +++ /dev/null @@ -1,267 +0,0 @@ -from itertools import permutations -from math import sqrt - -import pytest - -from microkanren import ( - Hooks, - compose_constraints, - conj, - default_enforce_constraints, - default_process_prefix, - enforce_constraints_neq, - eq, - freshn, - neq, - process_prefix_neq, - run, - run_all, -) -from microkanren.fd import ( - alldifffd, - domfd, - enforce_constraints_fd, - infd, - ltefd, - ltfd, - make_domain, - mkrange, - neqfd, - plusfd, - process_prefix_fd, -) - - -@pytest.fixture(autouse=True, scope="module") -def setup_process_prefix(): - def process_prefix(prefix, constraints): - return compose_constraints( - process_prefix_neq(prefix, constraints), - process_prefix_fd(prefix, constraints), - ) - - yield Hooks.set_process_prefix(process_prefix) - Hooks.set_process_prefix(default_process_prefix) - - -@pytest.fixture(autouse=True, scope="module") -def setup_enforce_constraints(): - def enforce_constraints(var): - return conj( - enforce_constraints_neq(var), - enforce_constraints_fd(var), - ) - - yield Hooks.set_enforce_constraints(enforce_constraints) - Hooks.set_enforce_constraints(default_enforce_constraints) - - -class TestFdConstraints: - @pytest.mark.parametrize("domain", [make_domain(1, 2, 3), make_domain(5, 7, 9)]) - def test_domfd(self, domain): - result = run_all(lambda x: domfd(x, domain)) - assert set(result) == set(domain) - - @pytest.mark.parametrize( - ("a", "b", "intersection"), - [ - (make_domain(1, 2), make_domain(1, 2), {1, 2}), - (make_domain(1, 2, 3), make_domain(2, 3, 4), {2, 3}), - (make_domain(1, 2), make_domain(3, 4), make_domain()), - ], - ) - def test_domain_intersection(self, a, b, intersection): - result = run_all(lambda x: domfd(x, a) & domfd(x, b)) - assert set(result) == intersection - - def test_violated_infd_fails(self): - result = run_all(lambda x: domfd(x, make_domain(1, 2, 3)) & eq(x, 4)) - assert result == [] - - @pytest.mark.parametrize( - ("a", "b", "expected_x"), - [ - (make_domain(1, 2, 3, 4), make_domain(2, 3), make_domain(1, 2, 3)), - (make_domain(4, 5), make_domain(1, 2), make_domain()), - (make_domain(3, 4), make_domain(2, 3, 4, 5), make_domain(3, 4)), - (make_domain(1, 2, 3, 4), make_domain(1, 2, 3, 4), make_domain(1, 2, 3, 4)), - (make_domain(1, 2, 3), make_domain(1, 2), make_domain(1, 2)), - ], - ) - def test_ltefd(self, a, b, expected_x): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & ltefd(x, y)) - assert {x[0] for x in result} == expected_x - for x, y in result: - assert x <= y - - @pytest.mark.parametrize( - ("a", "b", "expected_x"), - [ - (make_domain(1, 2, 3, 4), make_domain(2, 3), make_domain(1, 2)), - (make_domain(1, 2, 3), make_domain(4, 5), make_domain(1, 2, 3)), - (make_domain(4, 5), make_domain(1, 2), make_domain()), - (make_domain(3, 4), make_domain(2, 3, 4, 5), make_domain(3, 4)), - (make_domain(1, 2, 3, 4), make_domain(1, 2, 3, 4), make_domain(1, 2, 3)), - ], - ) - def test_ltfd(self, a, b, expected_x): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & ltfd(x, y)) - assert {x[0] for x in result} == expected_x - for x, y in result: - assert x < y - - def test_neq_with_domfd(self): - """ - If neq(x, n), then n cannot be in the domain of x. - """ - result = run_all(lambda x: domfd(x, make_domain(1, 2, 3)) & neq(x, 2)) - assert set(result) == {1, 3} - - def test_neq_with_ltefd(self): - """ - If neq(x, n), then n cannot be in the domain of x. - """ - result = run_all( - lambda x, y: domfd(x, make_domain(1, 2, 3)) - & domfd(y, make_domain(1, 2)) - & ltefd(x, y) - & neq(x, 1) - ) - assert result == [(2, 2)] - - def test_plusfd(self): - result = run_all( - lambda x, y, z: domfd(x, mkrange(1, 3)) - & domfd(y, mkrange(1, 3)) - & domfd(z, mkrange(1, 3)) - & plusfd(x, y, z) - ) - assert set(result) == {(1, 1, 2), (1, 2, 3), (2, 1, 3)} - - def test_plusfd_with_ltefd(self): - result = run_all( - lambda x, y, z: domfd(x, mkrange(1, 3)) - & domfd(y, mkrange(1, 2)) - & domfd(z, mkrange(1, 4)) - & plusfd(x, y, z) - & ltefd(x, y) - ) - assert set(result) == {(1, 1, 2), (1, 2, 3), (2, 2, 4)} - - @pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (make_domain(1), make_domain(1), set()), - (make_domain(1, 2, 3), make_domain(1), {2, 3}), - (make_domain(1, 2, 3), make_domain(3), {1, 2}), - (make_domain(4, 5, 6), make_domain(3), {4, 5, 6}), - (make_domain(4, 5, 6), make_domain(4, 5), {4, 5, 6}), - ], - ) - def test_neqfd(self, a, b, expected): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & neqfd(x, y)) - assert {x[0] for x in result} == expected - for x, y in result: - assert x != y - - def test_neqfd_with_ltefd(self): - result = run_all( - lambda x, y: domfd(x, mkrange(2, 3)) - & domfd(y, mkrange(1, 3)) - & ltefd(x, y) - & neqfd(x, y) - ) - assert set(result) == {(2, 3)} - - @pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (1, 1, []), - (2, 3, [(2, 3)]), - ], - ) - def test_alldifffd_constants(self, a, b, expected): - result = run_all(lambda x, y: eq(x, a) & eq(y, b) & alldifffd(x, y)) - assert result == expected - - @pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (make_domain(1, 2), make_domain(1, 2), {(1, 2), (2, 1)}), - (make_domain(2), make_domain(1, 2, 3), {(2, 1), (2, 3)}), - ( - make_domain(7, 9, 11), - make_domain(1, 11), - {(7, 1), (9, 1), (11, 1), (7, 11), (9, 11)}, - ), - ], - ) - def test_alldifffd_domains(self, a, b, expected): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & alldifffd(x, y)) - assert set(result) == expected - - def test_alldifffd_many(self): - result = run_all( - lambda w, x, y, z: domfd(w, mkrange(1, 4)) - & domfd(x, mkrange(1, 4)) - & domfd(y, mkrange(1, 4)) - & domfd(z, mkrange(1, 4)) - & alldifffd(w, x, y, z) - ) - assert set(result) == set(permutations((1, 2, 3, 4), 4)) - - def test_infd(self): - result = run_all( - lambda a, b, c: infd((a, b, c), mkrange(1, 3)) & alldifffd(a, b, c) - ) - assert set(result) == set(permutations((1, 2, 3), 3)) - - -class TestLargeGoals: - def test_sudoku(self): - def sudokuo(a, b, size): - def grido(grid, vs): - return eq( - grid, - [ - tuple(vs[j] for j in range(i, i + size)) - for i in range(0, size * size, size) - ], - ) - - def blocko(vs, block_size): - blocks = [ - tuple( - vs[segment + block + row + i] - for row in range(0, size * block_size, size) - for i in range(block_size) - ) - for segment in range(0, size * size, size * block_size) - for block in range(0, size, block_size) - ] - return conj( - *(alldifffd(*block) for block in blocks), - ) - - def rowo(vs): - rows = [ - tuple(vs[row + i] for i in range(size)) - for row in range(0, size * size, size) - ] - return conj(*(alldifffd(*row) for row in rows)) - - block_size = int(sqrt(size)) - - return freshn( - size * size, - lambda *vs: conj( - grido(a, vs), - infd(vs, mkrange(1, size)), - rowo(vs), - blocko(vs, block_size), - eq(a, b), - ), - ) - - result = run(1, lambda a, b: sudokuo(a, b, 4)) - assert result != [] From 784b0ddb912a895c012c6af626308147c13f95b3 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 17:54:23 +0000 Subject: [PATCH 05/35] fix: Add occurs check to extend_substitution --- tests/test_core.py | 127 +++++++++++---------------------------------- 1 file changed, 30 insertions(+), 97 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 689ae95..d20dfb6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,115 +1,48 @@ +import pytest from fastcons import cons, nil -from pyrsistent import pmap from microkanren import ( - ReifiedVar, + OccursError, Var, - disj, empty_sub, - eq, extend_substitution, - fresh, - get_sub_prefix, - run, - run_all, - snooze, walk, ) -from microkanren.goals import appendo -from microkanren.utils import _ -class TestSubstitution: - def test_extend_substitution(self): - val = object() - s = extend_substitution(Var(0), val, empty_sub()) - assert walk(Var(0), s) is val +def test_extend_substitution(): + val = object() + s = extend_substitution(Var(0), val, empty_sub()) + assert walk(Var(0), s) is val - def test_walk_self(self): - assert walk(Var(0), empty_sub()) == Var(0) - def test_walk_constant(self): - assert walk("foo", empty_sub()) == "foo" +def test_walk_unbound_var(): + assert walk(Var(0), empty_sub()) == Var(0) - def test_recursive_walk(self): - val = object() - s = extend_substitution( - Var(0), Var(1), extend_substitution(Var(1), val, empty_sub()) - ) - assert walk(Var(0), s) is val - def test_get_sub_prefix(self): - x = object() - initial = empty_sub() - a = extend_substitution(Var(0), 1, initial) - b = extend_substitution( - Var(2), - x, - extend_substitution(Var(1), Var(2), a), - ) - assert set(get_sub_prefix(b, a).items()) == set( - pmap({Var(1): Var(2), Var(2): x}).items() - ) +def test_walk_unbound_value(): + assert walk("foo", empty_sub()) == "foo" -class TestEq: - def test_simple_eq(self): - result = run_all(lambda x: eq(x, 1)) - assert result == [1] +def test_recursive_walk(): + val = object() + s = extend_substitution( + Var(0), Var(1), extend_substitution(Var(1), val, empty_sub()) + ) + assert walk(Var(0), s) is val -def fives(x): - return eq(x, 5) | snooze(fives, x) - - -def sixes(x): - return eq(x, 6) | snooze(sixes, x) - - -def test_snooze(): - result = run(3, lambda x: fives(x)) - assert result == [5, 5, 5] - - result = run(8, lambda x: fives(x) | sixes(x)) - assert result == [5, 6, 5, 6, 5, 6, 5, 6] - - -def test_recursion(): - # Check we don't blow python's stack - assert len(run(10000, lambda x: fives(x))) == 10000 - - -def test_disj_interleaving(): - # Test that the order of results matches examples from the miniKanren paper - - def function_disj_relation(x): - return disj(eq(x, 1), eq(x, 2), eq(x, 3), snooze(function_disj_relation, x)) - - assert run(6, lambda x: function_disj_relation(x)) == [1, 2, 3, 1, 2, 3] - - def operator_disj_relation(x): - return eq(x, 1) | eq(x, 2) | eq(x, 3) | snooze(operator_disj_relation, x) - - assert run(6, lambda x: function_disj_relation(x)) == [1, 2, 3, 1, 2, 3] - - def patho(x, y): - return arco(x, y) | fresh(lambda z: arco(x, z) & patho(z, y)) - - def arco(x, y): - return disj( - eq("a", x) & eq("b", y), - eq("b", x) & eq("a", y), - eq("b", x) & eq("d", y), - ) - - assert "".join(run(9, lambda x: patho("a", x))) == "badbadbad" - - -def test_vars_reified_correctly(): - # Test the reification of fresh variables matches a known example (appendo) - - R = ReifiedVar - result = run(3, lambda x, y, z: appendo(x, y, z)) - assert result[0] == (nil(), R(0), R(0)) - assert result[1] == (_(R(0)), R(1), cons(R(0), R(1))) - assert result[2] == (_(R(0), R(1)), R(2), cons(R(0), cons(R(1), R(2)))) +@pytest.mark.parametrize( + "val", + [ + Var(0), + cons(Var(0), nil()), + (Var(0), Var(0)), + [Var(0)], + [[Var(0)]], + ([Var(0)],), + ], +) +def test_occurs_check_raises(val): + with pytest.raises(OccursError): + extend_substitution(Var(0), val, empty_sub()) From 6df05451ed0c84d690943a2d90509440dd361feb Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Sun, 27 Oct 2024 17:54:24 +0000 Subject: [PATCH 06/35] feat: Add more tests to test_core --- tests/test_core.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index d20dfb6..bb9e4f6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,9 +3,17 @@ from microkanren import ( OccursError, + Symbol, Var, + empty_state, empty_sub, extend_substitution, + mplus, + mzero, + pull, + take, + unit, + unify, walk, ) @@ -32,6 +40,78 @@ def test_recursive_walk(): assert walk(Var(0), s) is val +def test_symbol_equality(): + s1 = Symbol("test") + s2 = Symbol("test") + s3 = Symbol("other") + assert s1 is s2 # Same symbols are identical + assert s1 == s2 # Same symbols are equal + assert s1 != s3 # Different symbols are not equal + assert str(s1) == "test" + assert repr(s1) == "test" + + +def test_empty_state(): + state = empty_state() + assert state.counter == 0 + assert state.sub == empty_sub() + + +def test_unify_basic(): + s = empty_sub() + # Equal atoms unify + assert unify(1, 1, s) == s + assert unify("a", "a", s) == s + # Different atoms don't unify + assert unify(1, 2, s) is SENTINEL + assert unify("a", "b", s) is SENTINEL + + +def test_unify_var(): + s = empty_sub() + x = Var(0) + y = Var(1) + # Var unifies with anything + assert unify(x, 1, s) == extend_substitution(x, 1, s) + assert unify(1, x, s) == extend_substitution(x, 1, s) + # Two vars unify + assert unify(x, y, s) == extend_substitution(x, y, s) + + +def test_unify_sequences(): + s = empty_sub() + # Equal sequences unify + assert unify((1, 2), (1, 2), s) == s + assert unify([1, 2], [1, 2], s) == s + # Different sequences don't unify + assert unify((1, 2), (1, 3), s) is SENTINEL + assert unify([1, 2], [1], s) is SENTINEL + + +def test_stream_operations(): + state = empty_state() + # Empty stream + assert mzero() == () + # Unit stream + assert unit(state) == (state, mzero()) + # Pull thunk + thunk = lambda: unit(state) + assert pull(thunk) == unit(state) + # Take from stream + assert take(1, unit(state)) == [state] + assert take(2, unit(state)) == [state] + assert take(1, mzero()) == [] + + +def test_mplus(): + state = empty_state() + s1 = unit(state) + s2 = unit(State(1, empty_sub())) + # Combine two streams + combined = mplus(s1, s2) + assert take(2, combined) == [state, State(1, empty_sub())] + + @pytest.mark.parametrize( "val", [ From 5a5d544d18a116b6e4920868f2b32a1b47c3f505 Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Sun, 27 Oct 2024 17:55:02 +0000 Subject: [PATCH 07/35] fix: Add missing imports to test_core.py --- tests/test_core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index bb9e4f6..b172195 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,6 +3,8 @@ from microkanren import ( OccursError, + SENTINEL, + State, Symbol, Var, empty_state, From 49d91182034152a0c6db37038233fbcb8db4055a Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 17:58:00 +0000 Subject: [PATCH 08/35] fix: Fix import order in test_core.py --- tests/test_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index b172195..dbcf4d4 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,8 +2,8 @@ from fastcons import cons, nil from microkanren import ( - OccursError, SENTINEL, + OccursError, State, Symbol, Var, @@ -14,8 +14,8 @@ mzero, pull, take, - unit, unify, + unit, walk, ) From d8ddebc29256a744a61c6c822853a13d730ce30e Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Sun, 27 Oct 2024 17:58:01 +0000 Subject: [PATCH 09/35] feat: Split test_stream_operations into separate test functions --- tests/test_core.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index dbcf4d4..fd7de71 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -90,18 +90,28 @@ def test_unify_sequences(): assert unify([1, 2], [1], s) is SENTINEL -def test_stream_operations(): - state = empty_state() - # Empty stream +def test_mzero_is_empty_stream(): assert mzero() == () - # Unit stream + + +def test_unit_creates_stream_with_state(): + state = empty_state() assert unit(state) == (state, mzero()) - # Pull thunk + + +def test_pull_evaluates_thunk(): + state = empty_state() thunk = lambda: unit(state) assert pull(thunk) == unit(state) - # Take from stream + + +def test_take_from_unit_stream(): + state = empty_state() assert take(1, unit(state)) == [state] assert take(2, unit(state)) == [state] + + +def test_take_from_empty_stream(): assert take(1, mzero()) == [] From bcca801915fbb694828d66abdadc7f1fa95b5edd Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Sun, 27 Oct 2024 17:59:25 +0000 Subject: [PATCH 10/35] fix: Split `test_stream_operations` into separate test functions --- tests/test_core.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index fd7de71..119ffbd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -60,58 +60,66 @@ def test_empty_state(): def test_unify_basic(): + """Verify that unification works correctly for basic atomic values.""" s = empty_sub() - # Equal atoms unify + # Equal atoms unify. assert unify(1, 1, s) == s assert unify("a", "a", s) == s - # Different atoms don't unify + # Different atoms don't unify. assert unify(1, 2, s) is SENTINEL assert unify("a", "b", s) is SENTINEL def test_unify_var(): + """Verify that unification works correctly with logic variables.""" s = empty_sub() x = Var(0) y = Var(1) - # Var unifies with anything + # Var unifies with anything. assert unify(x, 1, s) == extend_substitution(x, 1, s) assert unify(1, x, s) == extend_substitution(x, 1, s) - # Two vars unify + # Two vars unify. assert unify(x, y, s) == extend_substitution(x, y, s) def test_unify_sequences(): + """Verify that unification works correctly for sequences like tuples and lists.""" s = empty_sub() - # Equal sequences unify + # Equal sequences unify. assert unify((1, 2), (1, 2), s) == s assert unify([1, 2], [1, 2], s) == s - # Different sequences don't unify + # Different sequences don't unify. assert unify((1, 2), (1, 3), s) is SENTINEL assert unify([1, 2], [1], s) is SENTINEL def test_mzero_is_empty_stream(): + """Verify that mzero() returns an empty stream.""" assert mzero() == () def test_unit_creates_stream_with_state(): + """Verify that unit() creates a stream containing a single state followed by an empty stream.""" state = empty_state() assert unit(state) == (state, mzero()) def test_pull_evaluates_thunk(): + """Verify that pull() evaluates a thunk to produce its underlying stream.""" state = empty_state() thunk = lambda: unit(state) assert pull(thunk) == unit(state) def test_take_from_unit_stream(): + """Verify that take() correctly extracts elements from a unit stream.""" state = empty_state() - assert take(1, unit(state)) == [state] - assert take(2, unit(state)) == [state] + assert take(1, unit(state)) == [state] # Taking one element returns the state. + assert take(2, unit(state)) == [state] # Taking more elements still returns just the state. def test_take_from_empty_stream(): + """Verify that take() returns an empty list when given an empty stream.""" assert take(1, mzero()) == [] From a4d703bd759bda8345845421af7e4e8a7e4107d1 Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Sun, 27 Oct 2024 18:01:08 +0000 Subject: [PATCH 11/35] feat: add test for unifying var with incompatible nested sequence --- tests/test_core.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 119ffbd..07fd614 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -123,6 +123,20 @@ def test_take_from_empty_stream(): assert take(1, mzero()) == [] +def test_unify_var_with_incompatible_nested_sequence(): + """ + Unify sequences where one contains a variable and the other contains a sequence that should not unify. + """ + s = empty_sub() + x = Var(0) + + # Unify tuple containing var with tuple containing incompatible nested tuple + assert unify((1, x, 3), (1, (2, 3), 3), s) is SENTINEL + + # Unify list containing var with list containing incompatible nested list + assert unify([1, x, 3], [1, [2, 3], 3], s) is SENTINEL + + def test_mplus(): state = empty_state() s1 = unit(state) From afd41e665edc7b8b258924bc54d618a9fd0df1c4 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 27 Oct 2024 18:06:23 +0000 Subject: [PATCH 12/35] Add tests --- .gitignore | 1 + pyproject.toml | 4 ++++ src/microkanren/core.py | 10 ++++---- tests/test_core.py | 53 +++++++++++++++++++++++------------------ 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 9c4d81d..873f7ed 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ prof profiling.py sudoku.py .ccls-cache +.aider* diff --git a/pyproject.toml b/pyproject.toml index 2af4118..a3fdead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,10 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] pythonpath = ["src"] minversion = 7.0 +filterwarnings = [ + "ignore:ast.NameConstant is deprecated:DeprecationWarning", + "ignore:ast.Str is deprecated:DeprecationWarning", +] [tool.ruff] line-length = 88 diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 9a37211..5f82e3f 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -191,9 +191,14 @@ def extend_substitution( def occurs(x: Var, v: Any, sub: Substitution) -> bool: + """ + Does `x' occur in `v' with regards to `sub'? + """ v = walk(v, sub) if isinstance(v, Var): return v == x + elif isinstance(v, cons): + return occurs(x, v.head, sub) or occurs(x, v.tail, sub) elif isinstance(v, list | tuple): return any(occurs(x, term, sub) for term in v) else: @@ -503,8 +508,3 @@ def loop(w: WaitingStream, a: WaitingStream) -> Stream: return loop(w[1:], WaitingStream([w[0], *a])) return loop(w, WaitingStream()) - - -@tabled -def fives(x): - return disj(eq(x, 5), lambda sc: lambda: fives(x)(sc)) diff --git a/tests/test_core.py b/tests/test_core.py index 07fd614..fcd4009 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -60,7 +60,9 @@ def test_empty_state(): def test_unify_basic(): - """Verify that unification works correctly for basic atomic values.""" + """ + Unification works correctly for basic atomic values. + """ s = empty_sub() # Equal atoms unify. assert unify(1, 1, s) == s @@ -71,7 +73,9 @@ def test_unify_basic(): def test_unify_var(): - """Verify that unification works correctly with logic variables.""" + """ + Unification works correctly with logic variables. + """ s = empty_sub() x = Var(0) y = Var(1) @@ -83,7 +87,9 @@ def test_unify_var(): def test_unify_sequences(): - """Verify that unification works correctly for sequences like tuples and lists.""" + """ + Unification works correctly for sequences like tuples and lists. + """ s = empty_sub() # Equal sequences unify. assert unify((1, 2), (1, 2), s) == s @@ -94,47 +100,48 @@ def test_unify_sequences(): def test_mzero_is_empty_stream(): - """Verify that mzero() returns an empty stream.""" + """ + mzero() returns an empty stream. + """ assert mzero() == () def test_unit_creates_stream_with_state(): - """Verify that unit() creates a stream containing a single state followed by an empty stream.""" + """ + unit() creates a stream containing a single state followed by an empty stream. + """ state = empty_state() assert unit(state) == (state, mzero()) def test_pull_evaluates_thunk(): - """Verify that pull() evaluates a thunk to produce its underlying stream.""" + """ + pull() evaluates a thunk to produce its underlying stream. + """ state = empty_state() - thunk = lambda: unit(state) + + def thunk(): + return unit(state) + assert pull(thunk) == unit(state) def test_take_from_unit_stream(): - """Verify that take() correctly extracts elements from a unit stream.""" + """ + take() correctly extracts elements from a unit stream. + """ state = empty_state() assert take(1, unit(state)) == [state] # Taking one element returns the state. - assert take(2, unit(state)) == [state] # Taking more elements still returns just the state. + assert take(2, unit(state)) == [ + state + ] # Taking more elements still returns just the state. def test_take_from_empty_stream(): - """Verify that take() returns an empty list when given an empty stream.""" - assert take(1, mzero()) == [] - - -def test_unify_var_with_incompatible_nested_sequence(): """ - Unify sequences where one contains a variable and the other contains a sequence that should not unify. + take() returns an empty list when given an empty stream. """ - s = empty_sub() - x = Var(0) - - # Unify tuple containing var with tuple containing incompatible nested tuple - assert unify((1, x, 3), (1, (2, 3), 3), s) is SENTINEL - - # Unify list containing var with list containing incompatible nested list - assert unify([1, x, 3], [1, [2, 3], 3], s) is SENTINEL + assert take(1, mzero()) == [] def test_mplus(): From 54804a7c9facd407d89a4a439bf6d246318921b0 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Mon, 28 Oct 2024 22:27:48 +0000 Subject: [PATCH 13/35] feat: Add docstring for call_fresh function --- src/microkanren/core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 5f82e3f..5234692 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -253,6 +253,10 @@ def unify(u: Any, v: Any, s: Substitution) -> Substitution | Sentinel: def call_fresh(f: Callable[[Var], Goal]) -> Goal: + """ + Return a goal that calls `f' (a goal constructor) with a fresh logic variable. + """ + def _goal(state: State) -> Stream: i, sub = state return f(Var(i))(State(i + 1, sub)) @@ -409,6 +413,7 @@ def tabled_goal(state: State) -> Stream: return tabled_goal + tabled_gc._table = table return tabled_gc From 38d402bcdda30b13ab1946a95aae5d6b7f4669ad Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Tue, 29 Oct 2024 19:38:53 +0000 Subject: [PATCH 14/35] feat: Add test suite for tabling functionality --- tests/test_tabling.py | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_tabling.py diff --git a/tests/test_tabling.py b/tests/test_tabling.py new file mode 100644 index 0000000..419ba91 --- /dev/null +++ b/tests/test_tabling.py @@ -0,0 +1,81 @@ +from fastcons import cons +from microkanren.core import ( + disj, + eq, + tabled, + call_fresh, + empty_state, + Var, + Symbol, + State, + empty_sub, +) + + +def test_primary_call_cached(): + @tabled + def goal(x): + return eq(x, 5) + + call_fresh(goal)(empty_state()) + assert goal._table[(Symbol("_.0"),)] == [(5,)] + + +def test_compound_primary_call_cached(): + @tabled + def goal(x): + return eq(x, cons("a", "b")) + + call_fresh(lambda x: call_fresh(lambda y: goal(cons(x, y))))(empty_state()) + args = (cons(Var(0), Var(1)),) + assert goal._table[args] == [(cons("a", "b"),)] + + +def test_primary_call_cached_with_ground_arg(): + @tabled + def goal(x): + return eq(x, 5) + + goal(5)(empty_state()) + assert goal._table[(5,)] == [(5,)] + + +def test_primary_call_arg_walked(): + @tabled + def goal(x): + return eq(x, 5) + + call_fresh(goal)(State(0, empty_sub().set(Var(0), 5))) + assert goal._table[(5,)] == [(5,)] + + +def test_compound_primary_call_arg_walked(): + @tabled + def goal(x): + return eq(x, cons("a", "b")) + + call_fresh(lambda x: call_fresh(lambda y: goal(cons(x, y))))( + State(0, empty_sub().set(Var(0), "a").set(Var(1), "b")) + ) + args = (cons("a", "b"),) + assert goal._table[args] == [(cons("a", "b"),)] + + +def test_caching_idempotent(): + @tabled + def goal(x): + return eq(x, 5) + + call_fresh(goal)(empty_state()) + call_fresh(goal)(empty_state()) + assert goal._table[(Symbol("_.0"),)] == [(5,)] + assert len(goal._table) == 1 + + +def test_multiple_solutions_cached(): + @tabled + def goal(x): + return disj(eq(x, "a"), eq(x, "b")) + + thing = call_fresh(goal)(empty_state()) + assert goal._table[(Symbol("_.0"),)] == [("a",), ("b",)] From 34f48072c33917864ae92bc64d121ea4696103b4 Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Tue, 29 Oct 2024 19:38:54 +0000 Subject: [PATCH 15/35] feat: Add tests for tabling behavior --- tests/test_tabling.py | 95 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/test_tabling.py b/tests/test_tabling.py index 419ba91..aeb55d4 100644 --- a/tests/test_tabling.py +++ b/tests/test_tabling.py @@ -79,3 +79,98 @@ def goal(x): thing = call_fresh(goal)(empty_state()) assert goal._table[(Symbol("_.0"),)] == [("a",), ("b",)] + + +def test_recursive_tabled_goal(): + """ + Test that tabling prevents infinite recursion in recursive goals. + """ + @tabled + def fives(x): + return disj(eq(x, 5), lambda s: fives(x)(s)) + + result = call_fresh(fives)(empty_state()) + # Should only return one result (5) instead of infinitely recurring + assert len(take(5, result)) == 1 + + +def test_mutual_recursion_tabled(): + """ + Test that tabling works with mutually recursive goals. + """ + @tabled + def odds(x): + return disj( + eq(x, 1), + call_fresh(lambda y: conj( + eq(x, cons(2, y)), + evens(y) + )) + ) + + @tabled + def evens(x): + return disj( + eq(x, 2), + call_fresh(lambda y: conj( + eq(x, cons(1, y)), + odds(y) + )) + ) + + result = call_fresh(odds)(empty_state()) + # Should generate a finite number of odd/even alternating sequences + assert len(take(5, result)) > 0 + + +def test_reuse_cached_results(): + """ + Test that cached results are properly reused in subsequent calls. + """ + @tabled + def goal(x): + return disj(eq(x, 1), eq(x, 2)) + + # Make first call to populate cache + s1 = call_fresh(goal)(empty_state()) + assert len(goal._table) == 1 + + # Make second call - should reuse cached results + s2 = call_fresh(goal)(empty_state()) + # Results should be identical + assert take(2, s1) == take(2, s2) + + +def test_tabled_with_multiple_vars(): + """ + Test tabling behavior with goals that involve multiple variables. + """ + @tabled + def pair_sum(x, y, sum): + return disj( + conj(eq(x, 1), conj(eq(y, 2), eq(sum, 3))), + conj(eq(x, 2), conj(eq(y, 2), eq(sum, 4))) + ) + + result = call_fresh(lambda a: + call_fresh(lambda b: + call_fresh(lambda c: + pair_sum(a, b, c))))(empty_state()) + # Should cache and return both solutions + assert len(take(2, result)) == 2 + + +def test_tabled_alpha_equivalence(): + """ + Test that tabling correctly handles alpha-equivalent terms. + """ + @tabled + def goal(x, y): + return eq(x, y) + + # These calls should be considered equivalent + s1 = call_fresh(lambda a: call_fresh(lambda b: goal(a, b)))(empty_state()) + s2 = call_fresh(lambda c: call_fresh(lambda d: goal(c, d)))(empty_state()) + + # Should use same cache entry + assert len(goal._table) == 1 From 8149dc2ae2b707f9de2c0f3e37f64f3eed1fbfb2 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Tue, 29 Oct 2024 20:32:25 +0000 Subject: [PATCH 16/35] Add pytest conf for hy --- pyproject.toml | 10 ++++++++++ src/microkanren/{hy.hy => lang.hy} | 20 ++++++++++++++++++-- tests/conftest.py | 7 +++++++ tests/test_lang.hy | 6 ++++++ uv.lock | 28 ++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) rename src/microkanren/{hy.hy => lang.hy} (55%) create mode 100644 tests/conftest.py create mode 100644 tests/test_lang.hy diff --git a/pyproject.toml b/pyproject.toml index a3fdead..5b28225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,10 @@ license = {file = "LICENSE"} build = ["hatch == 1.7.0"] hy = [ "hy>=1.0.0", + "hyrule>=0.7.0", +] +debug = [ + "debugpy>=1.8.7", ] [build-system] @@ -39,9 +43,14 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] pythonpath = ["src"] minversion = 7.0 +python_functions=[ + "test_*", + "hyx_test_*", +] filterwarnings = [ "ignore:ast.NameConstant is deprecated:DeprecationWarning", "ignore:ast.Str is deprecated:DeprecationWarning", + "ignore::pytest.PytestReturnNotNoneWarning", ] [tool.ruff] @@ -85,6 +94,7 @@ lines-after-imports = 2 [tool.pyright] pythonVersion = "3.12" stubPath = "" +reportFunctionMemberAccess = "warning" [tool.uv] dev-dependencies = [ diff --git a/src/microkanren/hy.hy b/src/microkanren/lang.hy similarity index 55% rename from src/microkanren/hy.hy rename to src/microkanren/lang.hy index 520c36b..1c52707 100644 --- a/src/microkanren/hy.hy +++ b/src/microkanren/lang.hy @@ -1,4 +1,6 @@ -(import mk-tabling [core]) +(import microkanren [core]) +(import hy) +(require hyrule.destructure [setv+]) (setv call/fresh core.call-fresh) (setv == core.eq) @@ -32,7 +34,21 @@ (defmacro run [n lvars #* goals] `(lfor state (core.take ~n ((fresh ~lvars ~@goals)(core.empty-state))) - (core.reify (tuple (gfor i (range ~(len lvars)) (core.Var i))) state.sub))) + (core.reify (tuple (gfor i (range ~(len lvars)) (core.Var i))) state.sub))) (defmacro run* [lvars #* goals] (hy.macroexpand `(run -1 ~lvars ~@goals))) + +(defmacro defne [name args #* body] + "Accept list patterns only, that match the arity of `args'." + `(defn ~name ~args + (disj+ + ~@(map (fn [case] + (when (not (isinstance case hy.models.Expression)) + (raise (ValueError "defne case must be a hy.models.Expression"))) + (when (not case) + (raise (ValueError "defne case must be non-empty"))) + (setv+ [head rest] case) + (print (type head)) + `(~head ~@rest)) + body)))) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ef47d01 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import hy # noqa: F401 +import pytest + + +def pytest_collect_file(file_path, parent): + if file_path.name.startswith("test_") and file_path.suffix == ".hy": + return pytest.Module.from_parent(parent, path=file_path) diff --git a/tests/test_lang.hy b/tests/test_lang.hy new file mode 100644 index 0000000..05c1605 --- /dev/null +++ b/tests/test_lang.hy @@ -0,0 +1,6 @@ +(require microkanren.lang *) +(import microkanren.lang *) + +(defn test-run*-eq [] + (assert (= (run* [q] (== q 5) ) + [#(5)]))) diff --git a/uv.lock b/uv.lock index 7b8fdc1..9095f0a 100644 --- a/uv.lock +++ b/uv.lock @@ -112,6 +112,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, ] +[[package]] +name = "debugpy" +version = "1.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/00/5a8b5dc8f52617c5e41845e26290ebea1ba06377cc08155b6d245c27b386/debugpy-1.8.7.zip", hash = "sha256:18b8f731ed3e2e1df8e9cdaa23fb1fc9c24e570cd0081625308ec51c82efe42e", size = 4957835 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4b/9f52ca1a799601a10cd2673503658bd8c8ecc4a7a43302ee29cf062474ec/debugpy-1.8.7-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:4d27d842311353ede0ad572600c62e4bcd74f458ee01ab0dd3a1a4457e7e3706", size = 2529803 }, + { url = "https://files.pythonhosted.org/packages/80/79/8bba39190d2ea17840925d287f1c6c3a7c60b58f5090444e9ecf176c540f/debugpy-1.8.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c1fd62ae0356e194f3e7b7a92acd931f71fe81c4b3be2c17a7b8a4b546ec2", size = 4170911 }, + { url = "https://files.pythonhosted.org/packages/3b/19/5b3d312936db8eb281310fa27903459328ed722d845d594ba5feaeb2f0b3/debugpy-1.8.7-cp312-cp312-win32.whl", hash = "sha256:2f729228430ef191c1e4df72a75ac94e9bf77413ce5f3f900018712c9da0aaca", size = 5195476 }, + { url = "https://files.pythonhosted.org/packages/9f/49/ad20b29f8c921fd5124530d3d39b8f2077efd51b71339a2eff02bba693e9/debugpy-1.8.7-cp312-cp312-win_amd64.whl", hash = "sha256:45c30aaefb3e1975e8a0258f5bbd26cd40cde9bfe71e9e5a7ac82e79bad64e39", size = 5235031 }, + { url = "https://files.pythonhosted.org/packages/51/b1/a0866521c71a6ae3d3ca320e74835163a4671b1367ba360a55a0a51e5a91/debugpy-1.8.7-py2.py3-none-any.whl", hash = "sha256:57b00de1c8d2c84a61b90880f7e5b6deaf4c312ecbde3a0e8912f2a56c4ac9ae", size = 5210683 }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -260,6 +273,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638 }, ] +[[package]] +name = "hyrule" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/f6/55dbed29f7b16b393d5aba61bd69762cc836089e1ff922779b09f7dd8827/hyrule-0.7.0.tar.gz", hash = "sha256:3a4b3568abfe16d16895e584c0db84c1251d258fe234ba86f072906b90545ce8", size = 32955 } + [[package]] name = "idna" version = "3.10" @@ -388,8 +410,12 @@ dependencies = [ build = [ { name = "hatch" }, ] +debug = [ + { name = "debugpy" }, +] hy = [ { name = "hy" }, + { name = "hyrule" }, ] [package.dependency-groups] @@ -403,9 +429,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "debugpy", marker = "extra == 'debug'", specifier = ">=1.8.7" }, { name = "fastcons", specifier = "==0.4.1" }, { name = "hatch", marker = "extra == 'build'", specifier = "==1.7.0" }, { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0" }, + { name = "hyrule", marker = "extra == 'hy'", specifier = ">=0.7.0" }, { name = "immutables", specifier = ">=0.19,<1" }, { name = "pyrsistent", specifier = ">=0.19,<1" }, ] From bab388e9a14cd3f540931062b2343e3e4509c75d Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Tue, 29 Oct 2024 21:37:25 +0000 Subject: [PATCH 17/35] fix: Fix occurs check in extend_substitution --- tests/test_core.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index fcd4009..278cb61 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -22,12 +22,14 @@ def test_extend_substitution(): val = object() - s = extend_substitution(Var(0), val, empty_sub()) - assert walk(Var(0), s) is val + x = Var("x") + s = extend_substitution(x, val, empty_sub()) + assert walk(x, s) is val def test_walk_unbound_var(): - assert walk(Var(0), empty_sub()) == Var(0) + x = Var("x") + assert walk(x, empty_sub()) == x def test_walk_unbound_value(): @@ -36,10 +38,9 @@ def test_walk_unbound_value(): def test_recursive_walk(): val = object() - s = extend_substitution( - Var(0), Var(1), extend_substitution(Var(1), val, empty_sub()) - ) - assert walk(Var(0), s) is val + x, y = Var("x"), Var("y") + s = extend_substitution(x, y, extend_substitution(y, val, empty_sub())) + assert walk(x, s) is val def test_symbol_equality(): @@ -55,7 +56,6 @@ def test_symbol_equality(): def test_empty_state(): state = empty_state() - assert state.counter == 0 assert state.sub == empty_sub() @@ -77,12 +77,12 @@ def test_unify_var(): Unification works correctly with logic variables. """ s = empty_sub() - x = Var(0) - y = Var(1) + x = Var("x") + y = Var("y") # Var unifies with anything. assert unify(x, 1, s) == extend_substitution(x, 1, s) assert unify(1, x, s) == extend_substitution(x, 1, s) - # Two vars unify. + # Two distinct, fresh vars unify. assert unify(x, y, s) == extend_substitution(x, y, s) @@ -156,14 +156,17 @@ def test_mplus(): @pytest.mark.parametrize( "val", [ - Var(0), - cons(Var(0), nil()), - (Var(0), Var(0)), - [Var(0)], - [[Var(0)]], - ([Var(0)],), + Var("x", _id=0), + cons(Var("x", _id=0), nil()), + cons(nil(), Var("x", _id=0)), + (Var("x", _id=0), Var("x", _id=0)), + ("foo", Var("x", _id=0)), + [Var("x", _id=0)], + [0, Var("x", _id=0)], + [[Var("x", _id=0)]], + ([Var("x", _id=0)],), ], ) def test_occurs_check_raises(val): with pytest.raises(OccursError): - extend_substitution(Var(0), val, empty_sub()) + extend_substitution(Var("x", _id=0), val, empty_sub()) From 7206e2736424c0200fed8ddf2396a33a4a8870f2 Mon Sep 17 00:00:00 2001 From: "Joshua Munn (aider)" Date: Tue, 29 Oct 2024 21:37:25 +0000 Subject: [PATCH 18/35] feat: Add pytest.mark.parametrize to test_unify_sequences --- tests/test_core.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 278cb61..0af00f8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -86,17 +86,25 @@ def test_unify_var(): assert unify(x, y, s) == extend_substitution(x, y, s) -def test_unify_sequences(): +@pytest.mark.parametrize( + ("seq1", "seq2", "expected"), + [ + # Equal sequences unify + ((1, 2), (1, 2), empty_sub()), + ([1, 2], [1, 2], empty_sub()), + (cons(1, cons(2, nil())), cons(1, cons(2, nil())), empty_sub()), + # Different sequences don't unify + ((1, 2), (1, 3), SENTINEL), + ([1, 2], [1], SENTINEL), + (cons(1, cons(2, nil())), cons(1, nil()), SENTINEL), + ], +) +def test_unify_sequences(seq1, seq2, expected): """ - Unification works correctly for sequences like tuples and lists. + Unification works correctly for sequences like tuples, lists, and cons pairs. """ s = empty_sub() - # Equal sequences unify. - assert unify((1, 2), (1, 2), s) == s - assert unify([1, 2], [1, 2], s) == s - # Different sequences don't unify. - assert unify((1, 2), (1, 3), s) is SENTINEL - assert unify([1, 2], [1], s) is SENTINEL + assert unify(seq1, seq2, s) == expected def test_mzero_is_empty_stream(): From 69ed445b3b364ec5acbfe816cf44c0a4eec6ec7a Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 3 Nov 2024 20:57:49 +0000 Subject: [PATCH 19/35] Bump fastcons --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b28225..3872e5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "pyrsistent >= 0.19, < 1", - "fastcons == 0.4.1", + "fastcons == 0.5.0", "immutables >= 0.19, < 1", ] license = {file = "LICENSE"} diff --git a/uv.lock b/uv.lock index 9095f0a..e5eea5f 100644 --- a/uv.lock +++ b/uv.lock @@ -136,14 +136,14 @@ wheels = [ [[package]] name = "fastcons" -version = "0.4.1" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/94/f4947083d04781447a14210def1040d509287383a5fc106a0a29fff0d86c/fastcons-0.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bbd3475bbc1aaee8fab59bab086a418a05853e6ed84c7c9870f545ddfae164e7", size = 10873 }, - { url = "https://files.pythonhosted.org/packages/76/18/5735a6d5a533f1049d3694df62f2284381b1577335b20e9a69ee671ee2a1/fastcons-0.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:634fb6950e7f26d38d7ca31dd020ed9a6ee0e308f5aeab43eb0b5e9d9ecfdf07", size = 33790 }, - { url = "https://files.pythonhosted.org/packages/ae/65/961951bba7fa910e345d6dde319924d26e5b4b7a7e6058cbd1e109bf5f5c/fastcons-0.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbd2c8e462001c7bc545965303b88c9ce693211a00edfea8a0cd17b0f1b6079b", size = 36351 }, - { url = "https://files.pythonhosted.org/packages/42/15/bfcaadd7e6498f1aa515935aca7a0c0ee2f07c8702f34a1cfd0dddce2a4c/fastcons-0.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e83b81be03032f66617f9cfc1c21c38981f8767ced8df9781e1f93ecd31076be", size = 36860 }, - { url = "https://files.pythonhosted.org/packages/c4/47/ffc7868cfc321b086762e75ce175a3eae7a820813c3d3737b04e0fc04f03/fastcons-0.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9209403ade04ea8757faf36d2a5db0db4092b853830627e950d9897ad28e9cf7", size = 38916 }, + { url = "https://files.pythonhosted.org/packages/63/57/eafd1c55d5af2adcef889400d7350aff2f5235bb9429474ce772c50c4d6e/fastcons-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fdcef9400059696946a9262b6dc2b78675de1754eba4ade47a4019f148e2a27f", size = 11460 }, + { url = "https://files.pythonhosted.org/packages/a5/bf/704243be945ca6f343a94dfc75fd7fafa87316dc2ece9d50e9afbe6f26b4/fastcons-0.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:905dcae823ddc5b4d7841baf4aeb3bb78f3ab1ac6c3669740411983e7d50aaa6", size = 34263 }, + { url = "https://files.pythonhosted.org/packages/95/65/0276857a8fe8d449278e339f4f20ee48e7314867519eaf5ed0f27b6270da/fastcons-0.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a69d20f6b9b7f89798b0e92e6c6bd7e7d38d2624c5d0d9226ca2d5f2ad97964", size = 36974 }, + { url = "https://files.pythonhosted.org/packages/70/2b/d73bef709d7066c182bf8c022ad741949a71f97c96d14397835ae619b985/fastcons-0.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2daf9037f7b4b77434756193f48c86e02810ecde7e495f98f52e989f7f03d5c2", size = 33472 }, + { url = "https://files.pythonhosted.org/packages/76/33/4c391d66999215b031161ce6aeec23732884113edee7ccbe47df80ec81c8/fastcons-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:208efb6dc1324dceafc74fbd85c3ece4fe001de6353a0298ba1d55ee6a99748c", size = 35476 }, ] [[package]] @@ -430,7 +430,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "debugpy", marker = "extra == 'debug'", specifier = ">=1.8.7" }, - { name = "fastcons", specifier = "==0.4.1" }, + { name = "fastcons", specifier = "==0.5.0" }, { name = "hatch", marker = "extra == 'build'", specifier = "==1.7.0" }, { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0" }, { name = "hyrule", marker = "extra == 'hy'", specifier = ">=0.7.0" }, From 0bf86b7879770cd12f249f9fe91e08e168ec68ad Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 3 Nov 2024 21:39:48 +0000 Subject: [PATCH 20/35] Update Var representation to use module-scoped counter for ID --- src/microkanren/core.py | 65 +++++++++++------------ tests/test_core.py | 4 +- tests/test_tabling.py | 114 +++++++++++++++++++++------------------- 3 files changed, 93 insertions(+), 90 deletions(-) diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 5234692..42863af 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -1,3 +1,4 @@ +import itertools from collections.abc import Callable from functools import reduce from typing import ( @@ -13,6 +14,10 @@ import immutables from fastcons import cons +# We could rely on Var object identity, but this might make tests +# simpler. +_COUNTER = itertools.count() + class OccursError(Exception): ... @@ -24,33 +29,27 @@ class Sentinel: ... class Var: - i: int - _cache: ClassVar[dict[int, Self] | None] = None - __match_args__ = ("i",) - - def __new__(cls, i) -> Self: - if (cache := cls._cache) is None: - cache = {} - cls._cache = cache - - if i in cache: - return cache[i] - - instance = super().__new__(cls) - cache[i] = instance - return instance + name: str + _id: int + __match_args__ = ("name",) - def __init__(self, i: int): - self.i = i + def __init__(self, name: str | int, _id: int | None = None): + self.name = str(name) + self._id = next(_COUNTER) if _id is None else _id def __repr__(self) -> str: - return f"Var({self.i})" + return f"Var({self.name})" + + def __str__(self) -> str: + return f"?{self.name}" def __hash__(self): - return hash((self.__class__, self.i)) + return hash((self.__class__, self._id)) def __eq__(self, other): - return other is self + if not isinstance(other, self.__class__): + return False + return self._id == other._id class Symbol: @@ -93,7 +92,6 @@ def empty_sub() -> Substitution: class State(NamedTuple): - counter: int sub: Substitution @@ -158,7 +156,7 @@ def empty(stream: Stream): def empty_state(): - return State(0, empty_sub()) + return State(empty_sub()) def walk(candidate: Any, sub: Substitution) -> Any: @@ -178,6 +176,8 @@ def deep_walk(candidate: Any, sub: Substitution) -> Any: if isinstance(candidate, list | tuple): container = type(candidate) return container(deep_walk(x, sub) for x in candidate) + elif isinstance(candidate, cons): + return cons(deep_walk(candidate.head, sub), deep_walk(candidate.tail, sub)) else: return candidate @@ -222,7 +222,7 @@ def _eq(state: State) -> EmptyStream | ReadyStream: maybe_sub: Substitution | Sentinel = unify(u, v, state.sub) if isinstance(maybe_sub, Sentinel): return mzero() - return unit(State(state.counter, maybe_sub)) + return unit(State(maybe_sub)) return _eq @@ -258,8 +258,7 @@ def call_fresh(f: Callable[[Var], Goal]) -> Goal: """ def _goal(state: State) -> Stream: - i, sub = state - return f(Var(i))(State(i + 1, sub)) + return f(Var(f.__code__.co_varnames[0]))(state) return _goal @@ -357,7 +356,7 @@ def reify_symbol(i: int) -> Symbol: def make_reify(representation): - def reify(v, s): + def reify(v: Var, s: Substitution): v = deep_walk(v, s) return deep_walk(v, reify_sub(representation, v, empty_sub())) @@ -374,15 +373,14 @@ def reify_sub(representation: Callable, v: Any, sub: Substitution) -> Substituti return sub -# Reify unbound logic variables as Symbols +# Reify unbound logic variables as Symbols. reify = make_reify(reify_symbol) -# Reify unbound logic variables as fresh logic variables +# Reify unbound logic variables as fresh logic variables. reify_var = make_reify(Var) -# Reify as like reify_var, but transform i with f(i) = -(1+i). This -# prevents circular mappings (e.g. {Var(0) → Var(0)}). -reify_tabled_var = make_reify(lambda i: Var(-(1 + i))) +# Apparently like Prolog's copy_term/2. +reify_tabled_var = make_reify(lambda i: Var(str(i))) ### Tabling @@ -419,7 +417,7 @@ def tabled_goal(state: State) -> Stream: def primary_tabled_call(args: tuple, cache: TableCache) -> Goal: def _goal(state: State) -> Stream: - _, sub = state + (sub,) = state reified_args = reify(args, sub) # Check if the result is alpha-equivalent to any previous cached result. @@ -452,13 +450,12 @@ def loop(cached_results): ) else: head, *tail = cached_results - counter, sub = state + (sub,) = state # Produce a new state that is the result of unifying # the secondary call's args with the first cached # result. next_state = State( - counter, subunify(args, reify_tabled_var(head, sub), sub), ) diff --git a/tests/test_core.py b/tests/test_core.py index 0af00f8..28eb74d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -155,10 +155,10 @@ def test_take_from_empty_stream(): def test_mplus(): state = empty_state() s1 = unit(state) - s2 = unit(State(1, empty_sub())) + s2 = unit(State(empty_sub())) # Combine two streams combined = mplus(s1, s2) - assert take(2, combined) == [state, State(1, empty_sub())] + assert take(2, combined) == [state, State(empty_sub())] @pytest.mark.parametrize( diff --git a/tests/test_tabling.py b/tests/test_tabling.py index aeb55d4..a2725b9 100644 --- a/tests/test_tabling.py +++ b/tests/test_tabling.py @@ -1,14 +1,18 @@ from fastcons import cons + from microkanren.core import ( - disj, - eq, - tabled, + State, + Symbol, + Var, call_fresh, + conj, + disj, empty_state, - Var, - Symbol, - State, empty_sub, + eq, + reify, + tabled, + take, ) @@ -26,8 +30,9 @@ def test_compound_primary_call_cached(): def goal(x): return eq(x, cons("a", "b")) - call_fresh(lambda x: call_fresh(lambda y: goal(cons(x, y))))(empty_state()) - args = (cons(Var(0), Var(1)),) + x, y = Var(0), Var(1) + args = (cons(x, y),) + goal(*args)(empty_state()) assert goal._table[args] == [(cons("a", "b"),)] @@ -45,18 +50,24 @@ def test_primary_call_arg_walked(): def goal(x): return eq(x, 5) - call_fresh(goal)(State(0, empty_sub().set(Var(0), 5))) + x = Var(0) + goal(x)(State(empty_sub().set(x, 5))) assert goal._table[(5,)] == [(5,)] def test_compound_primary_call_arg_walked(): + """ + Arguments to a tabled goal are walked before being used as keys in the table. + """ + @tabled def goal(x): return eq(x, cons("a", "b")) - call_fresh(lambda x: call_fresh(lambda y: goal(cons(x, y))))( - State(0, empty_sub().set(Var(0), "a").set(Var(1), "b")) - ) + x, y = Var("x"), Var("y") + initial_state = State(empty_sub().set(x, "a").set(y, "b")) + goal(cons(x, y))(initial_state) + args = (cons("a", "b"),) assert goal._table[args] == [(cons("a", "b"),)] @@ -77,18 +88,19 @@ def test_multiple_solutions_cached(): def goal(x): return disj(eq(x, "a"), eq(x, "b")) - thing = call_fresh(goal)(empty_state()) + call_fresh(goal)(empty_state()) assert goal._table[(Symbol("_.0"),)] == [("a",), ("b",)] def test_recursive_tabled_goal(): """ - Test that tabling prevents infinite recursion in recursive goals. + Tabling prevents infinite recursion in trivially recursive goals. """ + @tabled def fives(x): return disj(eq(x, 5), lambda s: fives(x)(s)) - + result = call_fresh(fives)(empty_state()) # Should only return one result (5) instead of infinitely recurring assert len(take(5, result)) == 1 @@ -96,81 +108,75 @@ def fives(x): def test_mutual_recursion_tabled(): """ - Test that tabling works with mutually recursive goals. + Tabling works with mutually recursive goals. """ - @tabled - def odds(x): + + @tabled + def fives_and_sixes(x): return disj( - eq(x, 1), - call_fresh(lambda y: conj( - eq(x, cons(2, y)), - evens(y) - )) + eq(x, 5), + sixes_and_fives(x), ) - + @tabled - def evens(x): + def sixes_and_fives(x): return disj( - eq(x, 2), - call_fresh(lambda y: conj( - eq(x, cons(1, y)), - odds(y) - )) + eq(x, 6), + fives_and_sixes(x), ) - result = call_fresh(odds)(empty_state()) - # Should generate a finite number of odd/even alternating sequences - assert len(take(5, result)) > 0 + result = call_fresh(fives_and_sixes)(empty_state()) + assert len(take(5, result)) == 2 def test_reuse_cached_results(): """ Test that cached results are properly reused in subsequent calls. """ + @tabled def goal(x): return disj(eq(x, 1), eq(x, 2)) - + # Make first call to populate cache - s1 = call_fresh(goal)(empty_state()) + x = Var(0) + s1 = goal(x)(empty_state()) assert len(goal._table) == 1 - + # Make second call - should reuse cached results - s2 = call_fresh(goal)(empty_state()) + s2 = goal(x)(empty_state()) + + r1 = [reify(x, state.sub) for state in take(2, s1)] + r2 = [reify(x, state.sub) for state in take(2, s2)] + # Results should be identical - assert take(2, s1) == take(2, s2) + assert r1 == r2 def test_tabled_with_multiple_vars(): """ Test tabling behavior with goals that involve multiple variables. """ + @tabled - def pair_sum(x, y, sum): - return disj( - conj(eq(x, 1), conj(eq(y, 2), eq(sum, 3))), - conj(eq(x, 2), conj(eq(y, 2), eq(sum, 4))) - ) - - result = call_fresh(lambda a: - call_fresh(lambda b: - call_fresh(lambda c: - pair_sum(a, b, c))))(empty_state()) - # Should cache and return both solutions - assert len(take(2, result)) == 2 + def goal(x, y): + return conj(eq(x, 1), eq(y, 2)) + + call_fresh(lambda x: call_fresh(lambda y: goal(x, y)))(empty_state()) + assert len(goal._table) == 1 def test_tabled_alpha_equivalence(): """ Test that tabling correctly handles alpha-equivalent terms. """ + @tabled def goal(x, y): return eq(x, y) - + # These calls should be considered equivalent - s1 = call_fresh(lambda a: call_fresh(lambda b: goal(a, b)))(empty_state()) - s2 = call_fresh(lambda c: call_fresh(lambda d: goal(c, d)))(empty_state()) - + conj(goal(Var(0), Var(1)), goal(Var(2), Var(3)))(empty_state()) + # Should use same cache entry assert len(goal._table) == 1 From 6f23d8049d635e503513a958dc80c4c09005f8c2 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 3 Nov 2024 21:59:19 +0000 Subject: [PATCH 21/35] Make tabling tests more explicit --- src/microkanren/core.py | 2 +- tests/test_tabling.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 42863af..47e3c90 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -356,7 +356,7 @@ def reify_symbol(i: int) -> Symbol: def make_reify(representation): - def reify(v: Var, s: Substitution): + def reify(v: Any, s: Substitution): v = deep_walk(v, s) return deep_walk(v, reify_sub(representation, v, empty_sub())) diff --git a/tests/test_tabling.py b/tests/test_tabling.py index a2725b9..ebf287c 100644 --- a/tests/test_tabling.py +++ b/tests/test_tabling.py @@ -162,8 +162,11 @@ def test_tabled_with_multiple_vars(): def goal(x, y): return conj(eq(x, 1), eq(y, 2)) - call_fresh(lambda x: call_fresh(lambda y: goal(x, y)))(empty_state()) + x, y = Var(0), Var(1) + goal(x, y)(empty_state()) + assert len(goal._table) == 1 + assert goal._table[(Symbol("_.0"), Symbol("_.1"))] == [(1, 2)] def test_tabled_alpha_equivalence(): @@ -175,8 +178,16 @@ def test_tabled_alpha_equivalence(): def goal(x, y): return eq(x, y) + a, b, x, y = Var("a"), Var("b"), Var("x"), Var("y") + # These calls should be considered equivalent - conj(goal(Var(0), Var(1)), goal(Var(2), Var(3)))(empty_state()) + result = take( + 5, + conj(goal(a, b), goal(x, y))(empty_state()), + ) + r1 = [reify((a, b), state.sub) for state in result] + r2 = [reify((x, y), state.sub) for state in result] + assert r1 == r2 # Should use same cache entry assert len(goal._table) == 1 From 86e0b248fca52ecf961fde2a4d806a57b1df5624 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 3 Nov 2024 22:05:26 +0000 Subject: [PATCH 22/35] Test UV in CI --- .github/workflows/test.yml | 31 +++++++++++++---- pyproject.toml | 16 ++++----- tox.ini | 5 +-- uv.lock | 68 +++++++------------------------------- 4 files changed, 46 insertions(+), 74 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78446a2..1200aa7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,19 +25,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - uses: actions/setup-python@v4 with: python-version: ${{env.PYTHON_LATEST}} - - uses: pre-commit/action@v3.0.0 + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + uv venv + uv sync --all-extras + . .venv/bin/activate + - name: Run ruff + run: | + . .venv/bin/activate + ruff check . + ruff format --check . test: runs-on: ubuntu-latest needs: lint strategy: matrix: - python: ['3.11', '3.12'] + python: ['3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -45,9 +54,17 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install tox tox-gh-actions + uv venv + . .venv/bin/activate + uv pip install -e '.[dev,hy]' - name: Test with tox - run: tox -- -v + run: | + . .venv/bin/activate + tox -- -v diff --git a/pyproject.toml b/pyproject.toml index 3872e5c..e3eda6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,16 @@ license = {file = "LICENSE"} "Bug Tracker" = "https://github.com/jams2/microkanren/issues" [project.optional-dependencies] -build = ["hatch == 1.7.0"] hy = [ "hy>=1.0.0", "hyrule>=0.7.0", ] -debug = [ +dev = [ + "hatch == 1.7.0", + "ruff>=0.7.1", + "pyright>=1.1.377", + "tox==4.11.3", + "pytest==7.2.2", "debugpy>=1.8.7", ] @@ -97,10 +101,4 @@ stubPath = "" reportFunctionMemberAccess = "warning" [tool.uv] -dev-dependencies = [ - "ruff>=0.7.1", - "pyright>=1.1.377", - "tox==4.11.3", - "pytest==7.2.2", - "pytest-profiling==1.7.0", -] +dev-dependencies = [] diff --git a/tox.ini b/tox.ini index 30326b3..71de48e 100644 --- a/tox.ini +++ b/tox.ini @@ -5,8 +5,9 @@ minversion = 4.11.3 [testenv] description = run the tests with pytest -deps = - pytest==7.2.2 +extras = + hy + dev commands = pytest {tty:--color=yes} {posargs:tests} diff --git a/uv.lock b/uv.lock index e5eea5f..fbb9838 100644 --- a/uv.lock +++ b/uv.lock @@ -164,15 +164,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/66/acd740d84f59c655935f586c113a863aa404dfe932052a68a1163d88ea63/funcparserlib-1.0.1-py2.py3-none-any.whl", hash = "sha256:95da15d3f0d00b9b6f4bf04005c708af3faa115f7b45692ace064ebe758c68e8", size = 17842 }, ] -[[package]] -name = "gprof2dot" -version = "2024.6.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/32/11/16fc5b985741378812223f2c6213b0a95cda333b797def622ac702d28e81/gprof2dot-2024.6.6.tar.gz", hash = "sha256:fa1420c60025a9eb7734f65225b4da02a10fc6dd741b37fa129bc6b41951e5ab", size = 36536 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/27/15c4d20871a86281e2bacde9e9f634225d1c2ed0db072f98acf201022411/gprof2dot-2024.6.6-py2.py3-none-any.whl", hash = "sha256:45b14ad7ce64e299c8f526881007b9eb2c6b75505d5613e96e66ee4d5ab33696", size = 34763 }, -] - [[package]] name = "h11" version = "0.14.0" @@ -407,44 +398,32 @@ dependencies = [ ] [package.optional-dependencies] -build = [ - { name = "hatch" }, -] -debug = [ - { name = "debugpy" }, -] -hy = [ - { name = "hy" }, - { name = "hyrule" }, -] - -[package.dependency-groups] dev = [ + { name = "debugpy" }, + { name = "hatch" }, { name = "pyright" }, { name = "pytest" }, - { name = "pytest-profiling" }, { name = "ruff" }, { name = "tox" }, ] +hy = [ + { name = "hy" }, + { name = "hyrule" }, +] [package.metadata] requires-dist = [ - { name = "debugpy", marker = "extra == 'debug'", specifier = ">=1.8.7" }, + { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.7" }, { name = "fastcons", specifier = "==0.5.0" }, - { name = "hatch", marker = "extra == 'build'", specifier = "==1.7.0" }, + { name = "hatch", marker = "extra == 'dev'", specifier = "==1.7.0" }, { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0" }, { name = "hyrule", marker = "extra == 'hy'", specifier = ">=0.7.0" }, { name = "immutables", specifier = ">=0.19,<1" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.377" }, { name = "pyrsistent", specifier = ">=0.19,<1" }, -] - -[package.metadata.dependency-groups] -dev = [ - { name = "pyright", specifier = ">=1.1.377" }, - { name = "pytest", specifier = "==7.2.2" }, - { name = "pytest-profiling", specifier = "==1.7.0" }, - { name = "ruff", specifier = ">=0.7.1" }, - { name = "tox", specifier = "==4.11.3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==7.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.1" }, + { name = "tox", marker = "extra == 'dev'", specifier = "==4.11.3" }, ] [[package]] @@ -602,20 +581,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/68/5321b5793bd506961bd40bdbdd0674e7de4fb873ee7cab33dd27283ad513/pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", size = 317207 }, ] -[[package]] -name = "pytest-profiling" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gprof2dot" }, - { name = "pytest" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/70/22a4b33739f07f1732a63e33bbfbf68e0fa58cfba9d200e76d01921eddbf/pytest-profiling-1.7.0.tar.gz", hash = "sha256:93938f147662225d2b8bd5af89587b979652426a8a6ffd7e73ec4a23e24b7f29", size = 30985 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/71/cdb746eaee0d3be65fd777b4ac821f5f051063f3084d4a200ecfd7f7ab40/pytest_profiling-1.7.0-py2.py3-none-any.whl", hash = "sha256:999cc9ac94f2e528e3f5d43465da277429984a1c237ae9818f8cfd0b06acb019", size = 8255 }, -] - [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -685,15 +650,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] -[[package]] -name = "six" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, -] - [[package]] name = "sniffio" version = "1.3.1" From 89fcf27f9b60d45cb77b0cb45d609372f08298d6 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 3 Nov 2024 22:26:21 +0000 Subject: [PATCH 23/35] Add temporary fix for run in lang.hy --- src/microkanren/lang.hy | 7 +++++-- tests/test_lang.hy | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/microkanren/lang.hy b/src/microkanren/lang.hy index 1c52707..20e66ba 100644 --- a/src/microkanren/lang.hy +++ b/src/microkanren/lang.hy @@ -33,8 +33,11 @@ `(fresh ~lvars (&& #* goals))) (defmacro run [n lvars #* goals] - `(lfor state (core.take ~n ((fresh ~lvars ~@goals)(core.empty-state))) - (core.reify (tuple (gfor i (range ~(len lvars)) (core.Var i))) state.sub))) + `(lfor + x + (core.take ~n ((fresh ~lvars ~@goals (fn [s] (core.unit (core.reify ~(tuple lvars) s.sub)))) + (core.empty-state))) + x)) (defmacro run* [lvars #* goals] (hy.macroexpand `(run -1 ~lvars ~@goals))) diff --git a/tests/test_lang.hy b/tests/test_lang.hy index 05c1605..5563001 100644 --- a/tests/test_lang.hy +++ b/tests/test_lang.hy @@ -2,5 +2,5 @@ (import microkanren.lang *) (defn test-run*-eq [] - (assert (= (run* [q] (== q 5) ) + (assert (= (run* [q] (== q 5)) [#(5)]))) From 2daebd35267c37fdc149175535ac622cb92553d1 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Thu, 7 Nov 2024 21:57:15 +0000 Subject: [PATCH 24/35] Get correct var names in call_fresh for tabled forms --- src/microkanren/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 47e3c90..d548e66 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -258,7 +258,10 @@ def call_fresh(f: Callable[[Var], Goal]) -> Goal: """ def _goal(state: State) -> Stream: - return f(Var(f.__code__.co_varnames[0]))(state) + # If the goal constructor was tabled, get the actual goal + # constructor function so the var name is relevant. + actual_gc = getattr(f, "__wrapped_goal_constructor__", f) + return f(Var(actual_gc.__code__.co_varnames[0]))(state) return _goal @@ -412,6 +415,7 @@ def tabled_goal(state: State) -> Stream: return tabled_goal tabled_gc._table = table + tabled_gc.__wrapped_goal_constructor__ = gc return tabled_gc From e07c07db01c6262c209e51bd2a6a33dc0bc36d44 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Thu, 7 Nov 2024 22:30:39 +0000 Subject: [PATCH 25/35] Add more tabling tests https://raw.githubusercontent.com/webyrd/dissertation-single-spaced/master/thesis.pdf#page=128 --- pyproject.toml | 2 +- src/microkanren/core.py | 2 +- tests/{ => test_lang}/test_lang.hy | 0 tests/test_lang/test_lang_tabling.hy | 110 +++++++++++++++++++++++++++ tests/test_tabling.py | 63 +++++++++++++-- 5 files changed, 170 insertions(+), 7 deletions(-) rename tests/{ => test_lang}/test_lang.hy (100%) create mode 100644 tests/test_lang/test_lang_tabling.hy diff --git a/pyproject.toml b/pyproject.toml index e3eda6b..7baef04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ lines-after-imports = 2 [tool.pyright] pythonVersion = "3.12" stubPath = "" -reportFunctionMemberAccess = "warning" +reportFunctionMemberAccess = false [tool.uv] dev-dependencies = [] diff --git a/src/microkanren/core.py b/src/microkanren/core.py index d548e66..d3c9077 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -504,7 +504,7 @@ def loop(w: WaitingStream, a: WaitingStream) -> Stream: # its thunk, followed by any remaining suspended streams. head, *tail = w _, _, thunk = head - rest_suspended_streams = WaitingStream(a[::-1] + tail) + rest_suspended_streams = a[::-1] + tail return sk( lambda: thunk() if not w diff --git a/tests/test_lang.hy b/tests/test_lang/test_lang.hy similarity index 100% rename from tests/test_lang.hy rename to tests/test_lang/test_lang.hy diff --git a/tests/test_lang/test_lang_tabling.hy b/tests/test_lang/test_lang_tabling.hy new file mode 100644 index 0000000..53dd16b --- /dev/null +++ b/tests/test_lang/test_lang_tabling.hy @@ -0,0 +1,110 @@ +(require microkanren.lang *) +(import microkanren.lang *) +(import microkanren [core]) + +;; Examples from Will Byrd's thesis. + +(defn test-badbadbad [] + (defn path° [x y] + (conde + [(arc° x y)] + [(fresh [z] + (conj+ (arc° x z) (path° z y)))])) + + (defn arc° [x y] + (conde + [(== 'a x) (== 'b y)] + [(== 'b x) (== 'a y)] + [(== 'b x) (== 'd y)])) + + (setv [b a d] '[b a d]) + (setv result (run 9 [q] (path° 'a q))) + (assert (= result + [#(b) #(a) #(d) #(b) #(a) #(d) #(b) #(a) #(d)]))) + +(defn test-badbadbad-tabled [] + (defn [core.tabled] path° [x y] + (conde + [(arc° x y)] + [(fresh [z] + (conj+ (arc° x z) (path° z y)))])) + + (defn arc° [x y] + (conde + [(== 'a x) (== 'b y)] + [(== 'b x) (== 'a y)] + [(== 'b x) (== 'd y)])) + + (setv [b a d] '[b a d]) + (setv result (run* [q] (path° 'a q))) + (assert (= result + [#(b) #(a) #(d)]))) + +(defn test-mutually-recursive-no-table [] + (defn f° [x] + (conde + [(== 0 x)] + [(g° x)])) + + (defn g° [x] + (conde + [(== 1 x)] + [(f° x)])) + + (assert (= (run 5 [q] (f° q)) + [#(0) #(1) #(0) #(1) #(0)]))) + +(defn test-mutually-recursive-table-f° [] + (defn [core.tabled] f° [x] + (conde + [(== 0 x)] + [(g° x)])) + + (defn g° [x] + (conde + [(== 1 x)] + [(f° x)])) + + (assert (= (run 5 [q] (f° q)) + [#(0) #(1)]))) + +(defn test-mutually-recursive-table-both [] + (defn [core.tabled] f° [x] + (conde + [(== 0 x)] + [(g° x)])) + + (defn [core.tabled] g° [x] + (conde + [(== 1 x)] + [(f° x)])) + + (assert (= (run 5 [q] (f° q)) + [#(0) #(1)])) + + (assert (= (run 5 [q] (g° q)) + [#(1) #(0)]))) + +;; Other examples. + +(defn test-same-generation° [] + (defn [core.tabled] same-generation° [x y] + (conde + [(== x y)] + [(fresh [x1 y1] + ;; Eventually diverges without tabling, as the first conde + ;; case keeps succeeding, then we try the alt, recur, + ;; succeed, recur, succeed, etc. + (same-generation° x1 y1) + (parent° x x1) + (parent° y y1))])) + + (defn parent° [x y] + (conde + [(== 'john x) (== 'mary y)] + [(== 'jane x) (== 'mary y)] + [(== 'mary x) (== 'sam y)])) + + (setv [john jane] '[john jane]) + (assert (= (run* [q] (same-generation° q john)) + [#(john) #(jane)]))) diff --git a/tests/test_tabling.py b/tests/test_tabling.py index ebf287c..6000ff8 100644 --- a/tests/test_tabling.py +++ b/tests/test_tabling.py @@ -1,6 +1,8 @@ +import pytest from fastcons import cons from microkanren.core import ( + OccursError, State, Symbol, Var, @@ -98,12 +100,14 @@ def test_recursive_tabled_goal(): """ @tabled - def fives(x): - return disj(eq(x, 5), lambda s: fives(x)(s)) + def goal(x): + return disj( + eq(x, 5), + lambda s: lambda: goal(x)(s), + ) - result = call_fresh(fives)(empty_state()) - # Should only return one result (5) instead of infinitely recurring - assert len(take(5, result)) == 1 + result = take(5, call_fresh(goal)(empty_state())) + assert len(result) == 1 def test_mutual_recursion_tabled(): @@ -191,3 +195,52 @@ def goal(x, y): # Should use same cache entry assert len(goal._table) == 1 + + +def test_tabled_with_cyclic_terms(): + """ + Test tabling behavior with cyclic terms. + """ + + @tabled + def goal(x): + return eq(x, cons(1, x)) # Creates a cyclic term + + with pytest.raises(OccursError): + take(1, call_fresh(goal)(empty_state())) + + +def test_tabled_with_empty_results(): + """ + Test tabling behavior when goal produces no results. + """ + + @tabled + def goal(x): + return conj(eq(x, 1), eq(x, 2)) # Will never succeed + + result = call_fresh(goal)(empty_state()) + assert take(1, result) == [] + assert goal._table[(Symbol("_.0"),)] == [] + + +def test_tabled_reuse_with_different_var_names(): + """ + Test that tabling works correctly when reusing results with differently named vars. + """ + + @tabled + def goal(x, y): + return conj(eq(x, 1), eq(y, x)) + + # First call with vars a, b + a, b = Var("a"), Var("b") + r1 = take(1, goal(a, b)(empty_state())) + + # Second call with vars x, y + x, y = Var("x"), Var("y") + r2 = take(1, goal(x, y)(empty_state())) + + # Results should be equivalent after reification + assert reify((a, b), r1[0].sub) == reify((x, y), r2[0].sub) + assert len(goal._table) == 1 # Should reuse same cache entry From c6acb6fd6db6c25f883201ff0b5e851f68c5a5ef Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Thu, 7 Nov 2024 23:25:50 +0000 Subject: [PATCH 26/35] Remove hyrule for now --- pyproject.toml | 1 - src/microkanren/lang.hy | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7baef04..8912b7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ license = {file = "LICENSE"} [project.optional-dependencies] hy = [ "hy>=1.0.0", - "hyrule>=0.7.0", ] dev = [ "hatch == 1.7.0", diff --git a/src/microkanren/lang.hy b/src/microkanren/lang.hy index 20e66ba..93c1d55 100644 --- a/src/microkanren/lang.hy +++ b/src/microkanren/lang.hy @@ -1,6 +1,5 @@ (import microkanren [core]) (import hy) -(require hyrule.destructure [setv+]) (setv call/fresh core.call-fresh) (setv == core.eq) @@ -41,17 +40,3 @@ (defmacro run* [lvars #* goals] (hy.macroexpand `(run -1 ~lvars ~@goals))) - -(defmacro defne [name args #* body] - "Accept list patterns only, that match the arity of `args'." - `(defn ~name ~args - (disj+ - ~@(map (fn [case] - (when (not (isinstance case hy.models.Expression)) - (raise (ValueError "defne case must be a hy.models.Expression"))) - (when (not case) - (raise (ValueError "defne case must be non-empty"))) - (setv+ [head rest] case) - (print (type head)) - `(~head ~@rest)) - body)))) From ced2bf56a46944d3ec14d567bbf817a6b9beb57d Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Thu, 7 Nov 2024 23:26:05 +0000 Subject: [PATCH 27/35] Add tox-uv --- pyproject.toml | 3 ++- tox.ini | 6 ++--- uv.lock | 60 +++++++++++++++++++++++++++++++++++++------------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8912b7f..bfdef15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,8 @@ dev = [ "hatch == 1.7.0", "ruff>=0.7.1", "pyright>=1.1.377", - "tox==4.11.3", + "tox>=4.23.2,<5", + "tox-uv>=1.16.0", "pytest==7.2.2", "debugpy>=1.8.7", ] diff --git a/tox.ini b/tox.ini index 71de48e..742390b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] env_list = - py{311,312} -minversion = 4.11.3 + py{312,313} +minversion = 4.23.2 [testenv] description = run the tests with pytest @@ -14,4 +14,4 @@ commands = [gh] python = 3.12 = py312 - 3.11 = py311 + 3.13 = py313 diff --git a/uv.lock b/uv.lock index fbb9838..0d2239b 100644 --- a/uv.lock +++ b/uv.lock @@ -264,15 +264,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638 }, ] -[[package]] -name = "hyrule" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/f6/55dbed29f7b16b393d5aba61bd69762cc836089e1ff922779b09f7dd8827/hyrule-0.7.0.tar.gz", hash = "sha256:3a4b3568abfe16d16895e584c0db84c1251d258fe234ba86f072906b90545ce8", size = 32955 } - [[package]] name = "idna" version = "3.10" @@ -405,10 +396,10 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "tox" }, + { name = "tox-uv" }, ] hy = [ { name = "hy" }, - { name = "hyrule" }, ] [package.metadata] @@ -417,13 +408,13 @@ requires-dist = [ { name = "fastcons", specifier = "==0.5.0" }, { name = "hatch", marker = "extra == 'dev'", specifier = "==1.7.0" }, { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0" }, - { name = "hyrule", marker = "extra == 'hy'", specifier = ">=0.7.0" }, { name = "immutables", specifier = ">=0.19,<1" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.377" }, { name = "pyrsistent", specifier = ">=0.19,<1" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==7.2.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.1" }, - { name = "tox", marker = "extra == 'dev'", specifier = "==4.11.3" }, + { name = "tox", marker = "extra == 'dev'", specifier = ">=4.23.2,<5" }, + { name = "tox-uv", marker = "extra == 'dev'", specifier = ">=1.16.0" }, ] [[package]] @@ -679,7 +670,7 @@ wheels = [ [[package]] name = "tox" -version = "4.11.3" +version = "4.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -692,9 +683,23 @@ dependencies = [ { name = "pyproject-api" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/48/7744b1a3ebf2ae5e3bf6a23e8f9775476ae770061fa912db1c966a720fbf/tox-4.11.3.tar.gz", hash = "sha256:5039f68276461fae6a9452a3b2c7295798f00a0e92edcd9a3b78ba1a73577951", size = 175528 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/86/32b10f91b4b975a37ac402b0f9fa016775088e0565c93602ba0b3c729ce8/tox-4.23.2.tar.gz", hash = "sha256:86075e00e555df6e82e74cfc333917f91ecb47ffbc868dcafbd2672e332f4a2c", size = 189998 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/f9/963052e8b825645c54262dce7b7c88691505e3b9ee10a3e3667711eaaf21/tox-4.11.3-py3-none-any.whl", hash = "sha256:599af5e5bb0cad0148ac1558a0b66f8fff219ef88363483b8d92a81e4246f28f", size = 153836 }, + { url = "https://files.pythonhosted.org/packages/af/c0/124b73d01c120e917383bc6c53ebc34efdf7243faa9fca64d105c94cf2ab/tox-4.23.2-py3-none-any.whl", hash = "sha256:452bc32bb031f2282881a2118923176445bac783ab97c874b8770ab4c3b76c38", size = 166758 }, +] + +[[package]] +name = "tox-uv" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tox" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/5e/c3d2a45ab5465dddbbc267a589c9cfce23b91750d49af10738a08c98534e/tox_uv-1.16.0.tar.gz", hash = "sha256:71b2e2fa6c35c1360b91a302df1d65b3e5a1f656b321c5ebf7b84545804c9f01", size = 16337 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/8d/1baa9f725ddd4824708759cf7b74bc43379f5f7feb079fde0629d7b32b3e/tox_uv-1.16.0-py3-none-any.whl", hash = "sha256:e6f0b525a687e745ab878d07cbf5c7e85d582028d4a7c8935f95e84350651432", size = 13661 }, ] [[package]] @@ -727,6 +732,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065 }, ] +[[package]] +name = "uv" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/f2/0c3d2d741a62985b570787f8613fae1552de25fdd696c59c9bff1fd485d8/uv-0.5.0.tar.gz", hash = "sha256:d796198163478a8db4e2f27fa6a21fb7c96c3b62c4af28bfaf8a654b7a86ce0a", size = 2126398 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/ce/cfa36f135d09c13a7e27183cb5a28ac93a3bdde100c4747ca4c5c3d9f2ac/uv-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:b52fd615c4dba8366677528122f4ead7d0651dc6cbc8cd6d17be72e2deb0390c", size = 13507588 }, + { url = "https://files.pythonhosted.org/packages/ef/c8/4fc2ac6ef7b1d5d64016d0250c2651e3fb2a85ed953aeb2ed3fd2b4e3fca/uv-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9e22f38bd4cd66ea252fe9060ae567da92eec2dc9154fedab1f059c37288ee0", size = 13496193 }, + { url = "https://files.pythonhosted.org/packages/69/fb/e778bce57b12eac37c1e9e8e1efc8b190a2c72dee02b532d13706be447f4/uv-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a3bc6911be7d86f3750bce1580e664877a3a88c126eb68afbb132cd0896fd109", size = 12502854 }, + { url = "https://files.pythonhosted.org/packages/24/40/8c6483c5ab7998c5dcde7ab1428181e1c43ff321ddd8967c0c1bf3cc5566/uv-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:de8c70d26bc4231ada30d14eaf105740ad735b2b41fde9b81978df5f0ed25152", size = 12788512 }, + { url = "https://files.pythonhosted.org/packages/a5/30/2d152f914e8d89581d2f5c5a314993325b8c502689da40ccd398b1e3c314/uv-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b846b92230d64e50425cbf183e119f9c27ebd2eae77c197b3625c701a5c13b08", size = 13325013 }, + { url = "https://files.pythonhosted.org/packages/e1/35/47b01db8ba2ff6339a418630a90d89282b3271923f69a8d163f4cabdc2cc/uv-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:886c85e53b99cb66c544feab20d5a64467556ec59c92445a7aa2fc637e4f5820", size = 13847245 }, + { url = "https://files.pythonhosted.org/packages/b5/e0/96e5c2cdf0cf8d8a51851c102cba67257c24c8e09d6ec434ca0434ec7d43/uv-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4f0bcd3e97010e79a7a75e840d1177a859bf07764da1079e9fbce66e7ebd9428", size = 14409958 }, + { url = "https://files.pythonhosted.org/packages/91/7b/c7d92e81de7180df60960ac55a96582118f192e07a9f4c525fff262f5ca0/uv-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6c071304fae1e530c7d24464f80f5efdc3e03b04c620703e1d351d27afc970b", size = 14187544 }, + { url = "https://files.pythonhosted.org/packages/86/43/e05414cee4e984a9dc94aeb350e39f11ee0889e9cdd31e67275a6ab3bdd1/uv-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c59e971c02a953d1dc1a937ef84de527d8fbe9ae13faa71ee8c0d5f697127cc", size = 18442066 }, + { url = "https://files.pythonhosted.org/packages/b0/a8/028903341efb9c9a95487ef5287ba736b37507c4251339b1e397e258a182/uv-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb131612a96b719b80e15e3261b2dee67028b137a4bb86730f8fb02808f2d79", size = 13982574 }, + { url = "https://files.pythonhosted.org/packages/10/19/3eddfb5636ac7b67da89b1fefd59d9d9cd3fa389bc03c1f4c5775e26a7e4/uv-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:313c9fc30c6679fbf5bf4acc043ad171bee7853bb16f366af064e835d1fb1a74", size = 12967310 }, + { url = "https://files.pythonhosted.org/packages/b6/a9/60d913266feea57ce1fa0badf01afb7ab0b9bcd47bf9f6b4ae5bcffa6b8b/uv-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:63cc3a9f346b74012f7ac1daea1aee22568da1023993d8f4a7b8bc30bcb4edf2", size = 13293381 }, + { url = "https://files.pythonhosted.org/packages/95/d7/358021866c3d8ea2c046a50bd44f900e99e909707944803166a3d8cc1b73/uv-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b256e450f103e98e6d8ebd92af44db16d5d699766c73f9da979cddcc9665577c", size = 13597775 }, + { url = "https://files.pythonhosted.org/packages/40/96/c79530823e580f13012e52845c115d2d68afd28462cd539eff379e981c42/uv-0.5.0-py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:d1b7fa52da65196c29569032c1c1144574e75b0caaaca77ea4c22f4a09dedc60", size = 15484475 }, + { url = "https://files.pythonhosted.org/packages/73/1d/9614d0712275679c0229abfa09f78fda4b0939e6494f5b6d6025e052aef5/uv-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8a603ed4c91fba250cc62aaf3b54b68cf70b7fefda07b6c2f230a6d8a8005616", size = 14088554 }, + { url = "https://files.pythonhosted.org/packages/b0/fd/1262b86c18d1e9511d86d55cad99ae94dd84721c1585fb96c8b9e015de0b/uv-0.5.0-py3-none-win32.whl", hash = "sha256:feb4db59fd402461f64d9493525b2dd7bda5f8b1bb1502f1f1dbb8cd9dff7c62", size = 13377423 }, + { url = "https://files.pythonhosted.org/packages/3c/e5/019c86e33fd1216f4412968b096e4c16ef472b6a326e942b75bc043e6be1/uv-0.5.0-py3-none-win_amd64.whl", hash = "sha256:f5ad860fb028179ce4467fec6dd2b2a1a369cbd67e2a058f1b50116055fda5b8", size = 15189443 }, +] + [[package]] name = "virtualenv" version = "20.27.0" From 3d28be8e33deed93f08ed1e2b7e128108d231c5e Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Thu, 7 Nov 2024 23:28:33 +0000 Subject: [PATCH 28/35] Add uv-lock pre-commit --- .pre-commit-config.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18701e8..d4753ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,9 @@ repos: args: [--fix, --exit-non-zero-on-fix] - id: ruff-format args: [--check] + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.4.30 + hooks: + # Update the uv lockfile + - id: uv-lock From 29b432f021158d7638d098914b17abe5ba21aeb7 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Fri, 8 Nov 2024 21:07:37 +0000 Subject: [PATCH 29/35] Update publish workflow --- .github/workflows/publish.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f4baaa3..9a6a983 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,15 +14,18 @@ jobs: id-token: write # required for trusted publishing steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.11 - cache: 'pip' + python-version: 3.12 - name: Install dependencies - run: pip install .[build] + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + uv venv + uv sync --extra=dev - name: Build package - run: hatch build + run: uv build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 From 57659669164e5c70614fb5442a9776a8c6b4a9b3 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Fri, 8 Nov 2024 21:26:34 +0000 Subject: [PATCH 30/35] Update CI workflows --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a6a983..5244db1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + echo "$HOME/.local/bin" >> $GITHUB_PATH uv venv uv sync --extra=dev - name: Build package diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1200aa7..d0035c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Install UV run: | curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + echo "$HOME/.local/bin" >> $GITHUB_PATH uv venv uv sync --all-extras . .venv/bin/activate @@ -57,7 +57,7 @@ jobs: - name: Install UV run: | curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Install dependencies run: | From 718f59c62b0dfcfbad127c0f6017fb1fbf9ff51a Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sat, 9 Nov 2024 14:15:19 +0000 Subject: [PATCH 31/35] Trigger CI From 7dd459ba97b8fcc7702f0357e34abaf21491e616 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 10 Nov 2024 17:10:40 +0000 Subject: [PATCH 32/35] Add basic defne implementation, tests --- pyproject.toml | 3 +- src/microkanren/core.py | 4 ++ src/microkanren/lang.hy | 40 ++++++++++++++-- tests/test_lang/test_defne.hy | 69 ++++++++++++++++++++++++++++ tests/test_lang/test_lang.hy | 13 ++++++ tests/test_lang/test_lang_tabling.hy | 7 +-- uv.lock | 13 +++++- 7 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 tests/test_lang/test_defne.hy diff --git a/pyproject.toml b/pyproject.toml index bfdef15..eaf5950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,8 @@ license = {file = "LICENSE"} [project.optional-dependencies] hy = [ - "hy>=1.0.0", + "hy>=1.0.0,<2.0.0", + "hyrule>=0.7.0,<1.0.0", ] dev = [ "hatch == 1.7.0", diff --git a/src/microkanren/core.py b/src/microkanren/core.py index d3c9077..5d7aeed 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -217,6 +217,10 @@ def mzero() -> EmptyStream: return () +def fail(_: State) -> EmptyStream: + return mzero() + + def eq(u: Any, v: Any) -> Goal: def _eq(state: State) -> EmptyStream | ReadyStream: maybe_sub: Substitution | Sentinel = unify(u, v, state.sub) diff --git a/src/microkanren/lang.hy b/src/microkanren/lang.hy index 93c1d55..e898a48 100644 --- a/src/microkanren/lang.hy +++ b/src/microkanren/lang.hy @@ -1,5 +1,7 @@ (import microkanren [core]) (import hy) +(import hyrule.collections [prewalk]) +(require hyrule.argmove *) (setv call/fresh core.call-fresh) (setv == core.eq) @@ -25,11 +27,11 @@ (defmacro fresh [lvars #* goals] (match lvars - [] `(conj+ ~@goals) + [] (if goals `(conj+ ~@goals) 'core.succeed) [v #* vs] `(call/fresh (fn [~v] (fresh ~vs ~@goals))))) (defmacro exist [lvars #* goals] - `(fresh ~lvars (&& #* goals))) + `(fresh ~lvars (conj+ ~@goals))) (defmacro run [n lvars #* goals] `(lfor @@ -38,5 +40,35 @@ (core.empty-state))) x)) -(defmacro run* [lvars #* goals] - (hy.macroexpand `(run -1 ~lvars ~@goals))) +(defmacro run* [lvars / #* goals] + `(run -1 ~lvars ~@goals)) + +(defmacro unless [test / #* consequents] + `(when (not ~test) ~@consequents)) + +(defmacro defne [name subject / #* cases] + (unless (isinstance name hy.models.Symbol) + (raise + (ValueError + "First positional argument to defne must be an identifier, the name of the defined relation"))) + (unless (isinstance subject hy.models.List) + (raise + (ValueError + "Second positional argument to defne must be a list, the parameter list of the defined relation"))) + + (defn -collect-free-vars [head bound] + (match head + [first #* rest] [#* (-collect-free-vars first bound) #* (-collect-free-vars rest bound)] + x :if (and (isinstance x hy.models.Symbol) (not-in x bound)) [x] + _ [])) + + (defn -replace-anons [head] + (prewalk (fn [x] (if (= x '_) (hy.gensym) x)) head)) + + `(defn ~name ~subject + (disj+ + ~@(lfor [head #* rest] cases + (let [-head (-replace-anons head)] + `(fresh ~(-collect-free-vars -head subject) + (== ~-head ~subject) + ~@rest)))))) diff --git a/tests/test_lang/test_defne.hy b/tests/test_lang/test_defne.hy new file mode 100644 index 0000000..ab0f6f2 --- /dev/null +++ b/tests/test_lang/test_defne.hy @@ -0,0 +1,69 @@ +(require microkanren.lang *) +(import microkanren.lang *) +(import microkanren.core [Symbol]) + +(defreader ? + `(hy.I.microkanren.core.Symbol ~f"_.{(hy.eval (.parse-one-form &reader))}")) + +(defn test-unifies-self [] + (defne goal [x] + ([x])) + + (assert (= (run* [q] (goal q)) + [#(#? 0)]))) + +(defn test-unifies-ground-val [] + (defne goal [x] + ([5])) + + (assert (= (run* [q] (goal q)) + [#(5)]))) + +(defn test-unifies-ground-val-alternatives [] + (defne goal [x] + ([5]) + ([6])) + + (assert (= (run* [q] (goal q)) + [#(5) #(6)]))) + +(defn test-unifies-named-fresh-var [] + (defne goal [x] + ([a])) + + (assert (= (run* [q] (goal q)) + [#(#? 0)]))) + +(defn test-unifies-anon-fresh-var [] + (defne goal [x] + ([_])) + + (assert (= (run* [q] (goal q)) + [#(#? 0)]))) + +(defn test-unifies-varieties [] + (defne goal [x] + ([5]) + ([6]) + ([a]) + ([_])) + + (assert (= (run* [q] (goal q)) + [#(5) #(6) #(#? 0) #(#? 0)]))) + +(defn test-unifies-multiple-args [] + (defne goal [x y] + ([5 6]) + ([a b]) + ([_ _]) + ([_ 6])) + + (assert (= (run* [a b] (goal a b)) + [#(5 6) #(#? 0 #? 1) #(#? 0 #? 1) #(#? 0 6)]))) + +(defn test-unifies-nested-structures [] + (defne goal [x] + ([[1 [2 3]]])) + + (assert (= (run* [q] (goal q)) + [#([1 [2 3]])]))) diff --git a/tests/test_lang/test_lang.hy b/tests/test_lang/test_lang.hy index 5563001..8fe0e08 100644 --- a/tests/test_lang/test_lang.hy +++ b/tests/test_lang/test_lang.hy @@ -1,6 +1,19 @@ (require microkanren.lang *) (import microkanren.lang *) +(defn fives [x] + (conde + [(== x 5)] + [(Zzz (fives x))])) + (defn test-run*-eq [] (assert (= (run* [q] (== q 5)) [#(5)]))) + +(defn test-run-positive-n [] + (assert (= (len (run 5 [q] (fives q))) + 5))) + +(defn test-run-zero-n [] + (assert (= (len (run 0 [q] (fives q))) + 0))) diff --git a/tests/test_lang/test_lang_tabling.hy b/tests/test_lang/test_lang_tabling.hy index 53dd16b..949e4dd 100644 --- a/tests/test_lang/test_lang_tabling.hy +++ b/tests/test_lang/test_lang_tabling.hy @@ -92,9 +92,10 @@ (conde [(== x y)] [(fresh [x1 y1] - ;; Eventually diverges without tabling, as the first conde - ;; case keeps succeeding, then we try the alt, recur, - ;; succeed, recur, succeed, etc. + ;; Eventually diverges without tabling, due to the ordering + ;; of these sub-goals — same-generation° with fresh vars + ;; keeps recurring and calling the same sub-goal again and + ;; again. (same-generation° x1 y1) (parent° x x1) (parent° y y1))])) diff --git a/uv.lock b/uv.lock index 0d2239b..cdb2f68 100644 --- a/uv.lock +++ b/uv.lock @@ -264,6 +264,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638 }, ] +[[package]] +name = "hyrule" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/f6/55dbed29f7b16b393d5aba61bd69762cc836089e1ff922779b09f7dd8827/hyrule-0.7.0.tar.gz", hash = "sha256:3a4b3568abfe16d16895e584c0db84c1251d258fe234ba86f072906b90545ce8", size = 32955 } + [[package]] name = "idna" version = "3.10" @@ -400,6 +409,7 @@ dev = [ ] hy = [ { name = "hy" }, + { name = "hyrule" }, ] [package.metadata] @@ -407,7 +417,8 @@ requires-dist = [ { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.7" }, { name = "fastcons", specifier = "==0.5.0" }, { name = "hatch", marker = "extra == 'dev'", specifier = "==1.7.0" }, - { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0" }, + { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0,<2.0.0" }, + { name = "hyrule", marker = "extra == 'hy'", specifier = ">=0.7.0,<1.0.0" }, { name = "immutables", specifier = ">=0.19,<1" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.377" }, { name = "pyrsistent", specifier = ">=0.19,<1" }, From c78c401efc6d9b0fd62df0eca1bd8cfa0177fe64 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 10 Nov 2024 17:24:46 +0000 Subject: [PATCH 33/35] Update readme/comments --- README.md | 200 +++++++++++++--------------------------- src/microkanren/core.py | 6 +- 2 files changed, 67 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 9eb5be4..3f191f2 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,92 @@ # microkanren -`microkanren` is an implementation of a miniKanren style relational programming language, embedded in Python. The solver is implemented in the style of μKanren[^1]. It provides a framework for extending the language with constraints, as well as a basic implementation of disequality and finite domain constraints, in the style of cKanren[^2]. - -Due to the differences between Python and the reference implementation languages (Scheme, Racket), some divergences from the typical miniKanren API are necessary. It is a goal to capture the spirit of the miniKanren language family, but not the exact API. - -* [Installation](#installation) -* [Usage](#usage) - + [Basic usage](#basic-usage) - + [Conjunction and disjunction](#conjunction-and-disjunction) - + [The result type and multiple top-level variables](#the-result-type-and-multiple-top-level-variables) - + [Defining goal constructors](#defining-goal-constructors) - - [Recursive goal constructors and `snooze` (Zzz)](#recursive-goal-constructors-and--snooze---zzz-) -* [Developing microkanren](#developing-microkanren) +`microkanren` is an implementation of a miniKanren style relational programming language. The solver is implemented in the style of μKanren[^1], with a Hy-based interface that closely follows the traditional miniKanren API. The core implementation is in Python and can be used directly for advanced use cases. ## Installation -``` bash -pip install microkanren -``` - -## Usage - -### Basic usage - -The basic goal constructor is `eq`. `eq` takes two terms as arguments, and returns a goal that will succeed if the terms can be unified, and fails otherwise. - -``` python-console ->>> from microkanren import eq ->>> eq("🍕", "🍕") - -``` - -To run a goal, use one of the provided interfaces: `run`, `run_all`, or `irun`. `run` takes two arguments: - -1. an integer, the maximum number of results to return; and -2. a callable with positional-only arguments, each of which will receive a fresh logic variable. - -`run_all` and `irun` take a single argument, the fresh-var-receiver. - -``` python-console ->>> from microkanren import run ->>> run(1, lambda x: eq(x, "🍕")) -['🍕'] -``` - -The return type of `run` and `run_all` is a (possibly-empty) list of results. If the list is empty, there are no solutions that satisfy the goal. `irun` returns a generator that yields single results. - -``` python-console ->>> from microkanren import irun ->>> rs = irun(lambda x: eq(x, "😁")) ->>> next(rs) -'😁' ->>> next(rs) -Traceback (most recent call last): - File "", line 1, in -StopIteration +```bash +pip install microkanren[hy] # For the Hy interface +pip install microkanren # For just the Python core ``` -### Conjunction and disjunction - -Conjunction and disjunction are provided by the vararg `conj` and `disj` functions. `Goal` objects support combination using `|` and `&` operators, which map to `conj` and `disj`. - -``` python-console ->>> from microkanren import run_all ->>> run_all(lambda x: disj(eq(x, "α"), eq(x, "β"), eq(x, "δ"))) -['α', 'β', 'δ'] ->>> run_all(lambda x: eq(x, "α") | eq(x, "β") | eq(x, "δ")) -['α', 'β', 'δ'] ->>> run_all(lambda x: eq(x, "ω") & eq(x, "ω")) -['ω'] ->>> run_all(lambda x: conj(eq(x, "ω"), eq(x, "ω"))) -['ω'] +## Usage with Hy + +The main interface is provided through Hy macros and functions in `microkanren.lang`. Here's a basic example: + +```clojure +(require microkanren.lang *) +(import microkanren.lang *) + +;; Basic unification +(run* [q] + (== q 5)) ; Returns [#(5)] + +;; Conjunction with fresh variables +(fresh [x y] + (== x 1) + (== y 2) + (== q [x y])) + +;; Disjunction with conde +(conde + [(== q 'apple)] + [(== q 'orange)]) + +;; Basic pattern-matching support with defne +(defne ancestor° [x y] + ([[x y]] + (parent° x y)) + ([[x y]] + (fresh [z] + (parent° x z) + (ancestor° z y)))) ``` -### The result type and multiple top-level variables - -If the fresh-var-receiver provided to an interface has arity 1, results will be single elements. If it has arity > 1, the results will be a tuple of values, each mapping position-wise to the receiver's arguments. - -``` python-console ->>> run_all(lambda x, y: eq(x, "foo") & eq(y, "bar") | eq(x, "hello") & eq(y, "world")) -[('foo', 'bar'), ('hello', 'world')] -``` - -### Defining goal constructors - -Calling goal constructors in your top-level program quickly becomes unwieldy. To mitigate this, you can define your own goal constructors. +### Tabling Support -A goal constructor is a function that takes zero or more arguments, and returns a `Goal` (or some object that implements the `GoalProto`). +Relations can be tabled, using the `tabled` decorator to improve performance, and in some cases avoid divergence: -A `Goal` is a callable that takes a `State` and returns a `Stream` of `State` objects. - -A `Stream` is either: -- empty (`mzero`); -- a callable of no arguments that returns a `Stream` (a thunk); or -- a tuple, `(State, Stream)`. - -``` python-console ->>> def likes_pizza(person, out): -... return eq(out, (person, "likes 🍕")) -... ->>> run_all(lambda q: likes_pizza("Jane", q) | likes_pizza("Bill", q)) -[('Jane', 'likes 🍕'), ('Bill', 'likes 🍕')] +```clojure +(defn [core.tabled] path° [x y] + (conde + [(arc° x y)] + [(fresh [z] + (arc° x z) + (path° z y))])) ``` -As shown in the above example, it can be convenient to define goals in terms of the combination of other goals. However, if you require access to the current state, you can define the goal returned by your goal constructor explicitly. +## Python Core API -``` python -def my_constructor(x): - def _my_constructor(state): - if there_is_something_about(x): - return unit(state) - return mzero - - return Goal(_my_constructor) -``` +The core implementation is available in `microkanren.core` for direct use from Python: -Wrapping your goal with `Goal` means it will be combinable with other goals using `|` and `&`. +```python +from microkanren.core import eq, State, empty_state, reify -#### Recursive goal constructors and `snooze` (Zzz) +# Create a goal that unifies two terms +goal = eq("x", "x") -If your goal constructor is directly recursive, it will never terminate. +# Run the goal with an empty state +stream = goal(empty_state()) -``` python-console ->>> def always_pizza(x): -... return eq(x, "🍕") | always_pizza(x) -... ->>> run(1, lambda x: always_pizza(x)) -... -RecursionError: maximum recursion depth exceeded while calling a Python object -``` - -We provide `snooze` to delay the construction of a goal until it is needed. Using `snooze` we can fix `always_pizza` to return an infinite stream of pizza[^3]. - -``` python-console ->>> def always_pizza(x): -... return eq(x, "🍕") | snooze(always_pizza, x) -... ->>> rs = irun(lambda x: always_pizza(x)) ->>> next(rs) -'🍕' ->>> next(rs) -'🍕' ->>> next(rs) -'🍕' ->>> next(rs) -'🍕' +# Process results +state = next(stream) ``` ## Developing microkanren -`microkanren` currently requires Python 3.11. +Requirements: +- Python 3.12 +- Hy 1.0+ 1. `git clone git@github.com:jams2/microkanren.git` -2. `pip install -e .[dev,testing]` - -Run the tests with `pytest`. +2. `pip install -e '.[dev,hy]'` -Format code with `black` and `ruff`: +Run tests with `pytest`. -``` bash -black . +Format code: +```bash ruff check --fix src tests +ruff format src tests ``` [^1]: [μKanren: A Minimal Functional Core for Relational Programming (Hemann & Friedman, 2013)](http://webyrd.net/scheme-2013/papers/HemannMuKanren2013.pdf) -[^2]: [cKanren: miniKanren with constraints (Alvis et al, 2011)](http://www.schemeworkshop.org/2011/papers/Alvis2011.pdf) -[^3]: original example `fives` from the μKanren paper altered here to provide more pizza diff --git a/src/microkanren/core.py b/src/microkanren/core.py index 5d7aeed..5ec6588 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -278,7 +278,9 @@ def bind(stream: Stream, g: Goal) -> Stream: elif isinstance(stream, WaitingStream): return w_check( stream, + # Success: the WaitingStream can contribute new answers. lambda x: lambda: bind(x(), g), + # Failure: the WaitingStream cannot contribute new answers. lambda: WaitingStream( SuspendedStream( x.cache, @@ -301,7 +303,9 @@ def mplus(left: Stream, right: Stream) -> Stream: elif isinstance(left, WaitingStream): return w_check( left, + # Success: the WaitingStream can contribute new answers. lambda x: lambda: mplus(right, x), + # Failure: the WaitingStream cannot contribute new answers. lambda: right + left if isinstance(right, WaitingStream) else mplus(right, lambda: left), @@ -508,7 +512,7 @@ def loop(w: WaitingStream, a: WaitingStream) -> Stream: # its thunk, followed by any remaining suspended streams. head, *tail = w _, _, thunk = head - rest_suspended_streams = a[::-1] + tail + rest_suspended_streams: WaitingStream = a[::-1] + tail return sk( lambda: thunk() if not w From ae8fb5febae63c98e29ce62230432d5a07abcee2 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 10 Nov 2024 17:30:17 +0000 Subject: [PATCH 34/35] Add tutorial to readme --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f191f2..000cf9a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,51 @@ Relations can be tabled, using the `tabled` decorator to improve performance, an (path° z y))])) ``` +## Tutorial: Building a Pizza Recommendation System + +Let's build a simple pizza recommendation system to demonstrate how relational programming works: + +```clojure +(require microkanren.lang *) +(import microkanren.lang *) + +;; Define our pizza database +(defn likes° [person topping] + (conde + [(== person 'alice) (== topping 'mushroom)] + [(== person 'alice) (== topping 'olive)] + [(== person 'bob) (== topping 'pepper)] + [(== person 'bob) (== topping 'mushroom)])) + +;; Find toppings that both people like +(defn common-topping° [p1 p2 topping] + (fresh [] + (likes° p1 topping) + (likes° p2 topping))) + +;; Query examples: +;; What does Alice like? +(run* [q] + (likes° 'alice q)) +;; => [#(mushroom) #(olive)] + +;; Who likes mushrooms? +(run* [q] + (likes° q 'mushroom)) +;; => [#(alice) #(bob)] + +;; What toppings do Alice and Bob have in common? +(run* [q] + (common-topping° 'alice 'bob q)) +;; => [#(mushroom)] +``` + +This example shows how to: +1. Define facts using `conde` for alternatives +2. Create relations between entities (people and toppings) +3. Compose relations to find common preferences +4. Query the system in different ways + ## Python Core API The core implementation is available in `microkanren.core` for direct use from Python: @@ -69,7 +114,7 @@ goal = eq("x", "x") stream = goal(empty_state()) # Process results -state = next(stream) +five_states = take(5, stream) ``` ## Developing microkanren From daa1c9d0ea6e44e820ccfc5c79f0d209ce897989 Mon Sep 17 00:00:00 2001 From: Joshua Munn Date: Sun, 10 Nov 2024 17:39:27 +0000 Subject: [PATCH 35/35] Add API reference and pizza tutorial --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 000cf9a..9c346fc 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ The main interface is provided through Hy macros and functions in `microkanren.l ;; Basic pattern-matching support with defne (defne ancestor° [x y] - ([[x y]] + ([x y] (parent° x y)) - ([[x y]] + ([x y] (fresh [z] (parent° x z) (ancestor° z y)))) @@ -55,7 +55,7 @@ Relations can be tabled, using the `tabled` decorator to improve performance, an (path° z y))])) ``` -## Tutorial: Building a Pizza Recommendation System +### Tutorial: Building a Pizza Recommendation System Let's build a simple pizza recommendation system to demonstrate how relational programming works: @@ -94,12 +94,6 @@ Let's build a simple pizza recommendation system to demonstrate how relational p ;; => [#(mushroom)] ``` -This example shows how to: -1. Define facts using `conde` for alternatives -2. Create relations between entities (people and toppings) -3. Compose relations to find common preferences -4. Query the system in different ways - ## Python Core API The core implementation is available in `microkanren.core` for direct use from Python: @@ -134,4 +128,77 @@ ruff check --fix src tests ruff format src tests ``` +## API Reference + +### Core Operators + +#### == (unification) + +```clojure +(== term1 term2) +``` + +Unifies two terms, succeeding if they can be made equal. Basic building block for relations. + +#### fresh (variable introduction) + +```clojure +(fresh [x y z ...] goal ...) +``` + +Introduces new logic variables into scope. Returns conjunction of all goals. + +#### conde (disjunction) + +```clojure +(conde + [goal1 ...] + [goal3 ...]) +``` + +Returns disjunction of conjunctions. Each clause represents an "or" branch. + +### Running Queries + +#### run* (unlimited results) + +```clojure +(run* [q ...] goal) +``` + +Returns all results that satisfy the goals. + +#### run (limited results) + +```clojure +(run n [q ...] goal) +``` + +Returns at most n results that satisfy the goals. + +### Relation Definition + +#### defne (pattern matching) +```clojure +(defne relation° [args] + ([pattern1] goal1 ...) + ([pattern2] goal2 ...) + ...) + +``` + +Defines a relation using pattern matching. Patterns are unified with args. +- Use `_` for anonymous variables +- Variables in patterns become fresh variables, unless they are bound by the parameters +- Multiple clauses create disjunctions + +#### core.tabled (tabling decorator) + +```clojure +(defn [core.tabled] relation° [x y] + goals ...) +``` + +Tables the defined relation. + [^1]: [μKanren: A Minimal Functional Core for Relational Programming (Hemann & Friedman, 2013)](http://webyrd.net/scheme-2013/papers/HemannMuKanren2013.pdf)