Repository: rethinkpriorities/squigglepy (language: python)

Issue:
Overflows
During testing of the correlations feature, I've noticed a bunch of distributions which casually produce overflows. Users wouldn't notice this except for infinities (`np.inf`) in their samples or distribution parameters.

I've fixed some of these (mostly those related to input parameters) alongside the distributions feature, but some that are still a problem:

- **LognormalDistribution**:
	- **Outcome**: Infinities in distribution parameters.
	- **To replicate**: `sq.lognorm(1,1e+26)`
	- **Location**: https://github.com/rethinkpriorities/squigglepy/blob/9491e508a7704c3b09f8101208e8673012a42d4e/squigglepy/distributions.py#L773-L775
- **LogTDistribution**:
	-  **Outcome**: Infinities in samples. 
	- 	**To replicate**: `np.any(sq.log_t_sample(1.0, 2.0, 1, 100_000, 90) == np.inf)`
	- 	**Location**: https://github.com/rethinkpriorities/squigglepy/blob/9491e508a7704c3b09f8101208e8673012a42d4e/squigglepy/samplers.py#L216-L218

I think it would be hard to fix this, most of these errors come from limitations inherent of the floating point operations; but I would at least try to reraise the warning as an error, to prevent users from creating models which might be silently wrong.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 6e8f457..1c4e86d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ dist/*
 __pycache__
 .ruff_cache
 .pytest-runtimes
+.hypothesis
\ No newline at end of file
diff --git a/README.md b/README.md
index bc0e563..c998cdc 100644
--- a/README.md
+++ b/README.md
@@ -166,6 +166,12 @@ sq.norm(0, 3, lclip=0, rclip=5) # Sample norm with a 90% CI from 0-3, but anythi
 
 # You can also clip with a function, and use pipes
 sq.norm(0, 3) >> sq.clip(0, 5)
+
+# You can correlate continuous distributions
+a, b = sq.uniform(-1, 1), sq.to(0, 3)
+a, b = sq.correlate((a, b), 0.5)  # Correlate a and b with a correlation of 0.5
+# You can even pass your own correlation matrix!
+a, b = sq.correlate((a, b), [[1, 0.5], [0.5, 1]])
 ```
 
 #### Example: Rolling a Die
diff --git a/poetry.lock b/poetry.lock
index 8ca1d79..86c4e93 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,38 @@
 # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
 
+[[package]]
+name = "ansi2html"
+version = "1.8.0"
+description = ""
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "ansi2html-1.8.0-py3-none-any.whl", hash = "sha256:ef9cc9682539dbe524fbf8edad9c9462a308e04bce1170c32daa8fdfd0001785"},
+    {file = "ansi2html-1.8.0.tar.gz", hash = "sha256:38b82a298482a1fa2613f0f9c9beb3db72a8f832eeac58eb2e47bf32cd37f6d5"},
+]
+
+[package.extras]
+docs = ["Sphinx", "setuptools-scm", "sphinx-rtd-theme"]
+test = ["pytest", "pytest-cov"]
+
+[[package]]
+name = "attrs"
+version = "23.1.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"},
+    {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"},
+]
+
+[package.extras]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[docs,tests]", "pre-commit"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+
 [[package]]
 name = "black"
 version = "23.3.0"
@@ -49,6 +82,101 @@ d = ["aiohttp (>=3.7.4)"]
 jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
 uvloop = ["uvloop (>=0.15.2)"]
 
+[[package]]
+name = "certifi"
+version = "2023.5.7"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
+    {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.1.0"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+    {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"},
+    {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"},
+]
+
 [[package]]
 name = "click"
 version = "8.1.3"
@@ -78,7 +206,7 @@ files = [
 name = "contourpy"
 version = "1.0.7"
 description = "Python library for calculating contours of 2D quadrilateral grids"
-optional = true
+optional = false
 python-versions = ">=3.8"
 files = [
     {file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d"},
@@ -148,17 +276,154 @@ mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"]
 test = ["Pillow", "matplotlib", "pytest"]
 test-no-images = ["pytest"]
 
+[[package]]
+name = "coverage"
+version = "7.2.7"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"},
+    {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"},
+    {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"},
+    {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"},
+    {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"},
+    {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"},
+    {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"},
+    {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"},
+    {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"},
+    {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"},
+    {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"},
+    {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"},
+    {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"},
+    {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"},
+    {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"},
+    {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"},
+    {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"},
+    {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"},
+    {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"},
+    {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"},
+    {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"},
+    {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"},
+    {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"},
+    {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
 [[package]]
 name = "cycler"
 version = "0.11.0"
 description = "Composable style cycles"
-optional = true
+optional = false
 python-versions = ">=3.6"
 files = [
     {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"},
     {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"},
 ]
 
+[[package]]
+name = "dash"
+version = "2.11.1"
+description = "A Python framework for building reactive web-apps. Developed by Plotly."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "dash-2.11.1-py3-none-any.whl", hash = "sha256:a5b55efc7f139538d95331fa0b5ee0d21600f7dafa9cce4fe4f887b773aba01a"},
+    {file = "dash-2.11.1.tar.gz", hash = "sha256:1acc4c634311cb306a1086fff315c1f2aaa3898bac362d93bc8db6b1a6474e5c"},
+]
+
+[package.dependencies]
+ansi2html = "*"
+dash-core-components = "2.0.0"
+dash-html-components = "2.0.0"
+dash-table = "5.0.0"
+Flask = ">=1.0.4,<2.3.0"
+nest-asyncio = "*"
+plotly = ">=5.0.0"
+requests = "*"
+retrying = "*"
+typing-extensions = ">=4.1.1"
+Werkzeug = "<2.3.0"
+
+[package.extras]
+celery = ["celery[redis] (>=5.1.2)", "importlib-metadata (<5)", "redis (>=3.5.3)"]
+ci = ["black (==21.6b0)", "black (==22.3.0)", "dash-dangerously-set-inner-html", "dash-flow-example (==0.0.5)", "flake8 (==3.9.2)", "flaky (==3.7.0)", "flask-talisman (==1.0.0)", "isort (==4.3.21)", "jupyterlab (<4.0.0)", "mimesis", "mock (==4.0.3)", "numpy", "openpyxl", "orjson (==3.5.4)", "orjson (==3.6.7)", "pandas (==1.1.5)", "pandas (>=1.4.0)", "preconditions", "pyarrow", "pyarrow (<3)", "pylint (==2.13.5)", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "xlrd (<2)", "xlrd (>=2.0.1)"]
+compress = ["flask-compress"]
+dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"]
+diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"]
+testing = ["beautifulsoup4 (>=4.8.2)", "cryptography (<3.4)", "dash-testing-stub (>=0.0.2)", "lxml (>=4.6.2)", "multiprocess (>=0.70.12)", "percy (>=2.0.2)", "psutil (>=5.8.0)", "pytest (>=6.0.2)", "requests[security] (>=2.21.0)", "selenium (>=3.141.0,<=4.2.0)", "waitress (>=1.4.4)"]
+
+[[package]]
+name = "dash-core-components"
+version = "2.0.0"
+description = "Core component suite for Dash"
+optional = false
+python-versions = "*"
+files = [
+    {file = "dash_core_components-2.0.0-py3-none-any.whl", hash = "sha256:52b8e8cce13b18d0802ee3acbc5e888cb1248a04968f962d63d070400af2e346"},
+    {file = "dash_core_components-2.0.0.tar.gz", hash = "sha256:c6733874af975e552f95a1398a16c2ee7df14ce43fa60bb3718a3c6e0b63ffee"},
+]
+
+[[package]]
+name = "dash-html-components"
+version = "2.0.0"
+description = "Vanilla HTML components for Dash"
+optional = false
+python-versions = "*"
+files = [
+    {file = "dash_html_components-2.0.0-py3-none-any.whl", hash = "sha256:b42cc903713c9706af03b3f2548bda4be7307a7cf89b7d6eae3da872717d1b63"},
+    {file = "dash_html_components-2.0.0.tar.gz", hash = "sha256:8703a601080f02619a6390998e0b3da4a5daabe97a1fd7a9cebc09d015f26e50"},
+]
+
+[[package]]
+name = "dash-table"
+version = "5.0.0"
+description = "Dash table"
+optional = false
+python-versions = "*"
+files = [
+    {file = "dash_table-5.0.0-py3-none-any.whl", hash = "sha256:19036fa352bb1c11baf38068ec62d172f0515f73ca3276c79dee49b95ddc16c9"},
+    {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"},
+]
+
 [[package]]
 name = "dill"
 version = "0.3.6"
@@ -187,11 +452,33 @@ files = [
 [package.extras]
 test = ["pytest (>=6)"]
 
+[[package]]
+name = "flask"
+version = "2.2.5"
+description = "A simple framework for building complex web applications."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Flask-2.2.5-py3-none-any.whl", hash = "sha256:58107ed83443e86067e41eff4631b058178191a355886f8e479e347fa1285fdf"},
+    {file = "Flask-2.2.5.tar.gz", hash = "sha256:edee9b0a7ff26621bd5a8c10ff484ae28737a2410d99b0bb9a6850c7fb977aa0"},
+]
+
+[package.dependencies]
+click = ">=8.0"
+importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""}
+itsdangerous = ">=2.0"
+Jinja2 = ">=3.0"
+Werkzeug = ">=2.2.2"
+
+[package.extras]
+async = ["asgiref (>=3.2)"]
+dotenv = ["python-dotenv"]
+
 [[package]]
 name = "fonttools"
 version = "4.40.0"
 description = "Tools to manipulate font files"
-optional = true
+optional = false
 python-versions = ">=3.8"
 files = [
     {file = "fonttools-4.40.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b802dcbf9bcff74672f292b2466f6589ab8736ce4dcf36f48eb994c2847c4b30"},
@@ -244,11 +531,101 @@ ufo = ["fs (>=2.2.0,<3)"]
 unicode = ["unicodedata2 (>=15.0.0)"]
 woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 
+[[package]]
+name = "hypofuzz"
+version = "23.6.1"
+description = "Adaptive fuzzing for property-based tests"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "hypofuzz-23.6.1-py3-none-any.whl", hash = "sha256:111f55a05ba7d9c633bdcfc03a9972b593dc62813c0d25ca980cd7c641f31683"},
+    {file = "hypofuzz-23.6.1.tar.gz", hash = "sha256:34f8dde8a0f92ca4d246bb62b64bf4dc573bee21cebf4c94015e754cb165ce6b"},
+]
+
+[package.dependencies]
+black = ">=23.3.0"
+coverage = ">=5.2.1"
+dash = ">=2.0.0"
+hypothesis = {version = ">=6.75.7", extras = ["cli"]}
+libcst = ">=1.0.0"
+pandas = ">=1.0.0"
+psutil = ">=3.0.0"
+pytest = ">=6.0.1"
+requests = ">=2.24.0"
+
+[package.extras]
+pytrace = ["flask-cors (>=3.0.10)", "pycrunch-trace (>=0.1.6)"]
+
+[[package]]
+name = "hypothesis"
+version = "6.78.3"
+description = "A library for property-based testing"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "hypothesis-6.78.3-py3-none-any.whl", hash = "sha256:6a853db06edca9867043cf6c14e5dff0e9ba8a7ad87b63d880016ca8469f42ba"},
+    {file = "hypothesis-6.78.3.tar.gz", hash = "sha256:e14ee83db4d2008d790b1d90cb80c5cee10b86c8babcee4c12585e068b682f4b"},
+]
+
+[package.dependencies]
+attrs = ">=19.2.0"
+black = {version = ">=19.10b0", optional = true, markers = "extra == \"cli\""}
+click = {version = ">=7.0", optional = true, markers = "extra == \"cli\""}
+exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+rich = {version = ">=9.0.0", optional = true, markers = "extra == \"cli\""}
+sortedcontainers = ">=2.1.0,<3.0.0"
+
+[package.extras]
+all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "django (>=3.2)", "dpcontracts (>=0.4)", "importlib-metadata (>=3.6)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.16.0)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2023.3)"]
+cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"]
+codemods = ["libcst (>=0.3.16)"]
+dateutil = ["python-dateutil (>=1.4)"]
+django = ["django (>=3.2)"]
+dpcontracts = ["dpcontracts (>=0.4)"]
+ghostwriter = ["black (>=19.10b0)"]
+lark = ["lark (>=0.10.1)"]
+numpy = ["numpy (>=1.16.0)"]
+pandas = ["pandas (>=1.1)"]
+pytest = ["pytest (>=4.6)"]
+pytz = ["pytz (>=2014.1)"]
+redis = ["redis (>=3.0.0)"]
+zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2023.3)"]
+
+[[package]]
+name = "idna"
+version = "3.4"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
+    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "6.7.0"
+description = "Read metadata from Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"},
+    {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"},
+]
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
+
 [[package]]
 name = "importlib-resources"
 version = "5.12.0"
 description = "Read resources from Python packages"
-optional = true
+optional = false
 python-versions = ">=3.7"
 files = [
     {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"},
@@ -273,11 +650,39 @@ files = [
     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
 ]
 
+[[package]]
+name = "itsdangerous"
+version = "2.1.2"
+description = "Safely pass data to untrusted environments and back."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"},
+    {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
 [[package]]
 name = "kiwisolver"
 version = "1.4.4"
 description = "A fast implementation of the Cassowary constraint solver"
-optional = true
+optional = false
 python-versions = ">=3.7"
 files = [
     {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"},
@@ -350,11 +755,141 @@ files = [
     {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"},
 ]
 
+[[package]]
+name = "libcst"
+version = "1.0.1"
+description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10 programs."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "libcst-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80423311f09fc5fc3270ede44d30d9d8d3c2d3dd50dbf703a581ca7346949fa6"},
+    {file = "libcst-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9d6dec2a3c443792e6af7c36fadc256e4ea586214c76b52f0d18118811dbe351"},
+    {file = "libcst-1.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4840a3de701778f0a19582bb3085c61591329153f801dc25da84689a3733960b"},
+    {file = "libcst-1.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0138068baf09561268c7f079373bda45f0e2b606d2d19df1307ca8a5134fc465"},
+    {file = "libcst-1.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a4931feceab171e6fce73de94e13880424367247dad6ff2b49cabfec733e144"},
+    {file = "libcst-1.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:47dba43855e9c7b06d8b256ee81f0ebec6a4f43605456519577e09dfe4b4288c"},
+    {file = "libcst-1.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c50541c3fd6b1d5a3765c4bb5ee8ecbba9d0e798e48f79fd5adf3b6752de4d0"},
+    {file = "libcst-1.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5599166d5fec40e18601fb8868519dde99f77b6e4ad6074958018f9545da7abd"},
+    {file = "libcst-1.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600c4d3a9a2f75d5a055fed713a5a4d812709947909610aa6527abe08a31896f"},
+    {file = "libcst-1.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b5aea04c35e13109edad3cf83bc6dcd74309b150a781d2189eecb288b73a87"},
+    {file = "libcst-1.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddd4e0eeec499d1c824ab545e62e957dbbd69a16bc4273208817638eb7d6b3c6"},
+    {file = "libcst-1.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:414350df5e334ddf0db1732d63da44e81b734d45abe1c597b5e5c0dd46aa4156"},
+    {file = "libcst-1.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1adcfa7cafb6a0d39a1a0bec541355608038b45815e0c5019c95f91921d42884"},
+    {file = "libcst-1.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d31ce2790eab59c1bd8e33fe72d09cfc78635c145bdc3f08296b360abb5f443"},
+    {file = "libcst-1.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2cb687e1514625e91024e50a5d2e485c0ad3be24f199874ebf32b5de0346150"},
+    {file = "libcst-1.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6caa33430c0c7a0fcad921b0deeec61ddb96796b6f88dca94966f6db62065f4f"},
+    {file = "libcst-1.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b97f652b15c50e91df411a9c8d5e6f75882b30743a49b387dcedd3f68ed94d75"},
+    {file = "libcst-1.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:967c66fabd52102954207bf1541312b467afc210fdf7033f32da992fb6c2372c"},
+    {file = "libcst-1.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b666a605f4205c8357696f3b6571a38f6a8537cdcbb8f357587d35168298af34"},
+    {file = "libcst-1.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae49dcbfadefb82e830d41d9f0a1db0af3b771224768f431f1b7b3a9803ed7e3"},
+    {file = "libcst-1.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c90c74a8a314f0774f045122323fb60bacba79cbf5f71883c0848ecd67179541"},
+    {file = "libcst-1.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0533de4e35396c61aeb3a6266ac30369a855910c2385aaa902ff4aabd60d409"},
+    {file = "libcst-1.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:5e3293e77657ba62533553bb9f0c5fb173780e164c65db1ea2a3e0d03944a284"},
+    {file = "libcst-1.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:119ba709f1dcb785a4458cf36cedb51d6f9cb2eec0acd7bb171f730eac7cb6ce"},
+    {file = "libcst-1.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b4e336f6d68456017671cdda8ddebf9caebce8052cc21a3f494b03d7bd28386"},
+    {file = "libcst-1.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8420926791b0b6206cb831a7ec73d26ae820e65bdf07ce9813c7754c7722c07a"},
+    {file = "libcst-1.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d237e9164a43caa7d6765ee560412264484e7620c546a2ee10a8d01bd56884e0"},
+    {file = "libcst-1.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:440887e5f82efb299f2e98d4bfa5663851a878cfc0efed652ab8c50205191436"},
+    {file = "libcst-1.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:ae7f4e71d714f256b5f2ff98b5a9effba0f9dff4d779d8f35d7eb157bef78f59"},
+    {file = "libcst-1.0.1.tar.gz", hash = "sha256:37187337f979ba426d8bfefc08008c3c1b09b9e9f9387050804ed2da88107570"},
+]
+
+[package.dependencies]
+pyyaml = ">=5.2"
+typing-extensions = ">=3.7.4.2"
+typing-inspect = ">=0.4.0"
+
+[package.extras]
+dev = ["Sphinx (>=5.1.1)", "black (==23.3.0)", "build (>=0.10.0)", "coverage (>=4.5.4)", "fixit (==0.1.1)", "flake8 (>=3.7.8,<5)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jinja2 (==3.1.2)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<0.16)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "pyre-check (==0.9.10)", "setuptools-rust (>=1.5.2)", "setuptools-scm (>=6.0.1)", "slotscheck (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==2.1.0)", "usort (==1.0.7)"]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
+    {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins"]
+profiling = ["gprof2dot"]
+rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.3"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"},
+    {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
+    {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"},
+    {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"},
+    {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"},
+    {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"},
+    {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
+]
+
 [[package]]
 name = "matplotlib"
 version = "3.7.1"
 description = "Python plotting package"
-optional = true
+optional = false
 python-versions = ">=3.8"
 files = [
     {file = "matplotlib-3.7.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:95cbc13c1fc6844ab8812a525bbc237fa1470863ff3dace7352e910519e194b1"},
@@ -412,6 +947,17 @@ pillow = ">=6.2.0"
 pyparsing = ">=2.3.1"
 python-dateutil = ">=2.7"
 
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+    {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
 [[package]]
 name = "msgspec"
 version = "0.15.1"
@@ -494,6 +1040,17 @@ files = [
     {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
 ]
 
+[[package]]
+name = "nest-asyncio"
+version = "1.5.6"
+description = "Patch asyncio to allow nested event loops"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "nest_asyncio-1.5.6-py3-none-any.whl", hash = "sha256:b9a953fb40dceaa587d109609098db21900182b16440652454a146cffb06e8b8"},
+    {file = "nest_asyncio-1.5.6.tar.gz", hash = "sha256:d267cc1ff794403f7df692964d1d2a3fa9418ffea2a3f6859a439ff482fef290"},
+]
+
 [[package]]
 name = "numpy"
 version = "1.24.3"
@@ -546,7 +1103,7 @@ files = [
 name = "pandas"
 version = "2.0.2"
 description = "Powerful data structures for data analysis, time series, and statistics"
-optional = true
+optional = false
 python-versions = ">=3.8"
 files = [
     {file = "pandas-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ebb9f1c22ddb828e7fd017ea265a59d80461d5a79154b49a4207bd17514d122"},
@@ -641,7 +1198,7 @@ files = [
 name = "pillow"
 version = "9.5.0"
 description = "Python Imaging Library (Fork)"
-optional = true
+optional = false
 python-versions = ">=3.7"
 files = [
     {file = "Pillow-9.5.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16"},
@@ -731,6 +1288,21 @@ files = [
 docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
 test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"]
 
+[[package]]
+name = "plotly"
+version = "5.15.0"
+description = "An open-source, interactive data visualization library for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "plotly-5.15.0-py2.py3-none-any.whl", hash = "sha256:3508876bbd6aefb8a692c21a7128ca87ce42498dd041efa5c933ee44b55aab24"},
+    {file = "plotly-5.15.0.tar.gz", hash = "sha256:822eabe53997d5ebf23c77e1d1fcbf3bb6aa745eb05d532afd4b6f9a2e2ab02f"},
+]
+
+[package.dependencies]
+packaging = "*"
+tenacity = ">=6.2.0"
+
 [[package]]
 name = "pluggy"
 version = "1.0.0"
@@ -771,11 +1343,51 @@ files = [
 [package.extras]
 dill = ["dill (>=0.3.6)"]
 
+[[package]]
+name = "psutil"
+version = "5.9.5"
+description = "Cross-platform lib for process and system monitoring in Python."
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"},
+    {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"},
+    {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"},
+    {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"},
+    {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"},
+    {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"},
+    {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"},
+    {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"},
+    {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"},
+    {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"},
+    {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"},
+    {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"},
+    {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"},
+    {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"},
+]
+
+[package.extras]
+test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"]
+
+[[package]]
+name = "pygments"
+version = "2.15.1"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"},
+    {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+
 [[package]]
 name = "pyparsing"
 version = "3.0.9"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
-optional = true
+optional = false
 python-versions = ">=3.6.8"
 files = [
     {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
@@ -828,7 +1440,7 @@ dev = ["pre-commit", "pytest-asyncio", "tox"]
 name = "python-dateutil"
 version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
-optional = true
+optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
     {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
@@ -842,13 +1454,115 @@ six = ">=1.5"
 name = "pytz"
 version = "2023.3"
 description = "World timezone definitions, modern and historical"
-optional = true
+optional = false
 python-versions = "*"
 files = [
     {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"},
     {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"},
 ]
 
+[[package]]
+name = "pyyaml"
+version = "6.0"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+    {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+    {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+    {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+    {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
+    {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
+    {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
+    {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
+    {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+    {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+    {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+    {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+    {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+    {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+    {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+    {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+    {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+    {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+    {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+    {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+    {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+    {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
+]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "retrying"
+version = "1.3.4"
+description = "Retrying"
+optional = false
+python-versions = "*"
+files = [
+    {file = "retrying-1.3.4-py3-none-any.whl", hash = "sha256:8cc4d43cb8e1125e0ff3344e9de678fefd85db3b750b81b2240dc0183af37b35"},
+    {file = "retrying-1.3.4.tar.gz", hash = "sha256:345da8c5765bd982b1d1915deb9102fd3d1f7ad16bd84a9700b85f64d24e8f3e"},
+]
+
+[package.dependencies]
+six = ">=1.7.0"
+
+[[package]]
+name = "rich"
+version = "13.4.2"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+    {file = "rich-13.4.2-py3-none-any.whl", hash = "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec"},
+    {file = "rich-13.4.2.tar.gz", hash = "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
 [[package]]
 name = "ruff"
 version = "0.0.272"
@@ -913,17 +1627,63 @@ dev = ["click", "doit (>=0.36.0)", "flake8", "mypy", "pycodestyle", "pydevtool",
 doc = ["matplotlib (>2)", "numpydoc", "pydata-sphinx-theme (==0.9.0)", "sphinx (!=4.1.0)", "sphinx-design (>=0.2.0)"]
 test = ["asv", "gmpy2", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"]
 
+[[package]]
+name = "seaborn"
+version = "0.12.2"
+description = "Statistical data visualization"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "seaborn-0.12.2-py3-none-any.whl", hash = "sha256:ebf15355a4dba46037dfd65b7350f014ceb1f13c05e814eda2c9f5fd731afc08"},
+    {file = "seaborn-0.12.2.tar.gz", hash = "sha256:374645f36509d0dcab895cba5b47daf0586f77bfe3b36c97c607db7da5be0139"},
+]
+
+[package.dependencies]
+matplotlib = ">=3.1,<3.6.1 || >3.6.1"
+numpy = ">=1.17,<1.24.0 || >1.24.0"
+pandas = ">=0.25"
+
+[package.extras]
+dev = ["flake8", "flit", "mypy", "pandas-stubs", "pre-commit", "pytest", "pytest-cov", "pytest-xdist"]
+docs = ["ipykernel", "nbconvert", "numpydoc", "pydata_sphinx_theme (==0.10.0rc2)", "pyyaml", "sphinx-copybutton", "sphinx-design", "sphinx-issues"]
+stats = ["scipy (>=1.3)", "statsmodels (>=0.10)"]
+
 [[package]]
 name = "six"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
-optional = true
+optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+files = [
+    {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+    {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
+[[package]]
+name = "tenacity"
+version = "8.2.2"
+description = "Retry code until it succeeds"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "tenacity-8.2.2-py3-none-any.whl", hash = "sha256:2f277afb21b851637e8f52e6a613ff08734c347dc19ade928e519d7d2d8569b0"},
+    {file = "tenacity-8.2.2.tar.gz", hash = "sha256:43af037822bd0029025877f3b2d97cc4d7bb0c2991000a3d59d71517c5c969e0"},
+]
+
+[package.extras]
+doc = ["reno", "sphinx", "tornado (>=4.5)"]
+
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -966,22 +1726,71 @@ files = [
     {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"},
 ]
 
+[[package]]
+name = "typing-inspect"
+version = "0.9.0"
+description = "Runtime inspection utilities for typing module."
+optional = false
+python-versions = "*"
+files = [
+    {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
+    {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=0.3.0"
+typing-extensions = ">=3.7.4"
+
 [[package]]
 name = "tzdata"
 version = "2023.3"
 description = "Provider of IANA time zone data"
-optional = true
+optional = false
 python-versions = ">=2"
 files = [
     {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"},
     {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"},
 ]
 
+[[package]]
+name = "urllib3"
+version = "2.0.3"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"},
+    {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "werkzeug"
+version = "2.2.3"
+description = "The comprehensive WSGI web application library."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"},
+    {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.1.1"
+
+[package.extras]
+watchdog = ["watchdog"]
+
 [[package]]
 name = "zipp"
 version = "3.15.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
-optional = true
+optional = false
 python-versions = ">=3.7"
 files = [
     {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
@@ -1000,4 +1809,4 @@ plots = ["matplotlib"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.9,<3.12"
-content-hash = "c08e85917917d22c840e121db13bdcdf32be455720eec35eee6299d378d645e0"
+content-hash = "5625f6f3eec6de2121f93d9cb0591c23402a9c232f824ce8353c50b7441e3a9d"
diff --git a/pyproject.toml b/pyproject.toml
index 3dbc5d0..b12988a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "squigglepy"
-version = "0.27-dev5"
+version = "0.27-dev6"
 description = "Squiggle programming language for intuitive probabilistic estimation features in Python"
 authors = ["Peter Wildeford <peter@rethinkpriorities.org>"]
 license = "MIT"
@@ -30,6 +30,9 @@ ruff = "^0.0.272"
 pytest = "^7.3.2"
 pytest-mock = "^3.10.0"
 black = "^23.3.0"
+seaborn = "^0.12.2"
+hypothesis = "^6.78.3"
+hypofuzz = "^23.6.1"
 
 [tool.poetry.extras]
 plots = ["matplotlib"]
diff --git a/squigglepy/__init__.py b/squigglepy/__init__.py
index f885e8b..9ac7350 100644
--- a/squigglepy/__init__.py
+++ b/squigglepy/__init__.py
@@ -3,4 +3,5 @@
 from .samplers import *  # noqa ignore=F405
 from .utils import *  # noqa ignore=F405
 from .rng import *  # noqa ignore=F405
+from .correlation import *  # noqa ignore=F405
 from .version import __version__  # noqa ignore=F405
diff --git a/squigglepy/correlation.py b/squigglepy/correlation.py
new file mode 100644
index 0000000..b14116a
--- /dev/null
+++ b/squigglepy/correlation.py
@@ -0,0 +1,348 @@
+"""
+This module implements the Iman-Conover method for inducing correlations between distributions.
+
+Some of the code has been adapted from Abraham Lee's mcerp package (https://github.com/tisimst/mcerp/).
+"""
+
+# Parts of `induce_correlation` are licensed as follows:
+
+# BSD 3-Clause License
+
+# Copyright (c) 2018, Abraham Lee
+# All rights reserved.
+
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+
+# * Redistributions of source code must retain the above copyright notice, this
+#   list of conditions and the following disclaimer.
+
+# * Redistributions in binary form must reproduce the above copyright notice,
+#   this list of conditions and the following disclaimer in the documentation
+#   and/or other materials provided with the distribution.
+
+# * Neither the name of the copyright holder nor the names of its
+#   contributors may be used to endorse or promote products derived from
+#   this software without specific prior written permission.
+
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import numpy as np
+from scipy.linalg import cholesky
+from scipy.stats import rankdata, spearmanr
+from scipy.stats.distributions import norm as _scipy_norm
+from numpy.typing import NDArray
+from copy import deepcopy
+
+from typing import TYPE_CHECKING, Union
+
+if TYPE_CHECKING:
+    from .distributions import OperableDistribution
+
+
+def correlate(
+    variables: tuple[OperableDistribution, ...],
+    correlation: Union[NDArray[np.float64], list[list[float]], np.float64, float],
+    tolerance: Union[float, np.float64, None] = 0.05,
+    _min_unique_samples: int = 100,
+):
+    """
+    Correlate a set of variables according to a rank correlation matrix.
+
+    This employs the Iman-Conover method to induce the correlation while
+    preserving the original marginal distributions.
+
+    This method works on a best-effort basis, and may fail to induce the desired
+    correlation depending on the distributions provided. An exception will be raised
+    if that's the case.
+
+    Parameters
+    ----------
+    variables : tuple of distributions
+        The variables to correlate as a tuple of distributions.
+
+        The distributions must be able to produce enough unique samples for the method
+        to be able to induce the desired correlation by shuffling the samples.
+
+        Discrete distributions are notably hard to correlate this way,
+        as it's common for them to result in very few unique samples.
+
+    correlation : 2d-array or float
+        An n-by-n array that defines the desired Spearman rank correlation coefficients.
+        This matrix must be symmetric and positives emi-definite; and must not be confused with
+        a covariance matrix.
+
+        Correlation parameters can only be between -1 and 1, exclusive
+        (including extremely close approximations).
+
+        If a float is provided, all variables will be correlated with the same coefficient.
+
+    tolerance : float, optional
+        If provided, overrides the absolute tolerance used to check if the resulting
+        correlation matrix matches the desired correlation matrix. Defaults to 0.05.
+
+        Checking can also be disabled by passing None.
+
+    Returns
+    -------
+    correlated_variables : tuple of distributions
+        The correlated variables as a tuple of distributions in the same order as
+        the input variables.
+
+    Examples
+    --------
+    Suppose we want to correlate two variables with a correlation coefficient of 0.65:
+    >>> solar_radiation, temperature = sq.gamma(300, 100), sq.to(22, 28)
+    >>> solar_radiation, temperature = sq.correlate((solar_radiation, temperature), 0.7)
+    >>> print(np.corrcoef(solar_radiation @ 1000, temperature @ 1000)[0, 1])
+        0.6975960649767123
+
+    Or you could pass a correlation matrix:
+    >>> funding_gap, cost_per_delivery, effect_size = (
+            sq.to(20_000, 80_000), sq.to(30, 80), sq.beta(2, 5)
+        )
+    >>> funding_gap, cost_per_delivery, effect_size = sq.correlate(
+            (funding_gap, cost_per_delivery, effect_size),
+            [[1, 0.6, -0.5], [0.6, 1, -0.2], [-0.5, -0.2, 1]]
+        )
+    >>> print(np.corrcoef(funding_gap @ 1000, cost_per_delivery @ 1000, effect_size @ 1000))
+        array([[ 1.      ,  0.580520  , -0.480149],
+               [ 0.580962,  1.        , -0.187831],
+               [-0.480149, -0.187831  ,  1.        ]])
+
+    """
+    if not isinstance(variables, tuple):
+        variables = tuple(variables)
+
+    if len(variables) < 2:
+        raise ValueError("You must provide at least two variables to correlate.")
+
+    assert all(v.correlation_group is None for v in variables)
+
+    # Convert a float to a correlation matrix
+    if (
+        isinstance(correlation, float)
+        or isinstance(correlation, np.floating)
+        or isinstance(correlation, int)
+    ):
+        correlation_parameter = np.float64(correlation)
+
+        assert (
+            -1 < correlation_parameter < 1
+        ), "Correlation parameter must be between -1 and 1, exclusive."
+        # Generate a correlation matrix with
+        # pairwise correlations equal to the correlation parameter
+        correlation_matrix: NDArray[np.float64] = np.full(
+            (len(variables), len(variables)), correlation_parameter
+        )
+        # Set the diagonal to 1
+        np.fill_diagonal(correlation_matrix, 1)
+    else:
+        # Coerce the correlation matrix into a numpy array
+        correlation_matrix: NDArray[np.float64] = np.array(correlation, dtype=np.float64)
+
+    tolerance = float(tolerance) if tolerance is not None else None
+
+    # Deepcopy the variables to avoid modifying the originals
+    variables = deepcopy(variables)
+
+    # Create the correlation group
+    CorrelationGroup(variables, correlation_matrix, tolerance, _min_unique_samples)
+
+    return variables
+
+
+@dataclass
+class CorrelationGroup:
+    """
+    An object that holds metadata for a group of correlated distributions.
+    This object is not intended to be used directly by the user, but
+    rather during sampling to induce correlations between distributions.
+    """
+
+    correlated_dists: tuple[OperableDistribution]
+    correlation_matrix: NDArray[np.float64]
+    correlation_tolerance: Union[float, None] = 0.05
+    min_unique_samples: int = 100
+
+    def __post_init__(self):
+        # Check that the correlation matrix is square of the expected size
+        assert (
+            self.correlation_matrix.shape[0]
+            == self.correlation_matrix.shape[1]
+            == len(self.correlated_dists)
+        ), "Correlation matrix must be square, and of the length of the number of dists. provided."
+
+        # Check that the diagonal of the correlation matrix is all ones
+        assert np.all(np.diag(self.correlation_matrix) == 1), "Diagonal must be all ones."
+
+        # Check that values are between -1 and 1
+        assert (
+            -1 <= np.min(self.correlation_matrix) and np.max(self.correlation_matrix) <= 1
+        ), "Correlation matrix values must be between -1 and 1."
+
+        # Check that the correlation matrix is positive semi-definite
+        assert np.all(
+            np.linalg.eigvals(self.correlation_matrix) >= 0
+        ), "Matrix must be positive semi-definite."
+
+        # Check that the correlation matrix is symmetric
+        assert np.all(
+            self.correlation_matrix == self.correlation_matrix.T
+        ), "Matrix must be symmetric."
+
+        # Link the correlation group to each distribution
+        for dist in self.correlated_dists:
+            dist.correlation_group = self
+
+    def induce_correlation(self, data: NDArray[np.float64]) -> NDArray[np.float64]:
+        """
+        Induce a set of correlations on a column-wise dataset
+
+        Parameters
+        ----------
+        data : 2d-array
+            An m-by-n array where m is the number of samples and n is the
+            number of independent variables, each column of the array corresponding
+            to each variable
+        corrmat : 2d-array
+            An n-by-n array that defines the desired correlation coefficients
+            (between -1 and 1). Note: the matrix must be symmetric and
+            positive-definite in order to induce.
+
+        Returns
+        -------
+        new_data : 2d-array
+            An m-by-n array that has the desired correlations.
+
+        """
+        # Check that each column doesn't have too little unique values
+        for column in data.T:
+            if not self.has_sufficient_sample_diversity(column):
+                raise ValueError(
+                    "The data has too many repeated values to induce a correlation. "
+                    "This might be because of too few samples, or too many repeated samples."
+                )
+
+        # If the correlation matrix is the identity matrix, just return the data
+        if np.all(self.correlation_matrix == np.eye(self.correlation_matrix.shape[0])):
+            return data
+
+        # Create a rank-matrix
+        data_rank = np.vstack([rankdata(datai, method="min") for datai in data.T]).T
+
+        # Generate van der Waerden scores
+        data_rank_score = data_rank / (data_rank.shape[0] + 1.0)
+        data_rank_score = _scipy_norm(0, 1).ppf(data_rank_score)
+
+        # Calculate the lower triangular matrix of the Cholesky decomposition
+        # of the desired correlation matrix
+        p = cholesky(self.correlation_matrix, lower=True)
+
+        # Calculate the current correlations
+        t = np.corrcoef(data_rank_score, rowvar=False)
+
+        # Calculate the lower triangular matrix of the Cholesky decomposition
+        # of the current correlation matrix
+        q = cholesky(t, lower=True)
+
+        # Calculate the re-correlation matrix
+        s = np.dot(p, np.linalg.inv(q))
+
+        # Calculate the re-sampled matrix
+        new_data = np.dot(data_rank_score, s.T)
+
+        # Create the new rank matrix
+        new_data_rank = np.vstack([rankdata(datai, method="min") for datai in new_data.T]).T
+
+        # Sort the original data according to the new rank matrix
+        self._sort_data_according_to_rank(data, data_rank, new_data_rank)
+
+        # # Check correlation
+        if self.correlation_tolerance:
+            self._check_empirical_correlation(data)
+
+        return data
+
+    def _sort_data_according_to_rank(
+        self,
+        data: NDArray[np.float64],
+        data_rank: NDArray[np.float64],
+        new_data_rank: NDArray[np.float64],
+    ):
+        """Sorts the original data according to new_data_rank, in place."""
+        assert (
+            data.shape == data_rank.shape == new_data_rank.shape
+        ), "All input arrays must have the same shape"
+        for i in range(data.shape[1]):
+            _, order = np.unique(
+                np.hstack((data_rank[:, i], new_data_rank[:, i])), return_inverse=True
+            )
+            old_order = order[: new_data_rank.shape[0]]
+            new_order = order[-new_data_rank.shape[0] :]
+            tmp = data[np.argsort(old_order), i][new_order]
+            data[:, i] = tmp[:]
+
+    def _check_empirical_correlation(self, samples: NDArray[np.float64]):
+        """
+        Ensures that the empirical correlation matrix is
+        the same as the desired correlation matrix.
+        """
+        assert self.correlation_tolerance is not None
+
+        # Compute the empirical correlation matrix
+        empirical_correlation = spearmanr(samples).statistic
+        if len(self.correlated_dists) == 2:
+            # empirical_correlation is a scalar
+            properly_correlated = np.isclose(
+                empirical_correlation,
+                self.correlation_matrix[0, 1],
+                atol=self.correlation_tolerance,
+                rtol=0,
+            )
+        else:
+            # empirical_correlation is a matrix
+            properly_correlated = np.allclose(
+                empirical_correlation,
+                self.correlation_matrix,
+                atol=self.correlation_tolerance,
+                rtol=0,
+            )
+        if not properly_correlated:
+            raise RuntimeError(
+                "Failed to induce the desired correlation between samples. "
+                "This might be because of too little diversity in the samples. "
+                "You can relax the tolerance by passing `tolerance` to correlate()."
+            )
+
+    def has_sufficient_sample_diversity(
+        self,
+        samples: NDArray[np.float64],
+        relative_threshold: float = 0.7,
+        absolute_threshold=None,
+    ) -> bool:
+        """
+        Check if there is there are sufficient unique samples to work with in the data.
+        """
+
+        if absolute_threshold is None:
+            absolute_threshold = self.min_unique_samples
+
+        unique_samples = len(np.unique(samples, axis=0))
+        n_samples = len(samples)
+
+        diversity = unique_samples / n_samples
+
+        return (diversity >= relative_threshold) and (unique_samples >= absolute_threshold)
diff --git a/squigglepy/distributions.py b/squigglepy/distributions.py
index daef6a6..2e3f2b4 100644
--- a/squigglepy/distributions.py
+++ b/squigglepy/distributions.py
@@ -1,13 +1,20 @@
 import operator
 import math
 import numpy as np
-from scipy import stats
+import scipy.stats
+
+from typing import Optional, Union
 
 from .utils import _process_weights_values, _is_numpy, is_dist, _round
 from .version import __version__
+from .correlation import CorrelationGroup
+
+from collections.abc import Iterable
+
+from abc import ABC, abstractmethod
 
 
-class BaseDistribution:
+class BaseDistribution(ABC):
     def __init__(self):
         self.x = None
         self.y = None
@@ -35,11 +42,25 @@ def __init__(self):
         self.weights = None
         self._version = __version__
 
-    def __str__(self):
-        return "<Distribution> base"
+        # Correlation metadata
+        self.correlation_group: Optional[CorrelationGroup] = None
+        self._correlated_samples: Optional[np.ndarray] = None
+
+    @abstractmethod
+    def __str__(self) -> str:
+        ...
 
     def __repr__(self):
-        return str(self)
+        if self.correlation_group:
+            return (
+                self.__str__() + f" (version {self._version}, corr_group {self.correlation_group})"
+            )
+        return self.__str__() + f" (version {self._version})"
+
+
+class OperableDistribution(BaseDistribution):
+    def __init__(self):
+        super().__init__()
 
     def plot(self, num_samples=None, bins=None):
         """
@@ -66,11 +87,6 @@ def plot(self, num_samples=None, bins=None):
         plt.hist(samples, bins=bins)
         plt.show()
 
-
-class OperableDistribution(BaseDistribution):
-    def __init__(self):
-        super().__init__()
-
     def __invert__(self):
         from .samplers import sample
 
@@ -157,7 +173,38 @@ def __hash__(self):
         return hash(repr(self))
 
 
-class ComplexDistribution(OperableDistribution):
+# Distribution are either discrete, continuous, or composite
+
+
+class DiscreteDistribution(OperableDistribution, ABC):
+    ...
+
+
+class ContinuousDistribution(OperableDistribution, ABC):
+    ...
+
+
+class CompositeDistribution(OperableDistribution):
+    def __init__(self):
+        super().__init__()
+        # Whether this distribution contains any correlated variables
+        self.contains_correlated: Optional[bool] = None
+
+    def __post_init__(self):
+        assert self.contains_correlated is not None, "contains_correlated must be set"
+
+    def _check_correlated(self, dists: Iterable) -> None:
+        for dist in dists:
+            if isinstance(dist, BaseDistribution) and dist.correlation_group is not None:
+                self.contains_correlated = True
+                break
+            if isinstance(dist, CompositeDistribution):
+                if dist.contains_correlated:
+                    self.contains_correlated = True
+                    break
+
+
+class ComplexDistribution(CompositeDistribution):
     def __init__(self, left, right=None, fn=operator.add, fn_str="+", infix=True):
         super().__init__()
         self.left = left
@@ -165,6 +212,7 @@ def __init__(self, left, right=None, fn=operator.add, fn_str="+", infix=True):
         self.fn = fn
         self.fn_str = fn_str
         self.infix = infix
+        self._check_correlated((left, right))
 
     def __str__(self):
         if self.right is None and self.infix:
@@ -572,7 +620,7 @@ def clip(dist1, left, right=None):
         return rclip(lclip(dist1, left), right)
 
 
-class ConstantDistribution(OperableDistribution):
+class ConstantDistribution(DiscreteDistribution):
     def __init__(self, x):
         super().__init__()
         self.x = x
@@ -604,11 +652,12 @@ def const(x):
     return ConstantDistribution(x)
 
 
-class UniformDistribution(OperableDistribution):
+class UniformDistribution(ContinuousDistribution):
     def __init__(self, x, y):
         super().__init__()
         self.x = x
         self.y = y
+        assert x < y, "x must be less than y"
 
     def __str__(self):
         return "<Distribution> uniform({}, {})".format(self.x, self.y)
@@ -637,7 +686,7 @@ def uniform(x, y):
     return UniformDistribution(x=x, y=y)
 
 
-class NormalDistribution(OperableDistribution):
+class NormalDistribution(ContinuousDistribution):
     def __init__(self, x=None, y=None, mean=None, sd=None, credibility=90, lclip=None, rclip=None):
         super().__init__()
         self.x = x
@@ -661,7 +710,7 @@ def __init__(self, x=None, y=None, mean=None, sd=None, credibility=90, lclip=Non
         if self.mean is None and self.sd is None:
             self.mean = (self.x + self.y) / 2
             cdf_value = 0.5 + 0.5 * (self.credibility / 100)
-            normed_sigma = stats.norm.ppf(cdf_value)
+            normed_sigma = scipy.stats.norm.ppf(cdf_value)
             self.sd = (self.y - self.mean) / normed_sigma
 
     def __str__(self):
@@ -674,7 +723,9 @@ def __str__(self):
         return out
 
 
-def norm(x=None, y=None, credibility=90, mean=None, sd=None, lclip=None, rclip=None):
+def norm(
+    x=None, y=None, credibility=90, mean=None, sd=None, lclip=None, rclip=None
+) -> NormalDistribution:
     """
     Initialize a normal distribution.
 
@@ -715,7 +766,7 @@ def norm(x=None, y=None, credibility=90, mean=None, sd=None, lclip=None, rclip=N
     )
 
 
-class LognormalDistribution(OperableDistribution):
+class LognormalDistribution(ContinuousDistribution):
     def __init__(
         self,
         x=None,
@@ -768,7 +819,7 @@ def __init__(
         if self.x is not None:
             self.norm_mean = (np.log(self.x) + np.log(self.y)) / 2
             cdf_value = 0.5 + 0.5 * (self.credibility / 100)
-            normed_sigma = stats.norm.ppf(cdf_value)
+            normed_sigma = scipy.stats.norm.ppf(cdf_value)
             self.norm_sd = (np.log(self.y) - self.norm_mean) / normed_sigma
 
         if self.lognorm_sd is None:
@@ -865,7 +916,9 @@ def lognorm(
     )
 
 
-def to(x, y, credibility=90, lclip=None, rclip=None):
+def to(
+    x, y, credibility=90, lclip=None, rclip=None
+) -> Union[LognormalDistribution, NormalDistribution]:
     """
     Initialize a distribution from ``x`` to ``y``.
 
@@ -905,13 +958,13 @@ def to(x, y, credibility=90, lclip=None, rclip=None):
         return norm(x=x, y=y, credibility=credibility, lclip=lclip, rclip=rclip)
 
 
-class BinomialDistribution(OperableDistribution):
+class BinomialDistribution(DiscreteDistribution):
     def __init__(self, n, p):
         super().__init__()
         self.n = n
         self.p = p
-        if self.p < 0 or self.p > 1:
-            raise ValueError("p must be between 0 and 1")
+        if self.p <= 0 or self.p >= 1:
+            raise ValueError("p must be between 0 and 1 (exclusive)")
 
     def __str__(self):
         return "<Distribution> binomial(n={}, p={})".format(self.n, self.p)
@@ -940,7 +993,7 @@ def binomial(n, p):
     return BinomialDistribution(n=n, p=p)
 
 
-class BetaDistribution(OperableDistribution):
+class BetaDistribution(ContinuousDistribution):
     def __init__(self, a, b):
         super().__init__()
         self.a = a
@@ -975,13 +1028,13 @@ def beta(a, b):
     return BetaDistribution(a, b)
 
 
-class BernoulliDistribution(OperableDistribution):
+class BernoulliDistribution(DiscreteDistribution):
     def __init__(self, p):
         super().__init__()
         if not isinstance(p, float) or isinstance(p, int):
             raise ValueError("bernoulli p must be a float or int")
-        if p < 0 or p > 1:
-            raise ValueError("bernoulli p must be 0-1")
+        if p <= 0 or p >= 1:
+            raise ValueError("bernoulli p must be 0-1 (exclusive)")
         self.p = p
 
     def __str__(self):
@@ -1009,15 +1062,16 @@ def bernoulli(p):
     return BernoulliDistribution(p)
 
 
-class DiscreteDistribution(OperableDistribution):
+class CategoricalDistribution(DiscreteDistribution):
     def __init__(self, items):
         super().__init__()
         if not isinstance(items, dict) and not isinstance(items, list) and not _is_numpy(items):
-            raise ValueError("inputs to discrete must be a dict or list")
+            raise ValueError("inputs to categorical must be a dict or list")
+        assert len(items) > 0, "inputs to categorical must be non-empty"
         self.items = list(items) if _is_numpy(items) else items
 
     def __str__(self):
-        return "<Distribution> discrete({})".format(self.items)
+        return "<Distribution> categorical({})".format(self.items)
 
 
 def discrete(items):
@@ -1032,23 +1086,23 @@ def discrete(items):
 
     Returns
     -------
-    DiscreteDistribution
+    CategoricalDistribution
 
     Examples
     --------
     >>> discrete({0: 0.1, 1: 0.9})  # 10% chance of returning 0, 90% chance of returning 1
-    <Distribution> discrete({0: 0.1, 1: 0.9})
+    <Distribution> categorical({0: 0.1, 1: 0.9})
     >>> discrete([[0.1, 0], [0.9, 1]])  # Different notation for the same thing.
-    <Distribution> discrete([[0.1, 0], [0.9, 1]])
+    <Distribution> categorical([[0.1, 0], [0.9, 1]])
     >>> discrete([0, 1, 2])  # When no weights are given, all have equal chance of happening.
-    <Distribution> discrete([0, 1, 2])
+    <Distribution> categorical([0, 1, 2])
     >>> discrete({'a': 0.1, 'b': 0.9})  # Values do not have to be numbers.
-    <Distribution> discrete({'a': 0.1, 'b': 0.9})
+    <Distribution> categorical({'a': 0.1, 'b': 0.9})
     """
-    return DiscreteDistribution(items)
+    return CategoricalDistribution(items)
 
 
-class TDistribution(OperableDistribution):
+class TDistribution(ContinuousDistribution):
     def __init__(self, x=None, y=None, t=20, credibility=90, lclip=None, rclip=None):
         super().__init__()
         self.x = x
@@ -1122,7 +1176,7 @@ def tdist(x=None, y=None, t=20, credibility=90, lclip=None, rclip=None):
     return TDistribution(x=x, y=y, t=t, credibility=credibility, lclip=lclip, rclip=rclip)
 
 
-class LogTDistribution(OperableDistribution):
+class LogTDistribution(ContinuousDistribution):
     def __init__(self, x=None, y=None, t=1, credibility=90, lclip=None, rclip=None):
         super().__init__()
         self.x = x
@@ -1135,8 +1189,10 @@ def __init__(self, x=None, y=None, t=1, credibility=90, lclip=None, rclip=None):
 
         if (self.x is None or self.y is None) and not (self.x is None and self.y is None):
             raise ValueError("must define either both `x` and `y` or neither.")
-        elif self.x is not None and self.y is not None and self.x > self.y:
+        if self.x is not None and self.y is not None and self.x > self.y:
             raise ValueError("`high value` cannot be lower than `low value`")
+        if self.x is not None and self.x <= 0:
+            raise ValueError("`low value` must be greater than 0.")
 
         if self.x is None:
             self.credibility = None
@@ -1170,7 +1226,8 @@ def log_tdist(x=None, y=None, t=1, credibility=90, lclip=None, rclip=None):
     Parameters
     ----------
     x : float or None
-        The low value of a credible interval defined by ``credibility``. Defaults to a 90% CI.
+        The low value of a credible interval defined by ``credibility``. Must be greater than 0.
+        Defaults to a 90% CI.
     y : float or None
         The high value of a credible interval defined by ``credibility``. Defaults to a 90% CI.
     t : float
@@ -1196,7 +1253,7 @@ def log_tdist(x=None, y=None, t=1, credibility=90, lclip=None, rclip=None):
     return LogTDistribution(x=x, y=y, t=t, credibility=credibility, lclip=lclip, rclip=rclip)
 
 
-class TriangularDistribution(OperableDistribution):
+class TriangularDistribution(ContinuousDistribution):
     def __init__(self, left, mode, right, lclip=None, rclip=None):
         super().__init__()
         self.left = left
@@ -1244,7 +1301,7 @@ def triangular(left, mode, right, lclip=None, rclip=None):
     return TriangularDistribution(left=left, mode=mode, right=right, lclip=lclip, rclip=rclip)
 
 
-class PoissonDistribution(OperableDistribution):
+class PoissonDistribution(DiscreteDistribution):
     def __init__(self, lam, lclip=None, rclip=None):
         super().__init__()
         self.lam = lam
@@ -1286,7 +1343,7 @@ def poisson(lam, lclip=None, rclip=None):
     return PoissonDistribution(lam=lam, lclip=lclip, rclip=rclip)
 
 
-class ChiSquareDistribution(OperableDistribution):
+class ChiSquareDistribution(ContinuousDistribution):
     def __init__(self, df):
         super().__init__()
         self.df = df
@@ -1318,9 +1375,12 @@ def chisquare(df):
     return ChiSquareDistribution(df=df)
 
 
-class ExponentialDistribution(OperableDistribution):
+class ExponentialDistribution(ContinuousDistribution):
     def __init__(self, scale, lclip=None, rclip=None):
         super().__init__()
+        assert scale > 0, "scale must be positive"
+        # Prevent numeric overflows
+        assert scale < 1e20, "scale must be less than 1e20"
         self.scale = scale
         self.lclip = lclip
         self.rclip = rclip
@@ -1342,7 +1402,7 @@ def exponential(scale, lclip=None, rclip=None):
     Parameters
     ----------
     scale : float
-        The scale value of the exponential distribution.
+        The scale value of the exponential distribution (> 0)
     lclip : float or None
         If not None, any value below ``lclip`` will be coerced to ``lclip``.
     rclip : float or None
@@ -1360,7 +1420,7 @@ def exponential(scale, lclip=None, rclip=None):
     return ExponentialDistribution(scale=scale, lclip=lclip, rclip=rclip)
 
 
-class GammaDistribution(OperableDistribution):
+class GammaDistribution(ContinuousDistribution):
     def __init__(self, shape, scale=1, lclip=None, rclip=None):
         super().__init__()
         self.shape = shape
@@ -1405,7 +1465,7 @@ def gamma(shape, scale=1, lclip=None, rclip=None):
     return GammaDistribution(shape=shape, scale=scale, lclip=lclip, rclip=rclip)
 
 
-class ParetoDistribution(OperableDistribution):
+class ParetoDistribution(ContinuousDistribution):
     def __init__(self, shape):
         super().__init__()
         self.shape = shape
@@ -1435,7 +1495,7 @@ def pareto(shape):
     return ParetoDistribution(shape=shape)
 
 
-class MixtureDistribution(OperableDistribution):
+class MixtureDistribution(CompositeDistribution):
     def __init__(self, dists, weights=None, relative_weights=None, lclip=None, rclip=None):
         super().__init__()
         weights, dists = _process_weights_values(weights, relative_weights, dists)
@@ -1443,6 +1503,7 @@ def __init__(self, dists, weights=None, relative_weights=None, lclip=None, rclip
         self.weights = weights
         self.lclip = lclip
         self.rclip = rclip
+        self._check_correlated(dists)
 
     def __str__(self):
         out = "<Distribution> mixture"
diff --git a/squigglepy/samplers.py b/squigglepy/samplers.py
index fb56b10..4db0baf 100644
--- a/squigglepy/samplers.py
+++ b/squigglepy/samplers.py
@@ -4,6 +4,8 @@
 import numpy as np
 import pathos.multiprocessing as mp
 
+from numpy.typing import NDArray
+
 from scipy import stats
 
 from .utils import (
@@ -21,13 +23,14 @@
 )
 
 from .distributions import (
+    BaseDistribution,
     BernoulliDistribution,
     BetaDistribution,
     BinomialDistribution,
     ChiSquareDistribution,
     ComplexDistribution,
     ConstantDistribution,
-    DiscreteDistribution,
+    CategoricalDistribution,
     ExponentialDistribution,
     GammaDistribution,
     GeometricDistribution,
@@ -176,7 +179,8 @@ def log_t_sample(low=None, high=None, t=20, samples=1, credibility=90):
     Parameters
     ----------
     low : float or None
-        The low value of a credible interval defined by ``credibility``. Defaults to a 90% CI.
+        The low value of a credible interval defined by ``credibility``.
+        Must be greater than 0. Defaults to a 90% CI.
     high : float or None
         The high value of a credible interval defined by ``credibility``. Defaults to a 90% CI.
     t : float
@@ -578,6 +582,14 @@ def _mixture_sample_for_large_n(
     _multicore_tqdm_cores=1,
 ):
     def _run_presample(dist, pbar):
+        if is_dist(dist) and isinstance(dist, MixtureDistribution):
+            raise ValueError(
+                ("You cannot nest mixture distributions within " + "mixture distributions.")
+            )
+        elif is_dist(dist) and isinstance(dist, CategoricalDistribution):
+            raise ValueError(
+                ("You cannot nest discrete distributions within " + "mixture distributions.")
+            )
         _tick_tqdm(pbar)
         return _enlist(sample(dist, n=samples))
 
@@ -709,6 +721,51 @@ def mixture_sample(
         )
 
 
+def sample_correlated_group(
+    requested_dist: BaseDistribution, n: int, verbose=False
+) -> NDArray[np.float64]:
+    """
+    Samples a correlated distribution, alongside
+    all other correlated distributions in the same group.
+
+    The samples for other variables are stored in the distributions themselves
+    (in `_correlated_samples`).
+
+    This is necessary, because the sampling needs to happen all at once, regardless
+    of where the distributions are used in the binary tree of operations.
+    """
+    group = requested_dist.correlation_group
+    assert group is not None
+
+    samples = np.column_stack(
+        [
+            # Skip correlation to prevent infinite recursion
+            # TODO: Check that this does not interfere
+            # with other correlated distributions downstream
+            sample(dist, n, verbose=verbose, _correlate_if_needed=False)
+            for dist in group.correlated_dists
+        ]
+    )
+    # Induce correlation
+    samples = group.induce_correlation(samples)
+
+    # Store the samples in each distribution
+    # except the one we are sampling from
+    # so it requires resampling next time
+    requested_samples = None
+    for i, target_distribution in enumerate(group.correlated_dists):
+        if requested_dist is not target_distribution:
+            # Store the samples in the distribution
+            target_distribution._correlated_samples = samples[:, i]
+        else:
+            # Store the samples we requested
+            requested_samples = samples[:, i]
+
+    assert requested_samples is not None
+
+    return requested_samples
+
+
 def sample(
     dist=None,
     n=1,
@@ -723,6 +780,7 @@ def sample(
     cores=1,
     _multicore_tqdm_n=1,
     _multicore_tqdm_cores=1,
+    _correlate_if_needed=True,
 ):
     """
     Sample random numbers from a given distribution.
@@ -918,13 +976,37 @@ def run_dist(dist, pbar=None, tick=1):
         ):
             samples = _simplify(np.array([dist for _ in range(n)]))
 
+        # Distribution is part of a correlation group
+        # and has not been sampled yet
+        elif (
+            isinstance(dist, BaseDistribution)
+            and _correlate_if_needed
+            and dist.correlation_group is not None
+            and dist._correlated_samples is None
+        ):
+            # Samples the entire correlated group at once
+            samples = sample_correlated_group(dist, n=n, verbose=verbose)
+
+        # Distribution has already been sampled
+        # as part of a correlation group
+        elif (
+            isinstance(dist, BaseDistribution)
+            and _correlate_if_needed
+            and dist.correlation_group is not None
+            and dist._correlated_samples is not None
+        ):
+            samples = dist._correlated_samples
+            # This forces the distribution to be resampled
+            # if the user attempts to sample from it again
+            dist._correlated_samples = None
+
         elif isinstance(dist, ConstantDistribution):
             samples = _simplify(np.array([dist.x for _ in range(n)]))
 
         elif isinstance(dist, UniformDistribution):
             samples = uniform_sample(dist.x, dist.y, samples=n)
 
-        elif isinstance(dist, DiscreteDistribution):
+        elif isinstance(dist, CategoricalDistribution):
             samples = discrete_sample(
                 dist.items,
                 samples=n,
diff --git a/squigglepy/utils.py b/squigglepy/utils.py
index f07e1da..03e02dc 100644
--- a/squigglepy/utils.py
+++ b/squigglepy/utils.py
@@ -229,6 +229,26 @@ def is_dist(obj):
     return isinstance(obj, BaseDistribution)
 
 
+def is_continuous_dist(obj):
+    from .distributions import (
+        ContinuousDistribution,
+        CompositeDistribution,
+        ComplexDistribution,
+        MixtureDistribution,
+    )
+
+    if isinstance(obj, ContinuousDistribution):
+        return True
+    elif isinstance(obj, CompositeDistribution):
+        if isinstance(obj, ComplexDistribution):
+            return is_continuous_dist(obj.left) and is_continuous_dist(obj.right)
+        elif isinstance(obj, MixtureDistribution):
+            return all([is_continuous_dist(d) for d in obj.dists])
+        else:
+            raise ValueError("Unknown composite distribution")
+    return False
+
+
 def is_sampleable(obj):
     """
     Test if a given object can be sampled from.
diff --git a/squigglepy/version.py b/squigglepy/version.py
index 14504ed..590af69 100644
--- a/squigglepy/version.py
+++ b/squigglepy/version.py
@@ -1,1 +1,1 @@
-__version__ = "0.27-dev5"
+__version__ = "0.27-dev6"


Test patch:
diff --git a/tests/strategies.py b/tests/strategies.py
new file mode 100644
index 0000000..129307d
--- /dev/null
+++ b/tests/strategies.py
@@ -0,0 +1,270 @@
+from typing import Callable
+
+import hypothesis.strategies as st
+import numpy as np
+from hypothesis import assume, note
+from hypothesis.extra.numpy import arrays
+
+from .. import squigglepy as sq
+
+CONTINUOUS_DISTRIBUTIONS = [
+    sq.uniform,
+    sq.norm,
+    sq.lognorm,
+    # sq.to, # Disabled to help isolate errors to either normal or lognormal
+    sq.beta,
+    sq.tdist,
+    # sq.log_tdist,  # TODO: Re-enable when overflows are fixed
+    sq.triangular,
+    sq.chisquare,
+    sq.exponential,
+    sq.gamma,
+    sq.pareto,
+]
+
+DISCRETE_DISTRIBUTIONS = [
+    sq.binomial,
+    sq.bernoulli,
+    sq.discrete,
+    sq.poisson,
+]
+
+ALL_DISTRIBUTIONS = CONTINUOUS_DISTRIBUTIONS + DISCRETE_DISTRIBUTIONS
+
+
+@st.composite
+def distributions_with_correlation(draw, min_size=2, max_size=20, continuous_only=False):
+    dists = tuple(
+        draw(
+            st.lists(
+                random_distributions(continuous_only),
+                min_size=min_size,
+                max_size=max_size,
+            )
+        )
+    )
+    corr = draw(correlation_matrices(min_size=len(dists), max_size=len(dists)))
+    note(f"Distributions: {dists}")
+    note(f"Correlation matrix: {corr}")
+    return dists, corr
+
+
+@st.composite
+def correlation_matrices(draw, min_size=2, max_size=20):
+    # Generate a random list of correlations
+    n_variables = draw(st.integers(min_size, max_size))
+    correlation_matrix = draw(
+        arrays(np.float64, (n_variables, n_variables), elements=st.floats(-0.99, 0.99))
+    )
+    # Reflect the matrix
+    correlation_matrix = np.tril(correlation_matrix) + np.tril(correlation_matrix, -1).T
+    # Fill the diagonal with 1s
+    np.fill_diagonal(correlation_matrix, 1)
+
+    # Reject if not positive semi-definite
+    assume(np.all(np.linalg.eigvals(correlation_matrix) >= 0))
+
+    return correlation_matrix
+
+
+@st.composite
+def random_distributions(
+    draw, continuous_only: bool = False, discrete_only: bool = False
+) -> sq.OperableDistribution:
+    assert not (continuous_only and discrete_only), "Cannot be both continuous and discrete"
+
+    if continuous_only:
+        dist = instantiate_with_parameters(draw, draw(st.sampled_from(CONTINUOUS_DISTRIBUTIONS)))
+        assert isinstance(dist, sq.ContinuousDistribution), f"{dist} is not continuous"
+    elif discrete_only:
+        dist = instantiate_with_parameters(draw, draw(st.sampled_from(DISCRETE_DISTRIBUTIONS)))
+        assert isinstance(dist, sq.DiscreteDistribution), f"{dist} is not discrete"
+    else:
+        dist = instantiate_with_parameters(draw, draw(st.sampled_from(ALL_DISTRIBUTIONS)))
+        assert isinstance(dist, sq.OperableDistribution), f"{dist} is not an operable distribution"
+
+    return dist
+
+
+def instantiate_with_parameters(draw, dist_fn: Callable) -> sq.OperableDistribution:
+    if dist_fn == sq.uniform:
+        a = draw(
+            st.floats(-1e30, 1e30, allow_nan=False, allow_infinity=False, allow_subnormal=False)
+        )
+        b = draw(
+            st.floats(
+                min_value=a + 2,
+                max_value=a + 1e30,
+                allow_nan=False,
+                allow_subnormal=False,
+                exclude_min=True,
+            )
+        )
+        return dist_fn(a, b)
+    elif dist_fn in (sq.norm, sq.tdist):
+        # Distributions that receive confidence intervals
+        a = draw(
+            st.floats(-1e30, 1e30, allow_infinity=False, allow_nan=False, allow_subnormal=False)
+        )
+        b = draw(
+            st.floats(
+                min_value=a + 0.01,
+                max_value=a + 1e30,
+                allow_infinity=False,
+                allow_nan=False,
+                exclude_min=True,
+                allow_subnormal=False,
+            )
+        )
+        return dist_fn(a, b)
+    elif dist_fn in (sq.lognorm, sq.log_tdist):
+        # Distributions that receive confidence intervals starting from 0
+        a = draw(
+            st.floats(
+                0.005,
+                1e20,
+                allow_infinity=False,
+                allow_nan=False,
+                allow_subnormal=False,
+                exclude_min=True,
+            )
+        )
+        b = draw(
+            st.floats(
+                min_value=a + 0.05,
+                max_value=a + 1e20,
+                allow_infinity=False,
+                allow_nan=False,
+                exclude_min=True,
+                allow_subnormal=False,
+            )
+        )
+        return dist_fn(a, b)
+    elif dist_fn == sq.binomial:
+        n = draw(st.integers(1, 500))
+        p = draw(st.floats(0.01, 0.999, exclude_min=True, exclude_max=True))
+        return dist_fn(n, p)
+    elif dist_fn == sq.bernoulli:
+        p = draw(st.floats(0.01, 0.999, exclude_min=True, exclude_max=True, allow_subnormal=False))
+        return dist_fn(p)
+    elif dist_fn == sq.discrete:
+        items = draw(
+            st.dictionaries(
+                st.floats(allow_infinity=False, allow_nan=False),
+                st.floats(0, 1, exclude_min=True),
+                min_size=1,
+            )
+        )
+        # Normalize the probabilities
+        normalized_items = dict()
+        value_sum = sum(items.values())
+        for k, v in items.items():
+            normalized_items[k] = v / value_sum
+
+        return dist_fn(normalized_items)
+    elif dist_fn == sq.exponential:
+        a = draw(
+            st.floats(
+                min_value=0,
+                max_value=1e20,  # Prevents overflow
+                exclude_min=True,
+                exclude_max=True,
+                allow_infinity=False,
+                allow_nan=False,
+                allow_subnormal=False,  # Prevents overflow (again)
+            )
+        )
+        return dist_fn(a)
+    elif dist_fn == sq.beta:
+        a = draw(
+            st.floats(
+                min_value=0.01,
+                max_value=100,
+                exclude_min=True,
+                allow_nan=False,
+                allow_infinity=False,
+                allow_subnormal=False,
+            )
+        )
+        b = draw(
+            st.floats(
+                min_value=0.01,
+                max_value=100,
+                exclude_min=True,
+                allow_nan=False,
+                allow_infinity=False,
+                allow_subnormal=False,
+            )
+        )
+        return dist_fn(a, b)
+
+    elif dist_fn == sq.triangular:
+        a = draw(
+            st.floats(-1e30, 1e30, allow_infinity=False, allow_nan=False, allow_subnormal=False)
+        )
+        b = draw(
+            st.floats(
+                min_value=a + 0.05,
+                max_value=a + 1e30,
+                allow_infinity=False,
+                allow_nan=False,
+                allow_subnormal=False,
+                exclude_min=True,
+            )
+        )
+        c = draw(
+            st.floats(
+                min_value=a,
+                max_value=b,
+                allow_infinity=False,
+                allow_nan=False,
+                allow_subnormal=False,
+            )
+        )
+        return dist_fn(a, c, b)
+
+    elif dist_fn == sq.poisson:
+        lambda_ = draw(st.integers(1, 1000))
+        return dist_fn(lambda_)
+
+    elif dist_fn == sq.chisquare:
+        df = draw(st.integers(1, 1000))
+        return dist_fn(df)
+
+    elif dist_fn == sq.gamma:
+        shape = draw(
+            st.floats(
+                min_value=0.01,
+                max_value=100,
+                exclude_min=True,
+                allow_nan=False,
+                allow_infinity=False,
+                allow_subnormal=False,
+            )
+        )
+        scale = draw(
+            st.floats(
+                min_value=0.01,
+                max_value=100,
+                exclude_min=True,
+                allow_nan=False,
+                allow_infinity=False,
+                allow_subnormal=False,
+            )
+        )
+        return dist_fn(shape, scale)
+
+    elif dist_fn == sq.pareto:
+        b = draw(
+            st.floats(
+                min_value=1.01,
+                max_value=10.0,
+                exclude_min=True,
+                allow_nan=False,
+                allow_infinity=False,
+                allow_subnormal=False,
+            )
+        )
+        return dist_fn(b)
+    else:
+        raise NotImplementedError(f"Unknown distribution {dist_fn}")
diff --git a/tests/test_correlation.py b/tests/test_correlation.py
new file mode 100644
index 0000000..eee523b
--- /dev/null
+++ b/tests/test_correlation.py
@@ -0,0 +1,131 @@
+import scipy.stats as stats
+
+from .. import squigglepy as sq
+from .strategies import distributions_with_correlation
+from hypothesis import given, assume, note, example
+import hypothesis.strategies as st
+import numpy as np
+import warnings
+
+
+def check_correlation_from_matrix(dists, corr, atol=0.08):
+    samples = np.column_stack([dist @ 3_000 for dist in dists])
+    estimated_corr = stats.spearmanr(samples).statistic
+    note(f"Estimated correlation: {estimated_corr}")
+    if len(dists) == 2:
+        note(f"Desired correlation: {corr[0, 1]}")
+        assert np.all(np.isclose(estimated_corr, corr[0, 1], atol=atol))
+    else:
+        note(f"Desired correlation: {corr}")
+        assert np.all(np.isclose(estimated_corr, corr, atol=atol))
+    return samples
+
+
+def check_correlation_from_parameter(dists_or_samples, corr, atol=0.08):
+    assert len(dists_or_samples) == 2
+    if isinstance(dists_or_samples[0], sq.OperableDistribution):
+        # Sample
+        samples = np.column_stack([dists_or_samples[0] @ 5_000, dists_or_samples[1] @ 5_000])
+    else:
+        assert isinstance(dists_or_samples[0], np.ndarray)
+        samples = np.column_stack(dists_or_samples)
+
+    note(f"Desired correlation: {corr}")
+    estimated_corr = stats.spearmanr(samples).statistic
+    note(f"Estimated correlation: {estimated_corr}")
+    assert np.all(np.isclose(estimated_corr, corr, atol=atol))
+
+
+@given(st.floats(-0.999, 0.999))
+@example(corr=0.5).via("discovered failure")
+def test_basic_correlates(corr):
+    """
+    Test a basic example of correlation between two distributions.
+    This ensures that the resulting distributions are correlated as expected.
+    """
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+
+        a_params = (-1, 1)
+        b_params = (0, 1)
+
+        a, b = sq.UniformDistribution(*a_params), sq.NormalDistribution(
+            mean=b_params[0], sd=b_params[1]
+        )
+        a, b = sq.correlate((a, b), corr, tolerance=None)
+
+        # Sample
+        a_samples = a @ 3_000
+        b_samples = b @ 3_000
+        check_correlation_from_parameter((a, b), corr)
+
+        # Check marginal distributions
+        # a (uniform)
+        assert np.isclose(
+            np.mean(a_samples), np.mean(a_params), atol=0.08
+        ), f"Mean: {np.mean(a_samples)} != {np.mean(a_params)}"
+        expected_sd = np.sqrt((a_params[1] - a_params[0]) ** 2 / 12)
+        assert np.isclose(
+            np.std(a_samples), expected_sd, atol=0.08
+        ), f"SD: {np.std(a_samples)} != {expected_sd}"
+        # b (normal)
+        assert np.isclose(np.mean(b_samples), b_params[0], atol=0.08), np.mean(b_samples)
+        assert np.isclose(np.std(b_samples), b_params[1], atol=0.08), np.std(b_samples)
+
+
+@given(distributions_with_correlation())
+def test_arbitrary_correlates(dist_corrs):
+    """
+    Test multi-variable correlation with a series of arbitrary random variables,
+    and an arbitrarily generated correlation matrix.
+
+    Ensures the resulting corr. matrix is as expected, and that the marginal
+    distributionsremain intact.
+    """
+
+    uncorrelated_dists, corrs = dist_corrs
+    correlated_dists = sq.correlate(
+        uncorrelated_dists, corrs, tolerance=None, _min_unique_samples=1_000
+    )
+    try:
+        # The tolerance is quite high here, given that we're only
+        # interested in very signifcant errors. The user would be warned
+        # if the correlation was too far off anyway (with less tolerance).
+        check_correlation_from_matrix(correlated_dists, corrs, atol=0.1)
+    except ValueError as e:
+        assume("repeated values" not in str(e))
+        raise e
+
+    # Check that marginal distributions are preserved
+    group = correlated_dists[0].correlation_group
+    assert group is not None
+    uncorr_samples = np.column_stack(
+        [sq.sample(dist, 3_000, _correlate_if_needed=False) for dist in group.correlated_dists]
+    )
+    corr_samples = group.induce_correlation(uncorr_samples)
+
+    assert np.isclose(
+        np.mean(uncorr_samples), np.mean(corr_samples), rtol=0.01
+    ), "Means are not equal, violating integrity of marginal distributions"
+    assert np.isclose(
+        np.std(uncorr_samples), np.std(corr_samples), rtol=0.01
+    ), "SDs are not equal, violating integrity of marginal distributions"
+    assert np.isclose(np.median(uncorr_samples), np.median(corr_samples), rtol=0.01)
+    assert np.isclose(np.max(uncorr_samples), np.max(corr_samples), rtol=0.01)
+    assert np.isclose(np.min(uncorr_samples), np.min(corr_samples), rtol=0.01)
+
+
+def test_correlated_resampling():
+    """
+    Tests that correlated distributions can be resampled
+    without stale samples being used (self._correlated_samples)
+    """
+    uncorrelated_dists = sq.to(2, 30), sq.uniform(-3, 6), sq.beta(50, 100)
+    correlated_dists = sq.correlate(uncorrelated_dists, 0.8, tolerance=None)
+
+    first_samples = np.column_stack([d @ 1_000 for d in correlated_dists])
+    second_samples = np.column_stack([d @ 1_000 for d in correlated_dists])
+
+    assert not np.allclose(
+        first_samples, second_samples
+    ), "Resampling correlated distributions produces the same samples"
diff --git a/tests/test_distributions.py b/tests/test_distributions.py
index 65c7701..af790d6 100644
--- a/tests/test_distributions.py
+++ b/tests/test_distributions.py
@@ -34,7 +34,7 @@
     inf0,
     geometric,
     ComplexDistribution,
-    DiscreteDistribution,
+    CategoricalDistribution,
     ExponentialDistribution,
     GammaDistribution,
     LogTDistribution,
@@ -679,30 +679,30 @@ def test_pareto():
 
 def test_discrete():
     obj = discrete({"a": 0.9, "b": 0.1})
-    assert isinstance(obj, DiscreteDistribution)
+    assert isinstance(obj, CategoricalDistribution)
     assert obj.items == {"a": 0.9, "b": 0.1}
     obj = discrete([0, 1])
-    assert isinstance(obj, DiscreteDistribution)
+    assert isinstance(obj, CategoricalDistribution)
     assert obj.items == [0, 1]
-    assert str(obj) == "<Distribution> discrete([0, 1])"
+    assert str(obj) == "<Distribution> categorical([0, 1])"
 
 
 def test_discrete_list():
     obj = discrete([1, 2, 3])
-    assert isinstance(obj, DiscreteDistribution)
+    assert isinstance(obj, CategoricalDistribution)
     assert obj.items == [1, 2, 3]
 
 
 def test_discrete_works_on_numpy():
     obj = discrete(np.array([1, 2, 3]))
-    assert isinstance(obj, DiscreteDistribution)
+    assert isinstance(obj, CategoricalDistribution)
     assert obj.items == [1, 2, 3]
 
 
 def test_discrete_raises_on_wrong_type():
     with pytest.raises(ValueError) as excinfo:
         discrete(2)
-    assert "inputs to discrete must be a dict or list" in str(excinfo.value)
+    assert "must be a dict or list" in str(excinfo.value)
 
 
 def test_mixture():