From af3b0c11b1fd3a000f1cbc193c44526d62ae340a Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:10:04 +0530 Subject: [PATCH 01/12] Turn on packaging with a src layout src/hotmemory holds an empty package and py.typed. pyproject.toml builds it with hatchling at version 0.0.0, with no runtime dependencies. mypy, pytest, and pytest-socket join the dev group. Refs #4 --- pyproject.toml | 22 ++- src/hotmemory/__init__.py | 3 + src/hotmemory/py.typed | 0 uv.lock | 356 +++++++++++++++++++++++++++++++++++++- 4 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 src/hotmemory/__init__.py create mode 100644 src/hotmemory/py.typed diff --git a/pyproject.toml b/pyproject.toml index b900a75..b3831d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,15 +2,20 @@ name = "hotmemory" version = "0.0.0" description = "Agent memory as tables on Hotdata" +readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [] -[dependency-groups] -dev = ["ruff"] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/hotmemory"] -[tool.uv] -package = false +[dependency-groups] +dev = ["mypy", "pytest", "pytest-socket", "ruff"] [tool.ruff] line-length = 100 @@ -18,3 +23,12 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src", "tests"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--strict-markers"] diff --git a/src/hotmemory/__init__.py b/src/hotmemory/__init__.py new file mode 100644 index 0000000..20f2714 --- /dev/null +++ b/src/hotmemory/__init__.py @@ -0,0 +1,3 @@ +"""Agent memory as tables on Hotdata.""" + +__all__: list[str] = [] diff --git a/src/hotmemory/py.typed b/src/hotmemory/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index 2f1c99b..aee8bed 100644 --- a/uv.lock +++ b/uv.lock @@ -1,21 +1,364 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] [[package]] name = "hotmemory" version = "0.0.0" -source = { virtual = "." } +source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-socket" }, { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "ruff" }] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-socket" }, + { name = "ruff" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-socket" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ce/4ef7b049852c95a8727b4a7e6496f762df1ac0b47bc0320d10293f5e95ec/pytest_socket-0.8.1.tar.gz", hash = "sha256:2f57787914ad2e1308d09ce141b95c3e55741fbb4fb7b7556593a6b063e0c9c7", size = 17313, upload-time = "2026-08-19T15:16:25.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ef/ab507f117b3d19b54e3c9c632a99c28c3b284562ec6e02e274581d530d92/pytest_socket-0.8.1-py3-none-any.whl", hash = "sha256:f9846bed1dcd96eed459e5e14795bbaf96715cf4e827891fe70773817ecb8ed4", size = 8751, upload-time = "2026-08-19T15:16:24.426Z" }, +] [[package]] name = "ruff" @@ -41,3 +384,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From fbfde4bd8d4e8e69221c360083236dcb3d9af64f Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:10:30 +0530 Subject: [PATCH 02/12] Run strict mypy and the offline suite in make verify make verify now runs five steps in order: ruff, ruff format in check mode, strict mypy over src/ and tests/, pytest with sockets disabled, and the link check. CONTRIBUTING.md lists the five steps. Refs #4 --- CONTRIBUTING.md | 10 +++++----- Makefile | 2 ++ tests/test_package.py | 7 +++++++ 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 tests/test_package.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97d66c0..26ea4f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,16 +17,16 @@ make verify `make verify` is the full check. CI runs the same target. If it passes on your machine, it passes in CI. It has no tiers, because the full check takes less than five seconds. -Today, `make verify` runs these checks, in this order: +`make verify` runs these checks, in this order: 1. `ruff check` over the Python files. 2. `ruff format --check` over the Python files and the Python code blocks in Markdown. -3. The link check. It reads each relative link in the Markdown files at the root and +3. Strict `mypy` over `src/` and `tests/`. +4. The offline test suite, with `pytest --disable-socket`. A test that opens a network + socket fails. +5. The link check. It reads each relative link in the Markdown files at the root and under `docs/`. A link to a file that does not exist makes it fail. -When the library exists, strict `mypy` and the offline test suite join `make verify` -before the link check. - ## The other targets | Target | What it does | diff --git a/Makefile b/Makefile index 2694cf8..9abbb2e 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,8 @@ STORAGE_URL := http://127.0.0.1:9000 verify: uv run --group dev ruff check . uv run --group dev ruff format --check . + uv run --group dev mypy + uv run --group dev pytest --disable-socket -q uv run --no-project python scripts/check_links.py local-up: diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..5c008ca --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,7 @@ +from pathlib import Path + +import hotmemory + + +def test_package_ships_type_information() -> None: + assert (Path(hotmemory.__file__).parent / "py.typed").is_file() From 004bfedaafaf9e8279a30a60104f0d4e03d3b24b Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:33:35 +0530 Subject: [PATCH 03/12] Add the record for schema version 1 Record is a frozen dataclass with the fields in docs/contracts.md. It derives id as namespace/key@revision and refuses a value the contract does not allow. A namespace label cannot be empty or contain . or /, and a key cannot be empty or contain / or @, so the stored path and the id stay unambiguous. cues, tags, and sources are tuples. normalize() gives the form that deduplication compares. contracts.md states the new refusals and the tuple types. Refs #4 --- docs/contracts.md | 14 ++-- src/hotmemory/__init__.py | 4 +- src/hotmemory/record.py | 140 ++++++++++++++++++++++++++++++++++++++ tests/test_record.py | 99 +++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 src/hotmemory/record.py create mode 100644 tests/test_record.py diff --git a/docs/contracts.md b/docs/contracts.md index e5a8cd5..2848e8d 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -76,16 +76,16 @@ A record is the unit that the store holds. Schema version 1 fixes these fields. | Field | Type | Meaning | |---|---|---| -| `namespace` | tuple of strings | Where the record lives. The store keeps it as one path string joined with `/`. A match is on whole labels and never on a string prefix. No label contains `.`. | -| `key` | string | The stable identifier of the caller inside the namespace. | +| `namespace` | tuple of strings | Where the record lives. The store keeps it as one path string joined with `/`. A match is on whole labels and never on a string prefix. A namespace has at least one label. A label is not empty and contains no `.` and no `/`. | +| `key` | string | The stable identifier of the caller inside the namespace. A key is not empty and contains no `/` and no `@`. | | `revision` | integer | 1 for the first put under a key. Each later put adds 1. | | `kind` | string | One of `fact`, `profile`, `procedure`, `episode`. | | `subject` | string | What the record is about, for example an alert key, a person, or a service. Empty if unknown. | | `content` | string | The text that a model reads. | -| `cues` | list of strings | Questions or phrases that this record answers. Optional. The driver embeds them apart from `content`. | +| `cues` | tuple of strings | Questions or phrases that this record answers. Optional. The driver embeds them apart from `content`. | | `payload` | JSON object | Structured data that the consumer defines. The store never reads it. | -| `tags` | list of strings | Free labels. You can filter on them. | -| `sources` | list of strings | References to the origin of the record: a thread id, a document path, a run id, an episode key. The length of the list is the corroboration count. | +| `tags` | tuple of strings | Free labels. You can filter on them. | +| `sources` | tuple of strings | References to the origin of the record: a thread id, a document path, a run id, an episode key. The length of the list is the corroboration count. | | `actor` | string | Who wrote this revision: a user id, an agent name, or an extractor name. | | `created_at` | timestamp | When the store wrote this revision. System clock. | | `observed_at` | timestamp or null | The time of the source. A post-mortem that you load a year later keeps the incident date here. | @@ -97,6 +97,10 @@ A record is the unit that the store holds. Schema version 1 fixes these fields. | `forget_reason` | string | The reason for `forget_after`. Empty if `forget_after` is null. | | `id` | string | `namespace/key@revision`. Derived. The load key. | +In Python, the record is a frozen dataclass. The list fields are tuples, so a record +cannot change after the store writes it. Every timestamp carries a time zone. The record +refuses a value that the table above does not allow. + The public record has no embedding field. If the caller supplies an embedder, the Hotdata driver adds embedding columns. If the caller uses a provider-backed index, the driver adds none. The driver configuration selects one of the two. diff --git a/src/hotmemory/__init__.py b/src/hotmemory/__init__.py index 20f2714..45f5db1 100644 --- a/src/hotmemory/__init__.py +++ b/src/hotmemory/__init__.py @@ -1,3 +1,5 @@ """Agent memory as tables on Hotdata.""" -__all__: list[str] = [] +from hotmemory.record import SCHEMA_VERSION, JSONValue, Kind, Record, normalize + +__all__ = ["SCHEMA_VERSION", "JSONValue", "Kind", "Record", "normalize"] diff --git a/src/hotmemory/record.py b/src/hotmemory/record.py new file mode 100644 index 0000000..2938147 --- /dev/null +++ b/src/hotmemory/record.py @@ -0,0 +1,140 @@ +"""The record of schema version 1, and the normalization that deduplication uses.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, TypeAlias + +SCHEMA_VERSION = 1 + +Kind: TypeAlias = Literal["fact", "profile", "procedure", "episode"] +KINDS: tuple[Kind, ...] = ("fact", "profile", "procedure", "episode") + +JSONValue: TypeAlias = "str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]" + + +def normalize(content: str) -> str: + """Return `content` in lowercase with each run of whitespace changed to one space.""" + return " ".join(content.lower().split()) + + +def check_namespace(namespace: Sequence[str]) -> tuple[str, ...]: + """Return `namespace` as a tuple, or raise ValueError if a label is not allowed. + + A namespace has at least one label. A label is a non-empty string with no `.` and + no `/`. + """ + if isinstance(namespace, str): + raise TypeError("namespace must be a sequence of labels, not a string") + labels = tuple(namespace) + if not labels: + raise ValueError("namespace must have at least one label") + for label in labels: + check_label(label) + return labels + + +def check_label(label: str) -> None: + """Raise ValueError if `label` is empty or contains `.` or `/`.""" + if not isinstance(label, str): + raise TypeError(f"namespace label must be a string, got {type(label).__name__}") + if not label: + raise ValueError("namespace label must not be empty") + if "." in label or "/" in label: + raise ValueError(f"namespace label must not contain '.' or '/': {label!r}") + + +def check_key(key: str) -> None: + """Raise ValueError if `key` is empty or contains `/` or `@`.""" + if not isinstance(key, str): + raise TypeError(f"key must be a string, got {type(key).__name__}") + if not key: + raise ValueError("key must not be empty") + if "/" in key or "@" in key: + raise ValueError(f"key must not contain '/' or '@': {key!r}") + + +def record_id(namespace: Sequence[str], key: str, revision: int) -> str: + """Return the id `namespace/key@revision`, with the labels joined by `/`.""" + return f"{'/'.join(namespace)}/{key}@{revision}" + + +@dataclass(frozen=True, kw_only=True) +class Record: + """One revision of a memory record, schema version 1. + + The constructor refuses a value that the contract does not allow, and derives `id`. + Every timestamp must carry a time zone. + """ + + namespace: tuple[str, ...] + key: str + revision: int + kind: Kind + subject: str = "" + content: str + cues: tuple[str, ...] = () + payload: dict[str, JSONValue] = field(default_factory=dict) + tags: tuple[str, ...] = () + sources: tuple[str, ...] = () + actor: str = "" + created_at: datetime + observed_at: datetime | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + expired_at: datetime | None = None + superseded_by: str | None = None + forget_after: datetime | None = None + forget_reason: str = "" + id: str = field(init=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "namespace", check_namespace(self.namespace)) + check_key(self.key) + if isinstance(self.revision, bool) or not isinstance(self.revision, int): + raise TypeError("revision must be an integer") + if self.revision < 1: + raise ValueError(f"revision must be 1 or more, got {self.revision}") + if self.kind not in KINDS: + raise ValueError(f"kind must be one of {', '.join(KINDS)}, got {self.kind!r}") + for name in ("subject", "content", "actor", "forget_reason"): + if not isinstance(getattr(self, name), str): + raise TypeError(f"{name} must be a string") + for name in ("cues", "tags", "sources"): + object.__setattr__(self, name, _strings(name, getattr(self, name))) + if not isinstance(self.payload, dict): + raise TypeError("payload must be a dict") + if self.created_at is None: + raise TypeError("created_at must be a datetime") + for name in ( + "created_at", + "observed_at", + "valid_from", + "valid_until", + "expired_at", + "forget_after", + ): + _check_time(name, getattr(self, name)) + if self.forget_reason and self.forget_after is None: + raise ValueError("forget_reason must be empty when forget_after is None") + object.__setattr__(self, "id", record_id(self.namespace, self.key, self.revision)) + + +def _strings(name: str, values: Sequence[str]) -> tuple[str, ...]: + if isinstance(values, str): + raise TypeError(f"{name} must be a sequence of strings, not a string") + result = tuple(values) + if not all(isinstance(value, str) for value in result): + raise TypeError(f"{name} must hold only strings") + return result + + +def _check_time(name: str, value: datetime | None) -> None: + if value is None: + return + if not isinstance(value, datetime): + raise TypeError(f"{name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{name} must carry a time zone") diff --git a/tests/test_record.py b/tests/test_record.py new file mode 100644 index 0000000..d293855 --- /dev/null +++ b/tests/test_record.py @@ -0,0 +1,99 @@ +from datetime import UTC, datetime +from typing import Any + +import pytest + +from hotmemory import Record, normalize + +NOW = datetime(2026, 10, 5, tzinfo=UTC) + + +def make(**overrides: Any) -> Record: + fields: dict[str, Any] = { + "namespace": ("team", "alerts"), + "key": "disk-full", + "revision": 1, + "kind": "fact", + "content": "The disk fills at night.", + "created_at": NOW, + } + fields.update(overrides) + return Record(**fields) + + +def test_id_is_namespace_key_and_revision() -> None: + assert make(revision=3).id == "team/alerts/disk-full@3" + + +def test_defaults_are_empty() -> None: + record = make() + assert record.subject == "" + assert record.cues == () + assert record.payload == {} + assert record.superseded_by is None + + +def test_sequences_become_tuples() -> None: + record = make(namespace=["team"], tags=["disk"], cues=["why"], sources=["thread-1"]) + assert record.namespace == ("team",) + assert record.tags == ("disk",) + assert record.cues == ("why",) + assert record.sources == ("thread-1",) + + +@pytest.mark.parametrize("label", ["a.b", "a/b", ""]) +def test_refuses_a_bad_namespace_label(label: str) -> None: + with pytest.raises(ValueError, match="label"): + make(namespace=("team", label)) + + +def test_refuses_an_empty_namespace() -> None: + with pytest.raises(ValueError, match="at least one label"): + make(namespace=()) + + +def test_refuses_a_string_namespace() -> None: + with pytest.raises(TypeError, match="not a string"): + make(namespace="team") + + +@pytest.mark.parametrize("key", ["a/b", "a@b", ""]) +def test_refuses_a_bad_key(key: str) -> None: + with pytest.raises(ValueError, match="key"): + make(key=key) + + +@pytest.mark.parametrize("revision", [0, -1]) +def test_refuses_a_revision_below_one(revision: int) -> None: + with pytest.raises(ValueError, match="revision"): + make(revision=revision) + + +def test_refuses_an_unknown_kind() -> None: + with pytest.raises(ValueError, match="kind"): + make(kind="note") + + +def test_refuses_a_string_for_tags() -> None: + with pytest.raises(TypeError, match="tags"): + make(tags="disk") + + +def test_refuses_a_naive_timestamp() -> None: + with pytest.raises(ValueError, match="time zone"): + make(observed_at=datetime(2026, 10, 5)) + + +def test_refuses_a_forget_reason_without_forget_after() -> None: + with pytest.raises(ValueError, match="forget_reason"): + make(forget_reason="stale") + + +def test_record_is_frozen() -> None: + record = make() + with pytest.raises(AttributeError): + record.content = "changed" # type: ignore[misc] + + +def test_normalize_lowercases_and_collapses_whitespace() -> None: + assert normalize(" The Disk\n\tFILLS at night. ") == "the disk fills at night." From 02ed8784ac54fcb9de02f445e8cba0c19afc0208 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:34:24 +0530 Subject: [PATCH 04/12] Add the Store protocol and the filter Store is a typing.Protocol with put, get, history, list, search, delete, list_namespaces, and writer. Writer is the protocol of the buffer that writer() returns. Filter is a frozen dataclass with one optional field per allowed key, and TimeRange is a half-open range in which a null timestamp never matches. Hit pairs a record with its distance. contracts.md states how the filter matches tags and ranges, the order of list and its since argument, the distance when search has no query text, and the arguments and flush rules of writer. Refs #4 --- docs/contracts.md | 15 ++-- src/hotmemory/__init__.py | 17 +++- src/hotmemory/filter.py | 76 ++++++++++++++++++ src/hotmemory/store.py | 160 ++++++++++++++++++++++++++++++++++++++ tests/test_filter.py | 61 +++++++++++++++ 5 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 src/hotmemory/filter.py create mode 100644 src/hotmemory/store.py create mode 100644 tests/test_filter.py diff --git a/docs/contracts.md b/docs/contracts.md index 2848e8d..5bae7ff 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -117,15 +117,20 @@ records the moment that the store found out. To ask what memory held on a given | `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision, and the previous current row gets `superseded_by`. Returns the id. If the normalized content is equal to the content of the current revision, it writes nothing and returns the current id. | | `get` | namespace, key, optional revision | Returns the current revision, or the named revision. Returns None if the record does not exist. | | `history` | namespace, key | Returns every revision, oldest first. | -| `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first. It uses no model and no embedding. | -| `search` | query text or none, namespace prefixes, optional filter, k | Returns up to k current revisions in order of relevance, closest first, each with a distance. It takes the same filter as `list`. With no query text, it is `list`. | +| `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first, with ties in `created_at` ordered by id. `since` keeps the revisions whose `created_at` is at or after it. It uses no model and no embedding. | +| `search` | query text or none, namespace prefixes, optional filter, k | Returns up to k current revisions in order of relevance, closest first, each with a distance. It takes the same filter as `list`. With no query text, it is `list`, and each distance is None. | | `delete` | namespace, key | Removes every revision of the key. This is a hard delete. | -| `list_namespaces` | optional prefix | Returns the distinct namespaces under the prefix. | -| `writer` | none | A context manager. It buffers every `put` inside it. The buffer flushes on exit, at a row count, or at an interval, and returns the ids that it flushed. | +| `list_namespaces` | optional prefix | Returns the distinct namespaces under the prefix that hold a record, sorted. | +| `writer` | optional row count, optional interval | A context manager. It buffers every `put` inside it. The buffer flushes when the block exits, when it reaches the row count, and on the first `put` after the interval passes. The writer records the ids that it flushed. If the block raises an error, the writer drops the buffer. | The filter accepts equality on `kind`, `subject`, `tags`, and `actor`. It accepts a range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. Any other filter raises an -error. +error. In Python, the filter is a frozen dataclass with one optional field for each key, so +an unknown key cannot be written, and a value of the wrong type raises an error. + +- A `tags` filter matches a record that holds every tag that the filter names. +- A range includes its start and excludes its end. A side that is not given is open. +- A null timestamp on a record never matches a range. This is the SQL rule for null. `list` and `search` never return a deleted revision, a superseded revision, or a record past `forget_after`. `history` returns superseded revisions. No operation returns a deleted diff --git a/src/hotmemory/__init__.py b/src/hotmemory/__init__.py index 45f5db1..8b24068 100644 --- a/src/hotmemory/__init__.py +++ b/src/hotmemory/__init__.py @@ -1,5 +1,20 @@ """Agent memory as tables on Hotdata.""" +from hotmemory.filter import Filter, TimeRange from hotmemory.record import SCHEMA_VERSION, JSONValue, Kind, Record, normalize +from hotmemory.store import Clock, Embedder, Hit, Store, Writer -__all__ = ["SCHEMA_VERSION", "JSONValue", "Kind", "Record", "normalize"] +__all__ = [ + "SCHEMA_VERSION", + "Clock", + "Embedder", + "Filter", + "Hit", + "JSONValue", + "Kind", + "Record", + "Store", + "TimeRange", + "Writer", + "normalize", +] diff --git a/src/hotmemory/filter.py b/src/hotmemory/filter.py new file mode 100644 index 0000000..fe8d04d --- /dev/null +++ b/src/hotmemory/filter.py @@ -0,0 +1,76 @@ +"""The filter that `list` and `search` accept.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from hotmemory.record import KINDS, Kind + + +@dataclass(frozen=True, kw_only=True) +class TimeRange: + """A half-open time range: `start` is inside it, `end` is not. + + A side that is None is open. A null timestamp on a record is never inside a range. + """ + + start: datetime | None = None + end: datetime | None = None + + def __post_init__(self) -> None: + for name in ("start", "end"): + value = getattr(self, name) + if value is None: + continue + if not isinstance(value, datetime): + raise TypeError(f"TimeRange.{name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"TimeRange.{name} must carry a time zone") + if self.start is not None and self.end is not None and self.end < self.start: + raise ValueError("TimeRange.end must not be before TimeRange.start") + + def contains(self, value: datetime | None) -> bool: + """Return True if `value` is not None and lies inside the range.""" + if value is None: + return False + if self.start is not None and value < self.start: + return False + return self.end is None or value < self.end + + +@dataclass(frozen=True, kw_only=True) +class Filter: + """Exact filters for `list` and `search`. A field that is None does not filter. + + `kind`, `subject`, and `actor` match by equality. `tags` matches a record that holds + every tag named. The four time fields match a record whose value lies in the range. + """ + + kind: Kind | None = None + subject: str | None = None + tags: tuple[str, ...] | None = None + actor: str | None = None + valid_from: TimeRange | None = None + valid_until: TimeRange | None = None + created_at: TimeRange | None = None + expired_at: TimeRange | None = None + + def __post_init__(self) -> None: + if self.kind is not None and self.kind not in KINDS: + raise ValueError(f"kind must be one of {', '.join(KINDS)}, got {self.kind!r}") + for name in ("subject", "actor"): + value = getattr(self, name) + if value is not None and not isinstance(value, str): + raise TypeError(f"Filter.{name} must be a string") + if self.tags is not None: + if isinstance(self.tags, str): + raise TypeError("Filter.tags must be a sequence of strings, not a string") + tags = tuple(self.tags) + if not all(isinstance(tag, str) for tag in tags): + raise TypeError("Filter.tags must hold only strings") + object.__setattr__(self, "tags", tags) + for name in ("valid_from", "valid_until", "created_at", "expired_at"): + value = getattr(self, name) + if value is not None and not isinstance(value, TimeRange): + raise TypeError(f"Filter.{name} must be a TimeRange") diff --git a/src/hotmemory/store.py b/src/hotmemory/store.py new file mode 100644 index 0000000..a17b3b9 --- /dev/null +++ b/src/hotmemory/store.py @@ -0,0 +1,160 @@ +"""The storage contract: the `Store` protocol that every driver implements.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import datetime, timedelta +from types import TracebackType +from typing import Protocol, Self, TypeAlias + +from hotmemory.filter import Filter +from hotmemory.record import JSONValue, Kind, Record + +Embedder: TypeAlias = Callable[[Sequence[str]], Sequence[Sequence[float]]] +"""Takes a list of texts and returns one vector for each text, in the same order.""" + +Clock: TypeAlias = Callable[[], datetime] +"""Returns the current time, with a time zone.""" + + +@dataclass(frozen=True) +class Hit: + """One result of `search`: a record and its cosine distance to the query. + + A lower distance is closer. The distance is None when the search has no query text. + """ + + record: Record + distance: float | None + + +class Writer(Protocol): + """A buffer of puts that a store writes in one batch. + + A buffered put is not visible until the writer flushes. The writer flushes when the + `with` block exits without an exception, when the buffer reaches its row count, and + on the first put after its interval passes. If the block raises, the writer drops the + buffer. + """ + + @property + def flushed(self) -> builtins.list[str]: + """The ids of every put that this writer flushed, in order.""" + ... + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> None: + """Buffer one put. The arguments are those of `Store.put`.""" + ... + + def flush(self) -> builtins.list[str]: + """Write the buffer and return the ids that it wrote, in order.""" + ... + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + +class Store(Protocol): + """The storage contract. Each method is described in docs/contracts.md.""" + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> str: + """Write a new revision and return its id. + + If the key has a current revision, the new revision gets the next number and the + current one gets `superseded_by`. If the normalized content equals that of the + current revision, nothing is written and the current id is returned. If + `valid_from` is None, it takes the value of `observed_at`. + """ + ... + + def get(self, namespace: Sequence[str], key: str, revision: int | None = None) -> Record | None: + """Return the current revision, or the named one, or None if it does not exist.""" + ... + + def history(self, namespace: Sequence[str], key: str) -> builtins.list[Record]: + """Return every revision of the key, oldest first.""" + ... + + def list( + self, + prefix: Sequence[str], + filter: Filter | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> builtins.list[Record]: + """Return up to `limit` current revisions under `prefix`, newest first. + + `since` keeps the revisions whose `created_at` is at or after it. Ties in + `created_at` are ordered by id. + """ + ... + + def search( + self, + query: str | None, + prefixes: Sequence[Sequence[str]], + filter: Filter | None = None, + k: int = 10, + ) -> builtins.list[Hit]: + """Return up to `k` current revisions under any of `prefixes`, closest first. + + With no query text, the order and the records are those of `list`, and each + distance is None. + """ + ... + + def delete(self, namespace: Sequence[str], key: str) -> None: + """Remove every revision of the key.""" + ... + + def list_namespaces(self, prefix: Sequence[str] = ()) -> builtins.list[tuple[str, ...]]: + """Return the distinct namespaces under `prefix` that hold a record, sorted.""" + ... + + def writer(self, max_rows: int = 1000, interval: timedelta = timedelta(seconds=5)) -> Writer: + """Return a writer that buffers puts. See `Writer`.""" + ... diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..29a6953 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,61 @@ +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from hotmemory import Filter, TimeRange + +NOW = datetime(2026, 10, 5, tzinfo=UTC) + + +def test_time_range_is_half_open() -> None: + window = TimeRange(start=NOW, end=NOW + timedelta(days=1)) + assert window.contains(NOW) + assert not window.contains(NOW + timedelta(days=1)) + assert not window.contains(NOW - timedelta(seconds=1)) + + +def test_time_range_never_contains_null() -> None: + assert not TimeRange().contains(None) + + +def test_open_time_range_contains_any_time() -> None: + assert TimeRange().contains(NOW) + + +def test_time_range_refuses_end_before_start() -> None: + with pytest.raises(ValueError, match="before"): + TimeRange(start=NOW, end=NOW - timedelta(days=1)) + + +def test_time_range_refuses_a_naive_time() -> None: + with pytest.raises(ValueError, match="time zone"): + TimeRange(start=datetime(2026, 10, 5)) + + +def test_filter_refuses_an_unknown_key() -> None: + fields: dict[str, Any] = {"payload": {}} + with pytest.raises(TypeError): + Filter(**fields) + + +@pytest.mark.parametrize( + ("fields", "error"), + [ + ({"kind": "note"}, ValueError), + ({"subject": 1}, TypeError), + ({"actor": 1}, TypeError), + ({"tags": "disk"}, TypeError), + ({"tags": [1]}, TypeError), + ({"created_at": NOW}, TypeError), + ], +) +def test_filter_refuses_an_unsupported_value( + fields: dict[str, Any], error: type[Exception] +) -> None: + with pytest.raises(error): + Filter(**fields) + + +def test_filter_tags_become_a_tuple() -> None: + assert Filter(tags=["disk"]).tags == ("disk",) # type: ignore[arg-type] From a9fb27038e4162658ed71179532fe623e129eef0 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:36:05 +0530 Subject: [PATCH 05/12] Add MemoryStore, the in-process driver MemoryStore implements Store: revisions and supersession, exact deduplication on normalized content, whole-label namespace matching, forget_after hiding, cosine distance on content through the embedder, and a buffered writer that records the ids it flushed. It takes an embedder and a clock. The rules that the Hotdata driver will share (prefix matching, revision numbering, deduplication, visibility, filter matching, list order, cosine distance) are plain functions in hotmemory._rules. tests/conftest.py adds a fixed clock, a deterministic fake embedder, and a store fixture parametrized over drivers. Refs #4 --- docs/contracts.md | 6 +- src/hotmemory/__init__.py | 3 + src/hotmemory/_rules.py | 128 +++++++++++++++++ src/hotmemory/memory.py | 288 +++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 53 +++++++ tests/test_memory_store.py | 111 ++++++++++++++ 7 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 src/hotmemory/_rules.py create mode 100644 src/hotmemory/memory.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_memory_store.py diff --git a/docs/contracts.md b/docs/contracts.md index 5bae7ff..d37b975 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -148,7 +148,11 @@ Version 1 ships two drivers. - `MemoryStore` runs in the process, in memory. It is a real driver and not a mock. It computes relevance with the same cosine distance that the engine uses, and it refuses a filter that it does not model. The offline test suite runs against it, and it is the - reference for the other driver. + reference for the other driver. It takes two optional arguments. The embedder is a + callable that turns a list of texts into a list of vectors. Without it, a `search` with + query text raises an error. The clock is a callable that returns the current time, and + the default reads the system clock. `MemoryStore` ranks by the cosine distance between + the query and `content` only. It does not rank by BM25 or by `cues`. - `HotdataStore` uses one managed database, two tables per schema version, keyed loads, a serialized writer, and the retrieval query below. With the local RuntimeDB stack, it is also the development driver. diff --git a/src/hotmemory/__init__.py b/src/hotmemory/__init__.py index 8b24068..e30c8f1 100644 --- a/src/hotmemory/__init__.py +++ b/src/hotmemory/__init__.py @@ -1,6 +1,7 @@ """Agent memory as tables on Hotdata.""" from hotmemory.filter import Filter, TimeRange +from hotmemory.memory import MemoryStore, MemoryWriter from hotmemory.record import SCHEMA_VERSION, JSONValue, Kind, Record, normalize from hotmemory.store import Clock, Embedder, Hit, Store, Writer @@ -12,6 +13,8 @@ "Hit", "JSONValue", "Kind", + "MemoryStore", + "MemoryWriter", "Record", "Store", "TimeRange", diff --git a/src/hotmemory/_rules.py b/src/hotmemory/_rules.py new file mode 100644 index 0000000..c88d690 --- /dev/null +++ b/src/hotmemory/_rules.py @@ -0,0 +1,128 @@ +"""Plain functions that every driver calls, so the drivers agree on the contract.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from datetime import datetime + +from hotmemory.filter import Filter +from hotmemory.record import JSONValue, Kind, Record, check_label, normalize + + +def check_prefix(prefix: Sequence[str]) -> tuple[str, ...]: + """Return `prefix` as a tuple of labels. An empty prefix is allowed and matches all.""" + if isinstance(prefix, str): + raise TypeError("prefix must be a sequence of labels, not a string") + labels = tuple(prefix) + for label in labels: + check_label(label) + return labels + + +def under_prefix(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool: + """Return True if the first labels of `namespace` are the labels of `prefix`.""" + return namespace[: len(prefix)] == prefix + + +def next_revision(current: Record | None) -> int: + """Return the revision number for the next put after `current`.""" + return 1 if current is None else current.revision + 1 + + +def is_duplicate(current: Record, content: str) -> bool: + """Return True if `content` normalizes to the normalized content of `current`.""" + return normalize(current.content) == normalize(content) + + +def is_listed(record: Record, now: datetime) -> bool: + """Return True if `list` and `search` can return `record` at `now`. + + A superseded revision is not listed. A record is not listed once its `forget_after` + is at or before `now`. + """ + if record.superseded_by is not None: + return False + return record.forget_after is None or record.forget_after > now + + +def matches(record: Record, filter: Filter | None) -> bool: + """Return True if `record` satisfies every field that `filter` sets.""" + if filter is None: + return True + if filter.kind is not None and record.kind != filter.kind: + return False + if filter.subject is not None and record.subject != filter.subject: + return False + if filter.actor is not None and record.actor != filter.actor: + return False + if filter.tags is not None and not set(filter.tags) <= set(record.tags): + return False + ranges = ( + (filter.valid_from, record.valid_from), + (filter.valid_until, record.valid_until), + (filter.created_at, record.created_at), + (filter.expired_at, record.expired_at), + ) + return all(window is None or window.contains(value) for window, value in ranges) + + +def newest_first(record: Record) -> tuple[float, str]: + """Sort key for the order of `list`: newest `created_at` first, then by id.""" + return (-record.created_at.timestamp(), record.id) + + +def build_record( + namespace: Sequence[str], + key: str, + revision: int, + created_at: datetime, + *, + kind: Kind, + content: str, + subject: str, + cues: Sequence[str], + payload: dict[str, JSONValue] | None, + tags: Sequence[str], + sources: Sequence[str], + actor: str, + observed_at: datetime | None, + valid_from: datetime | None, + valid_until: datetime | None, + forget_after: datetime | None, + forget_reason: str, +) -> Record: + """Return the record that a put writes. `valid_from` defaults to `observed_at`.""" + return Record( + namespace=tuple(namespace), + key=key, + revision=revision, + kind=kind, + subject=subject, + content=content, + cues=tuple(cues), + payload={} if payload is None else dict(payload), + tags=tuple(tags), + sources=tuple(sources), + actor=actor, + created_at=created_at, + observed_at=observed_at, + valid_from=observed_at if valid_from is None else valid_from, + valid_until=valid_until, + forget_after=forget_after, + forget_reason=forget_reason, + ) + + +def cosine_distance(a: Sequence[float], b: Sequence[float]) -> float: + """Return 1 minus the cosine similarity of `a` and `b`. + + Raises ValueError if the lengths differ or a vector has zero length. + """ + if len(a) != len(b): + raise ValueError(f"vectors differ in length: {len(a)} and {len(b)}") + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(x * x for x in b)) + if norm_a == 0 or norm_b == 0: + raise ValueError("cannot compute a cosine distance for a zero vector") + return 1.0 - sum(x * y for x, y in zip(a, b, strict=True)) / (norm_a * norm_b) diff --git a/src/hotmemory/memory.py b/src/hotmemory/memory.py new file mode 100644 index 0000000..4f31f4b --- /dev/null +++ b/src/hotmemory/memory.py @@ -0,0 +1,288 @@ +"""`MemoryStore`: the in-process driver, and the reference for every other driver.""" + +from __future__ import annotations + +import builtins +from collections.abc import Sequence +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from types import TracebackType +from typing import Self + +from hotmemory._rules import ( + build_record, + check_prefix, + cosine_distance, + is_duplicate, + is_listed, + matches, + newest_first, + next_revision, + under_prefix, +) +from hotmemory.filter import Filter +from hotmemory.record import JSONValue, Kind, Record, check_key, check_namespace +from hotmemory.store import Clock, Embedder, Hit + + +def utc_now() -> datetime: + """Return the current time in UTC.""" + return datetime.now(UTC) + + +class MemoryStore: + """A `Store` that holds every revision in process memory. + + `embedder` turns texts into vectors for `search` with query text. Without one, such a + search raises RuntimeError. `clock` gives `created_at` and the time against which + `forget_after` is compared. Search ranks by the cosine distance between the query and + `content`. + """ + + def __init__(self, *, embedder: Embedder | None = None, clock: Clock = utc_now) -> None: + self._embedder = embedder + self._clock = clock + self._revisions: dict[tuple[tuple[str, ...], str], builtins.list[Record]] = {} + self._vectors: dict[str, tuple[float, ...]] = {} + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> str: + draft = build_record( + namespace, + key, + 1, + self._clock(), + kind=kind, + content=content, + subject=subject, + cues=cues, + payload=payload, + tags=tags, + sources=sources, + actor=actor, + observed_at=observed_at, + valid_from=valid_from, + valid_until=valid_until, + forget_after=forget_after, + forget_reason=forget_reason, + ) + return self._write(draft) + + def get(self, namespace: Sequence[str], key: str, revision: int | None = None) -> Record | None: + revisions = self._revisions.get(self._slot(namespace, key), []) + if revision is None: + return revisions[-1] if revisions else None + return next((record for record in revisions if record.revision == revision), None) + + def history(self, namespace: Sequence[str], key: str) -> builtins.list[Record]: + return builtins.list(self._revisions.get(self._slot(namespace, key), [])) + + def list( + self, + prefix: Sequence[str], + filter: Filter | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> builtins.list[Record]: + _check_count("limit", limit) + records = self._listed([prefix], filter) + if since is not None: + records = [record for record in records if record.created_at >= since] + return records[:limit] + + def search( + self, + query: str | None, + prefixes: Sequence[Sequence[str]], + filter: Filter | None = None, + k: int = 10, + ) -> builtins.list[Hit]: + _check_count("k", k) + records = self._listed(prefixes, filter) + if query is None: + return [Hit(record, None) for record in records[:k]] + query_vector = self._embed_query(query) + self._embed_missing(records) + hits = [ + Hit(record, cosine_distance(query_vector, self._vectors[record.id])) + for record in records + ] + hits.sort(key=lambda hit: (hit.distance, newest_first(hit.record))) + return hits[:k] + + def delete(self, namespace: Sequence[str], key: str) -> None: + for record in self._revisions.pop(self._slot(namespace, key), []): + self._vectors.pop(record.id, None) + + def list_namespaces(self, prefix: Sequence[str] = ()) -> builtins.list[tuple[str, ...]]: + labels = check_prefix(prefix) + return sorted( + {namespace for namespace, _ in self._revisions if under_prefix(namespace, labels)} + ) + + def writer( + self, max_rows: int = 1000, interval: timedelta = timedelta(seconds=5) + ) -> MemoryWriter: + return MemoryWriter(self, max_rows, interval) + + def _write(self, draft: Record) -> str: + slot = (draft.namespace, draft.key) + revisions = self._revisions.get(slot, []) + current = revisions[-1] if revisions else None + if current is not None and is_duplicate(current, draft.content): + return current.id + record = replace(draft, revision=next_revision(current), created_at=self._clock()) + if current is not None: + revisions = [*revisions[:-1], replace(current, superseded_by=record.id)] + self._revisions[slot] = [*revisions, record] + return record.id + + def _slot(self, namespace: Sequence[str], key: str) -> tuple[tuple[str, ...], str]: + labels = check_namespace(namespace) + check_key(key) + return labels, key + + def _listed( + self, prefixes: Sequence[Sequence[str]], filter: Filter | None + ) -> builtins.list[Record]: + if isinstance(prefixes, str): + raise TypeError("prefixes must be a sequence of prefixes, not a string") + checked = [check_prefix(prefix) for prefix in prefixes] + now = self._clock() + records = [ + revisions[-1] + for (namespace, _), revisions in self._revisions.items() + if any(under_prefix(namespace, prefix) for prefix in checked) + and is_listed(revisions[-1], now) + and matches(revisions[-1], filter) + ] + records.sort(key=newest_first) + return records + + def _embed_query(self, query: str) -> tuple[float, ...]: + if self._embedder is None: + raise RuntimeError("search with query text needs a MemoryStore with an embedder") + return self._embed([query])[0] + + def _embed_missing(self, records: Sequence[Record]) -> None: + missing = [record for record in records if record.id not in self._vectors] + if not missing: + return + vectors = self._embed([record.content for record in missing]) + for record, vector in zip(missing, vectors, strict=True): + self._vectors[record.id] = vector + + def _embed(self, texts: Sequence[str]) -> builtins.list[tuple[float, ...]]: + assert self._embedder is not None + vectors = [tuple(float(x) for x in vector) for vector in self._embedder(texts)] + if len(vectors) != len(texts): + raise ValueError(f"embedder returned {len(vectors)} vectors for {len(texts)} texts") + return vectors + + +class MemoryWriter: + """The `Writer` that `MemoryStore.writer` returns.""" + + def __init__(self, store: MemoryStore, max_rows: int, interval: timedelta) -> None: + _check_count("max_rows", max_rows) + if max_rows < 1: + raise ValueError("max_rows must be 1 or more") + self._store = store + self._max_rows = max_rows + self._interval = interval + self._buffer: builtins.list[Record] = [] + self._flushed: builtins.list[str] = [] + self._last_flush = store._clock() + + @property + def flushed(self) -> builtins.list[str]: + return builtins.list(self._flushed) + + def put( + self, + namespace: Sequence[str], + key: str, + *, + kind: Kind, + content: str, + subject: str = "", + cues: Sequence[str] = (), + payload: dict[str, JSONValue] | None = None, + tags: Sequence[str] = (), + sources: Sequence[str] = (), + actor: str = "", + observed_at: datetime | None = None, + valid_from: datetime | None = None, + valid_until: datetime | None = None, + forget_after: datetime | None = None, + forget_reason: str = "", + ) -> None: + now = self._store._clock() + self._buffer.append( + build_record( + namespace, + key, + 1, + now, + kind=kind, + content=content, + subject=subject, + cues=cues, + payload=payload, + tags=tags, + sources=sources, + actor=actor, + observed_at=observed_at, + valid_from=valid_from, + valid_until=valid_until, + forget_after=forget_after, + forget_reason=forget_reason, + ) + ) + if len(self._buffer) >= self._max_rows or now - self._last_flush >= self._interval: + self.flush() + + def flush(self) -> builtins.list[str]: + ids = [self._store._write(draft) for draft in self._buffer] + self._buffer.clear() + self._flushed.extend(ids) + self._last_flush = self._store._clock() + return ids + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if exc_type is None: + self.flush() + else: + self._buffer.clear() + + +def _check_count(name: str, value: int) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if value < 0: + raise ValueError(f"{name} must not be negative") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..02cf9d3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +import re +import zlib +from collections.abc import Callable, Sequence +from datetime import UTC, datetime, timedelta + +import pytest + +from hotmemory import Clock, Embedder, MemoryStore, Store + +START = datetime(2026, 10, 5, 12, 0, tzinfo=UTC) +DIMENSIONS = 64 + + +class FakeClock: + """A clock that stands still until a test moves it.""" + + def __init__(self, now: datetime = START) -> None: + self.now = now + + def __call__(self) -> datetime: + return self.now + + def advance(self, delta: timedelta = timedelta(seconds=1)) -> None: + self.now += delta + + +def fake_embedder(texts: Sequence[str]) -> list[list[float]]: + """Embed each text as counts of its words, hashed into a fixed number of dimensions.""" + vectors = [] + for text in texts: + vector = [0.0] * DIMENSIONS + for word in re.findall(r"[a-z0-9]+", text.lower()): + vector[zlib.crc32(word.encode()) % DIMENSIONS] += 1.0 + vectors.append(vector) + return vectors + + +StoreFactory = Callable[[Embedder | None, Clock], Store] + +DRIVERS: dict[str, StoreFactory] = { + "memory": lambda embedder, clock: MemoryStore(embedder=embedder, clock=clock), +} + + +@pytest.fixture +def clock() -> FakeClock: + return FakeClock() + + +@pytest.fixture(params=sorted(DRIVERS)) +def store(request: pytest.FixtureRequest, clock: FakeClock) -> Store: + factory: StoreFactory = DRIVERS[request.param] + return factory(fake_embedder, clock) diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py new file mode 100644 index 0000000..8a4c845 --- /dev/null +++ b/tests/test_memory_store.py @@ -0,0 +1,111 @@ +from datetime import timedelta + +import pytest + +from hotmemory import MemoryStore +from hotmemory._rules import cosine_distance + +from .conftest import FakeClock, fake_embedder + +NS = ("team",) + + +def test_search_with_query_text_needs_an_embedder() -> None: + store = MemoryStore() + store.put(NS, "a", kind="fact", content="disk full") + with pytest.raises(RuntimeError, match="embedder"): + store.search("disk", [NS]) + + +def test_search_without_query_text_needs_no_embedder() -> None: + store = MemoryStore() + store.put(NS, "a", kind="fact", content="disk full") + assert [hit.distance for hit in store.search(None, [NS])] == [None] + + +def test_embedder_that_drops_a_vector_raises() -> None: + store = MemoryStore(embedder=lambda texts: []) + store.put(NS, "a", kind="fact", content="disk full") + with pytest.raises(ValueError, match="vectors"): + store.search("disk", [NS]) + + +def test_created_at_comes_from_the_clock(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + store.put(NS, "a", kind="fact", content="disk full") + record = store.get(NS, "a") + assert record is not None + assert record.created_at == clock.now + + +def test_valid_from_defaults_to_observed_at(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + observed = clock.now - timedelta(days=365) + store.put(NS, "a", kind="fact", content="disk full", observed_at=observed) + record = store.get(NS, "a") + assert record is not None + assert record.valid_from == observed + + +def test_writer_flushes_at_its_row_count(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + writer = store.writer(max_rows=2) + writer.put(NS, "a", kind="fact", content="one") + assert store.get(NS, "a") is None + writer.put(NS, "b", kind="fact", content="two") + assert writer.flushed == ["team/a@1", "team/b@1"] + + +def test_writer_flushes_on_the_first_put_after_its_interval(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + writer = store.writer(interval=timedelta(seconds=5)) + writer.put(NS, "a", kind="fact", content="one") + clock.advance(timedelta(seconds=5)) + writer.put(NS, "b", kind="fact", content="two") + assert writer.flushed == ["team/a@1", "team/b@1"] + + +def test_writer_drops_its_buffer_when_the_block_raises(clock: FakeClock) -> None: + store = MemoryStore(clock=clock) + with pytest.raises(KeyError), store.writer() as writer: + writer.put(NS, "a", kind="fact", content="one") + raise KeyError("stop") + assert store.get(NS, "a") is None + assert writer.flushed == [] + + +def test_writer_checks_a_put_before_it_buffers() -> None: + writer = MemoryStore().writer() + with pytest.raises(ValueError, match="label"): + writer.put(("a.b",), "k", kind="fact", content="one") + + +def test_list_namespaces_matches_whole_labels() -> None: + store = MemoryStore() + store.put(("team", "alerts"), "a", kind="fact", content="one") + store.put(("team", "alertsx"), "b", kind="fact", content="two") + store.put(("other",), "c", kind="fact", content="three") + assert store.list_namespaces(("team", "alerts")) == [("team", "alerts")] + assert store.list_namespaces() == [("other",), ("team", "alerts"), ("team", "alertsx")] + + +def test_reput_after_delete_does_not_reuse_a_stale_embedding() -> None: + store = MemoryStore(embedder=fake_embedder) + store.put(NS, "a", kind="fact", content="disk full") + store.search("disk full", [NS]) + store.delete(NS, "a") + store.put(NS, "a", kind="fact", content="network down") + [hit] = store.search("network down", [NS]) + assert hit.distance == pytest.approx(0.0) + + +def test_cosine_distance() -> None: + assert cosine_distance([1.0, 0.0], [1.0, 0.0]) == pytest.approx(0.0) + assert cosine_distance([1.0, 0.0], [0.0, 1.0]) == pytest.approx(1.0) + assert cosine_distance([1.0, 0.0], [-1.0, 0.0]) == pytest.approx(2.0) + + +@pytest.mark.parametrize(("a", "b"), [([1.0], [1.0, 0.0]), ([0.0], [1.0])]) +def test_cosine_distance_refuses_bad_vectors(a: list[float], b: list[float]) -> None: + with pytest.raises(ValueError): + cosine_distance(a, b) From 450e0ea1aa27a109671d74b0d7b068e24bcbb485 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:36:50 +0530 Subject: [PATCH 06/12] Add the conformance suite tests/test_conformance.py holds one test per answered storage-contract guarantee in docs/guarantees.md, run against every driver through the store fixture. Today the only driver is MemoryStore. Refs #4 --- tests/test_conformance.py | 183 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/test_conformance.py diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..c3d5144 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,183 @@ +"""The conformance suite. Each test proves one row of docs/guarantees.md for every driver.""" + +from datetime import timedelta +from typing import Any + +import pytest + +from hotmemory import Filter, Store, TimeRange + +from .conftest import FakeClock + +NS = ("team", "alerts") + + +def test_second_put_writes_a_new_revision(store: Store, clock: FakeClock) -> None: + first = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + clock.advance() + second = store.put(NS, "disk", kind="fact", content="The disk fills at noon.") + + assert (first, second) == ("team/alerts/disk@1", "team/alerts/disk@2") + current = store.get(NS, "disk") + assert current is not None + assert current.revision == 2 + previous = store.get(NS, "disk", revision=1) + assert previous is not None + assert previous.superseded_by == second + assert [record.id for record in store.history(NS, "disk")] == [first, second] + assert [record.id for record in store.list(NS)] == [second] + assert [hit.record.id for hit in store.search("disk fills", [NS])] == [second] + + +def test_deduplication_is_exact_on_normalized_content(store: Store, clock: FakeClock) -> None: + first = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + clock.advance() + again = store.put(NS, "disk", kind="fact", content=" the DISK fills\n at night. ") + clock.advance() + paraphrase = store.put(NS, "disk", kind="fact", content="At night the disk fills up.") + + assert again == first + assert paraphrase == "team/alerts/disk@2" + assert len(store.history(NS, "disk")) == 2 + + +def test_synchronous_put_is_visible_to_list(store: Store) -> None: + record_id = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + assert [record.id for record in store.list(NS)] == [record_id] + + +def test_synchronous_put_is_visible_to_search(store: Store) -> None: + record_id = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + assert [hit.record.id for hit in store.search("disk fills at night", [NS])] == [record_id] + + +def test_buffered_put_is_visible_after_flush(store: Store) -> None: + with store.writer() as writer: + writer.put(NS, "disk", kind="fact", content="The disk fills at night.") + writer.put(NS, "cpu", kind="fact", content="The CPU spikes at noon.") + assert store.get(NS, "disk") is None + assert store.list(NS) == [] + assert store.search("disk", [NS]) == [] + + assert writer.flushed == ["team/alerts/disk@1", "team/alerts/cpu@1"] + assert {record.id for record in store.list(NS)} == set(writer.flushed) + + +def test_last_writer_wins_on_one_key(store: Store, clock: FakeClock) -> None: + first = store.writer() + second = store.writer() + first.put(NS, "disk", kind="fact", content="The disk fills at night.", actor="one") + second.put(NS, "disk", kind="fact", content="The disk fills at noon.", actor="two") + first.flush() + clock.advance() + second.flush() + + assert [record.actor for record in store.history(NS, "disk")] == ["one", "two"] + current = store.get(NS, "disk") + assert current is not None + assert current.actor == "two" + + +def test_delete_removes_every_revision(store: Store, clock: FakeClock) -> None: + store.put(NS, "disk", kind="fact", content="The disk fills at night.") + clock.advance() + store.put(NS, "disk", kind="fact", content="The disk fills at noon.") + store.search("disk fills", [NS]) + store.delete(NS, "disk") + + assert store.get(NS, "disk") is None + assert store.get(NS, "disk", revision=1) is None + assert store.history(NS, "disk") == [] + assert store.list(NS) == [] + assert store.search("disk fills at noon", [NS]) == [] + assert store.list_namespaces() == [] + + +FILTER_CASES: list[tuple[str, dict[str, Any], Filter]] = [ + ("kind", {"kind": "procedure"}, Filter(kind="procedure")), + ("subject", {"subject": "db-1"}, Filter(subject="db-1")), + ("actor", {"actor": "extractor"}, Filter(actor="extractor")), + ("tags", {"tags": ("disk", "night")}, Filter(tags=("night", "disk"))), +] + + +@pytest.mark.parametrize( + ("name", "fields", "where"), FILTER_CASES, ids=[c[0] for c in FILTER_CASES] +) +def test_filter_matches_by_equality( + store: Store, name: str, fields: dict[str, Any], where: Filter +) -> None: + store.put(NS, "skipped", kind="fact", content="This one does not match.", tags=("disk",)) + store.put(NS, "wanted", content="This one matches.", **{"kind": "fact", **fields}) + wanted = store.get(NS, "wanted") + assert wanted is not None + + assert [record.id for record in store.list(NS, where)] == [wanted.id] + assert [hit.record.id for hit in store.search("one", [NS], where)] == [wanted.id] + + +def test_filter_matches_a_time_range(store: Store, clock: FakeClock) -> None: + start = clock.now + store.put(NS, "unknown", kind="fact", content="Validity unknown.") + store.put(NS, "early", kind="fact", content="Valid early.", valid_from=start) + store.put(NS, "late", kind="fact", content="Valid late.", valid_from=start + timedelta(days=2)) + clock.advance(timedelta(days=1)) + store.put(NS, "newer", kind="fact", content="Written later.") + + early = Filter(valid_from=TimeRange(start=start, end=start + timedelta(days=1))) + assert [record.key for record in store.list(NS, early)] == ["early"] + written = Filter(created_at=TimeRange(start=start + timedelta(hours=1))) + assert [record.key for record in store.list(NS, written)] == ["newer"] + assert [record.key for record in store.list(NS, since=clock.now)] == ["newer"] + + +def test_unknown_filter_key_raises() -> None: + fields: dict[str, Any] = {"content": "disk"} + with pytest.raises(TypeError): + Filter(**fields) + + +def test_prefix_matches_whole_labels(store: Store) -> None: + store.put(("team", "alerts"), "a", kind="fact", content="Inside the prefix.") + store.put(("team", "alerts", "disk"), "b", kind="fact", content="Below the prefix.") + store.put(("team", "alertsx"), "c", kind="fact", content="Shares a string prefix.") + + assert {record.key for record in store.list(("team", "alerts"))} == {"a", "b"} + found = store.search("prefix", [("team", "alerts")]) + assert {hit.record.key for hit in found} == {"a", "b"} + both = store.search(None, [("team", "alerts", "disk"), ("team", "alertsx")]) + assert {hit.record.key for hit in both} == {"b", "c"} + + +def test_search_returns_distance_closest_first(store: Store, clock: FakeClock) -> None: + store.put(NS, "exact", kind="fact", content="disk fills at night") + clock.advance() + store.put(NS, "near", kind="fact", content="disk fills at noon") + clock.advance() + store.put(NS, "far", kind="fact", content="cpu spikes after deploy") + + hits = store.search("disk fills at night", [NS], k=3) + assert [hit.record.key for hit in hits] == ["exact", "near", "far"] + distances = [hit.distance for hit in hits] + assert distances[0] == pytest.approx(0.0) + assert all(distance is not None for distance in distances) + assert distances == sorted(distances, key=lambda distance: distance or 0.0) + assert len(store.search("disk fills at night", [NS], k=2)) == 2 + + +def test_forget_after_hides_without_deleting(store: Store, clock: FakeClock) -> None: + store.put( + NS, + "disk", + kind="fact", + content="The disk fills at night.", + forget_after=clock.now + timedelta(days=1), + forget_reason="temporary", + ) + assert len(store.list(NS)) == 1 + clock.advance(timedelta(days=1)) + + assert store.list(NS) == [] + assert store.search("disk", [NS]) == [] + assert store.get(NS, "disk") is not None + assert len(store.history(NS, "disk")) == 1 From 7c67d0ef796064e7e5cb50f3a33e0e5432b47bbd Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:37:11 +0530 Subject: [PATCH 07/12] Freeze the four public surfaces and start the changelog tests/test_frozen.py compares __all__, the Store method set, the Record fields and types with the Kind values, and the Filter keys against literal sets. Each failure message says the change needs a CHANGELOG.md entry. CHANGELOG.md starts with an Unreleased section. Refs #4 --- CHANGELOG.md | 19 ++++++++++ tests/test_frozen.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 tests/test_frozen.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ed2f459 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +Each change to a public surface of hotmemory has an entry here. The frozen surfaces are the +names in `hotmemory.__all__`, the method set of `Store`, the fields and field types of +`Record` for each schema version, and the filter keys of `list` and `search`. A test guards +each one, and its failure message points to this file. + +## Unreleased + +### Added + +- `Record`, the frozen dataclass for schema version 1, and `normalize`, the form that + deduplication compares. +- `Store`, the protocol of the storage contract, with `put`, `get`, `history`, `list`, + `search`, `delete`, `list_namespaces`, and `writer`. `Writer` is the protocol of the + buffer that `writer` returns. +- `Filter` and `TimeRange`, the exact filters of `list` and `search`, and `Hit`, one search + result with its distance. +- `MemoryStore` and `MemoryWriter`, the in-process driver. diff --git a/tests/test_frozen.py b/tests/test_frozen.py new file mode 100644 index 0000000..6d5c0c6 --- /dev/null +++ b/tests/test_frozen.py @@ -0,0 +1,90 @@ +"""The frozen surfaces. Each test compares a public surface against a literal set.""" + +from dataclasses import fields +from typing import get_args + +import hotmemory +from hotmemory import Filter, Kind, Record, Store + + +def changed(surface: str) -> str: + return ( + f"{surface} is a frozen surface. A change to it is a public contract change: " + "add an entry to CHANGELOG.md under Unreleased, then update the literal in this test." + ) + + +def test_all_is_frozen() -> None: + assert set(hotmemory.__all__) == { + "SCHEMA_VERSION", + "Clock", + "Embedder", + "Filter", + "Hit", + "JSONValue", + "Kind", + "MemoryStore", + "MemoryWriter", + "Record", + "Store", + "TimeRange", + "Writer", + "normalize", + }, changed("hotmemory.__all__") + + +def test_store_methods_are_frozen() -> None: + methods = {name for name, value in vars(Store).items() if callable(value)} + public = {name for name in methods if not name.startswith("_")} + assert public == { + "put", + "get", + "history", + "list", + "search", + "delete", + "list_namespaces", + "writer", + }, changed("The method set of Store") + + +def test_record_fields_are_frozen() -> None: + assert hotmemory.SCHEMA_VERSION == 1, changed("The schema version") + assert {field.name: field.type for field in fields(Record)} == { + "namespace": "tuple[str, ...]", + "key": "str", + "revision": "int", + "kind": "Kind", + "subject": "str", + "content": "str", + "cues": "tuple[str, ...]", + "payload": "dict[str, JSONValue]", + "tags": "tuple[str, ...]", + "sources": "tuple[str, ...]", + "actor": "str", + "created_at": "datetime", + "observed_at": "datetime | None", + "valid_from": "datetime | None", + "valid_until": "datetime | None", + "expired_at": "datetime | None", + "superseded_by": "str | None", + "forget_after": "datetime | None", + "forget_reason": "str", + "id": "str", + }, changed("The fields and field types of Record, schema version 1,") + assert set(get_args(Kind)) == {"fact", "profile", "procedure", "episode"}, changed( + "The values of Kind" + ) + + +def test_filter_keys_are_frozen() -> None: + assert {field.name for field in fields(Filter)} == { + "kind", + "subject", + "tags", + "actor", + "valid_from", + "valid_until", + "created_at", + "expired_at", + }, changed("The filter keys of list and search") From ce97d2b8ec96793364c303d9317e38c395394765 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:37:44 +0530 Subject: [PATCH 08/12] Add the ledger test and name the tests in the ledger tests/test_ledger.py reads docs/guarantees.md. It fails when the Test column names a test that tests/test_conformance.py does not define, or when a row names neither a test nor a phase. The Test column now names the conformance test for each storage-contract row. The row on two processes waits for phase 2, because only the Hotdata driver has a load lock, and the memory-contract rows wait for phase 3. Refs #4 --- docs/guarantees.md | 43 ++++++++++++++++-------------- tests/test_ledger.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 tests/test_ledger.py diff --git a/docs/guarantees.md b/docs/guarantees.md index c8d5341..d7a197c 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -11,31 +11,34 @@ Each guarantee has one of two states. - Answered: a measurement or the design already supports the answer. - To measure: a measurement must give the answer before the library relies on it. -A guarantee marked [measured] was observed against a real Hotdata workspace. In phase 1, -each answered guarantee gets one conformance test, and the Test column names it. If a named test -does not exist, a test in the suite fails. Until the suite exists, -the Test column is empty. +A guarantee marked [measured] was observed against a real Hotdata workspace. + +The Test column names the conformance tests in `tests/test_conformance.py` that prove the +guarantee against every driver. If a named test does not exist, `tests/test_ledger.py` +fails. A row that no test proves yet names the phase that will prove it. Phase 2 proves +the rows that only the Hotdata driver can show, and phase 3 proves the rows of the memory +contract. ## The ledger | Question | Answer | State | Test | |---|---|---|---| -| Does a second `put` under the same key replace the record? | No. It writes revision n+1 and marks revision n as superseded. `get` returns n+1. | answered | | -| Can a retried `remember` create duplicates? | No. The key comes from the subject and a hash of the normalized content. A put whose normalized content is equal to the current revision writes nothing. | answered | | -| Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so that a caller can decide. | answered | | -| After a synchronous `put` returns, does `list` see the record? | Yes. A read after a write was never stale in 30 trials. | answered [measured] | | -| After a synchronous `put` returns, does `search` see the record? | Yes. Without an index, the retrieval query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 | | -| After a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes. `writer` returns the ids that it flushed. | answered | | -| Two processes write to the same table. What happens? | The engine refuses the second load with 409. The driver retries with backoff and stops after a bound. Inside one process, the writer sends one load at a time. | answered [measured], M3 | | -| Two writers put the same key. What happens? | The last writer wins at the row level. Revisions are new rows, so both revisions exist and the later one is current. | answered | | -| Does `delete` remove retained revisions and their embeddings? | Yes. An embedding is a column of its row. After a keyed delete, neither a provider-backed vector index nor a BM25 index returned the deleted row. | answered [measured], M2 | | -| Which filters work in `list` and `search`? | Equality on `kind`, `subject`, `tags`, and `actor`. A range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. A prefix on namespace labels. Any other filter raises an error. | answered | | -| Does a higher score mean more relevant? | The store returns a distance, and a lower distance is closer. The memory contract returns records in order, with no score. An adapter that needs a score converts the distance. | answered | | -| What does `recall(as_of=T)` return? | The records that are valid at T by the as-of rule in [contracts.md](contracts.md). If `history` is consulted, this includes records superseded after T. If not, it excludes them. | answered | | -| Who enforces scope? | The library filters on the allowed scopes of the caller. The platform enforces the database boundary through the API token. A caller that holds the token can go around the library. | answered | | -| Can a consumer tell sources, extractions, and hypotheses apart? | Yes, through `kind`, `sources`, and `actor`. An extracted fact carries the name of the extractor in `actor`. | answered | | -| Is a record deleted after its `forget_after` time passes? | No. It stops appearing in `list` and `search`. The sweeper deletes it on its next run. | answered [measured] | | -| Does a write inside a turn reach a `recall` in the same turn? | No, by contract. A consumer reads what was there before its own capture. | answered | | +| Does a second `put` under the same key replace the record? | No. It writes revision n+1 and marks revision n as superseded. `get` returns n+1. | answered || `test_second_put_writes_a_new_revision` | +| Can a retried `remember` create duplicates? | No. The key comes from the subject and a hash of the normalized content. A put whose normalized content is equal to the current revision writes nothing. | answered || phase 3 | +| Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so that a caller can decide. | answered || `test_deduplication_is_exact_on_normalized_content` | +| After a synchronous `put` returns, does `list` see the record? | Yes. A read after a write was never stale in 30 trials. | answered [measured] || `test_synchronous_put_is_visible_to_list` | +| After a synchronous `put` returns, does `search` see the record? | Yes. Without an index, the retrieval query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 || `test_synchronous_put_is_visible_to_search` | +| After a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes. `writer` returns the ids that it flushed. | answered || `test_buffered_put_is_visible_after_flush` | +| Two processes write to the same table. What happens? | The engine refuses the second load with 409. The driver retries with backoff and stops after a bound. Inside one process, the writer sends one load at a time. | answered [measured], M3 || phase 2 | +| Two writers put the same key. What happens? | The last writer wins at the row level. Revisions are new rows, so both revisions exist and the later one is current. | answered || `test_last_writer_wins_on_one_key` | +| Does `delete` remove retained revisions and their embeddings? | Yes. An embedding is a column of its row. After a keyed delete, neither a provider-backed vector index nor a BM25 index returned the deleted row. | answered [measured], M2 || `test_delete_removes_every_revision` | +| Which filters work in `list` and `search`? | Equality on `kind`, `subject`, `tags`, and `actor`. A range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. A prefix on namespace labels. Any other filter raises an error. | answered || `test_filter_matches_by_equality`, `test_filter_matches_a_time_range`, `test_unknown_filter_key_raises`, `test_prefix_matches_whole_labels` | +| Does a higher score mean more relevant? | The store returns a distance, and a lower distance is closer. The memory contract returns records in order, with no score. An adapter that needs a score converts the distance. | answered || `test_search_returns_distance_closest_first` | +| What does `recall(as_of=T)` return? | The records that are valid at T by the as-of rule in [contracts.md](contracts.md). If `history` is consulted, this includes records superseded after T. If not, it excludes them. | answered || phase 3 | +| Who enforces scope? | The library filters on the allowed scopes of the caller. The platform enforces the database boundary through the API token. A caller that holds the token can go around the library. | answered || phase 3 | +| Can a consumer tell sources, extractions, and hypotheses apart? | Yes, through `kind`, `sources`, and `actor`. An extracted fact carries the name of the extractor in `actor`. | answered || phase 3 | +| Is a record deleted after its `forget_after` time passes? | No. It stops appearing in `list` and `search`. The sweeper deletes it on its next run. | answered [measured] || `test_forget_after_hides_without_deleting`, and the sweeper in phase 2 | +| Does a write inside a turn reach a `recall` in the same turn? | No, by contract. A consumer reads what was there before its own capture. | answered || phase 3 | ## Measurements diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..d0cf983 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,62 @@ +"""The ledger test. Every row of docs/guarantees.md names a test that exists, or a phase.""" + +import ast +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LEDGER = ROOT / "docs" / "guarantees.md" +SUITE = ROOT / "tests" / "test_conformance.py" +HEADER = "| Question | Answer | State | Test |" +TEST_NAME = re.compile(r"`(test_[A-Za-z0-9_]+)`") +PHASE = re.compile(r"\bphase \d+\b") + + +def ledger_rows() -> list[tuple[str, str]]: + """Return the question and the Test cell of each row of the ledger table.""" + lines = LEDGER.read_text(encoding="utf-8").splitlines() + start = lines.index(HEADER) + 2 + rows = [] + for line in lines[start:]: + if not line.startswith("|"): + break + cells = [cell.strip() for cell in line.strip("|").split("|")] + rows.append((cells[0], cells[-1])) + return rows + + +def defined_tests() -> set[str]: + """Return the name of every test function in the conformance suite.""" + tree = ast.parse(SUITE.read_text(encoding="utf-8")) + return { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + } + + +def test_ledger_has_rows() -> None: + assert len(ledger_rows()) >= 16 + + +def test_every_named_test_exists() -> None: + defined = defined_tests() + missing = [ + f"{question} -> {name}" + for question, cell in ledger_rows() + for name in TEST_NAME.findall(cell) + if name not in defined + ] + assert not missing, ( + "docs/guarantees.md names tests that tests/test_conformance.py does not define:\n" + + "\n".join(missing) + ) + + +def test_every_row_names_a_test_or_a_phase() -> None: + empty = [ + question + for question, cell in ledger_rows() + if not TEST_NAME.search(cell) and not PHASE.search(cell) + ] + assert not empty, "these ledger rows name no test and no phase:\n" + "\n".join(empty) From f6bf2bf7c2106d4e220557bd86640c622fa52c2d Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:38:07 +0530 Subject: [PATCH 09/12] Run make verify in CI A GitHub Actions workflow installs uv with Python 3.11, the lowest version that pyproject.toml allows, and runs make verify on each pull request and on each push to main. Refs #4 --- .github/workflows/verify.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..86c1931 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,20 @@ +name: verify + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + - run: make verify From 1501687ac8d615b7fe906d312362244ec02a967c Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:39:30 +0530 Subject: [PATCH 10/12] Audit the docs against the code README and contracts.md no longer say that no code exists. README gets an example of MemoryStore, run as written. CONTRIBUTING.md names the test files, the fake clock and embedder, and the ledger rule for rows that a later phase proves, and marks the Hotdata tests and the oracle test as phase 2. AGENTS.md points to CHANGELOG.md and the library, and adds the changelog rule. local.md says the Hotdata driver comes in phase 2. The brief and the ledger carry the record rules and the writer wording from contracts.md. plan.md records the supersede question for phase 3. Refs #4 --- AGENTS.md | 3 +++ CONTRIBUTING.md | 40 +++++++++++++++++++++++----------------- README.md | 32 ++++++++++++++++++++++++++++++-- docs/contracts.md | 5 +++-- docs/guarantees.md | 2 +- docs/internal/brief.md | 17 +++++++++-------- docs/internal/plan.md | 5 +++++ docs/local.md | 3 ++- 8 files changed, 76 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a1cf3b..46e09b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ uv run --env-file .env scripts/measure_local.py --cloud # M4 to M6 in the clou - [docs/guarantees.md](docs/guarantees.md): what a consumer can rely on, and the proof. - [docs/internal/brief.md](docs/internal/brief.md): the design and its reasons. - [CONTRIBUTING.md](CONTRIBUTING.md): the checks inside `make verify` and the test rules. +- [CHANGELOG.md](CHANGELOG.md): each change to a public surface. +- `src/hotmemory/`: the library. `tests/test_conformance.py` is the conformance suite. ## Rules @@ -37,3 +39,4 @@ uv run --env-file .env scripts/measure_local.py --cloud # M4 to M6 in the clou workspace id, or a database id in a file. - If a change alters a behavior, update `docs/contracts.md` and `docs/guarantees.md` in the same commit. +- If a change alters a frozen surface, add an entry to `CHANGELOG.md` in the same commit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26ea4f6..2e91309 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,29 +38,34 @@ passes in CI. It has no tiers, because the full check takes less than five secon ## Rules of the harness -The library is deterministic. The offline suite needs no clock, no network, and no model. -These rules keep it that way. - -- One conformance suite runs against every driver. Each answered guarantee in - [docs/guarantees.md](docs/guarantees.md) is one test. A driver that fails a conformance - test is not a driver. -- The in-memory driver is the reference for the Hotdata driver. A test builds the same - records in both drivers and compares the results of `search`. +The library is deterministic. The offline suite needs no real clock, no network, and no +model. The tests pass a fixed clock and a fake embedder to the store, from +`tests/conftest.py`. These rules keep the suite deterministic. + +- One conformance suite, `tests/test_conformance.py`, runs against every driver through + the `store` fixture. Each answered guarantee of the storage contract in + [docs/guarantees.md](docs/guarantees.md) has its tests there. A driver that fails a + conformance test is not a driver. To add a driver, add it to `DRIVERS` in + `tests/conftest.py`. +- The in-memory driver is the reference for the Hotdata driver. From phase 2, a test + builds the same records in both drivers and compares the results of `search`. - Four surfaces are frozen: the names in `__all__`, the method set of the `Store` protocol, the fields and field types of the record for each schema version, and the filter keys of `list` and `search`. A test compares each surface against a literal set. - A change to a frozen surface is a public contract change, and it needs a changelog - entry. + These tests are in `tests/test_frozen.py`. A change to a frozen surface is a public + contract change, and it needs an entry in [CHANGELOG.md](CHANGELOG.md). - A frozen surface is compared against a literal set, never against the thing that it protects. A test that iterates over the protected thing turns a deletion into one test fewer and not into a failure. -- Each row in the guarantees ledger names the test that proves it. A test reads the ledger. - A named test that does not exist makes it fail. -- Tests marked `hotdata` run the Hotdata driver against a real database. They need - `HOTMEMORY_TEST_DB` to name a throwaway database. Without it, they skip. They +- Each row in the guarantees ledger names the conformance tests that prove it, or the phase + that will prove it. `tests/test_ledger.py` reads the ledger. A named test that the + conformance suite does not define makes it fail. A row with no test and no phase also + makes it fail. +- From phase 2, tests marked `hotdata` run the Hotdata driver against a real database. + They need `HOTMEMORY_TEST_DB` to name a throwaway database. Without it, they skip. They run once for each pull request. They are the only tests that use the network. -- No test calls a model. `capture` takes a callable, and the tests pass a fake extractor - that returns fixed facts. +- No test calls a model. The tests pass a fake embedder. From phase 3, `capture` takes a + callable, and the tests pass a fake extractor that returns fixed facts. - There is no coverage gate, no mutation-testing gate, and no report generator. The output of `make verify` is the report. @@ -68,7 +73,8 @@ These rules keep it that way. Documents are checked like code. If you change a behavior, update [docs/contracts.md](docs/contracts.md) and [docs/guarantees.md](docs/guarantees.md) in the -same pull request. +same pull request. If you change a public surface, add an entry to +[CHANGELOG.md](CHANGELOG.md). No file in this repository names a private repository, a customer, or a deployment detail. This rule includes `docs/internal/`. diff --git a/README.md b/README.md index 385adb5..6d8f75c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ Hotdata table, so an agent can join its memory to its own data in one SQL query. the memory systems that we surveyed stores memory as typed columns in the same engine as the data of the consumer. -Status: design. No code exists yet. The contracts and the guarantees are written, and -phase 0 measured each guarantee that was open. +Status: version 0.0.0, not published. The storage contract exists in Python with one +driver, `MemoryStore`, which runs in process memory. The Hotdata driver and the memory +contract do not exist yet. [docs/internal/roadmap.md](docs/internal/roadmap.md) lists the +phases. The library has two layers: @@ -24,6 +26,31 @@ of the consumer. There is no hotmemory server. The first consumer is an incident investigator built on Hotdata. The library is not specific to it. A plain Python agent, a LangGraph agent, or any process with a Hotdata API key can use it. +## Try the storage contract + +`MemoryStore` needs an embedder for a search with query text. An embedder is a callable +that turns a list of texts into a list of vectors. This example uses a toy embedder that +counts two words: + +```python +from hotmemory import Filter, MemoryStore + + +def embed(texts): + return [[text.count("disk") + 0.1, text.count("cpu") + 0.1] for text in texts] + + +store = MemoryStore(embedder=embed) +store.put(("team", "alerts"), "disk", kind="fact", content="The disk fills at night.") +store.put(("team", "alerts"), "disk", kind="fact", content="The disk fills at noon.") +store.put(("team", "alerts"), "cpu", kind="fact", content="The cpu spikes after a deploy.") + +print(store.get(("team", "alerts"), "disk").id) # team/alerts/disk@2 +print([r.revision for r in store.history(("team", "alerts"), "disk")]) # [1, 2] +hits = store.search("disk", [("team",)], Filter(kind="fact"), k=1) +print(hits[0].record.content) # The disk fills at noon. +``` + ## Documents - [docs/contracts.md](docs/contracts.md): the record, the storage operations, the memory @@ -34,5 +61,6 @@ LangGraph agent, or any process with a Hotdata API key can use it. container. - [CONTRIBUTING.md](CONTRIBUTING.md): the one command that checks a change, and the rules of the test suite. +- [CHANGELOG.md](CHANGELOG.md): each change to a public surface. - [docs/internal/](docs/internal/brief.md): the design brief, the survey, the roadmap, and the plan for the current phase. diff --git a/docs/contracts.md b/docs/contracts.md index d37b975..8651966 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -5,8 +5,9 @@ implements. The memory contract is the surface that an agent calls, and it is bu store. This file states both. The behavior that each contract guarantees, and the proof for each guarantee, are in [guarantees.md](guarantees.md). -Status: design. No code exists yet. This file describes schema version 1 as the library -will ship it. +Status: the storage contract exists in Python, with `MemoryStore` as its only driver. +`HotdataStore` and the memory contract are design, and this file describes them as the +library will ship them. This file describes schema version 1. ## The platform under the store diff --git a/docs/guarantees.md b/docs/guarantees.md index d7a197c..622b576 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -28,7 +28,7 @@ contract. | Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so that a caller can decide. | answered || `test_deduplication_is_exact_on_normalized_content` | | After a synchronous `put` returns, does `list` see the record? | Yes. A read after a write was never stale in 30 trials. | answered [measured] || `test_synchronous_put_is_visible_to_list` | | After a synchronous `put` returns, does `search` see the record? | Yes. Without an index, the retrieval query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 || `test_synchronous_put_is_visible_to_search` | -| After a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes. `writer` returns the ids that it flushed. | answered || `test_buffered_put_is_visible_after_flush` | +| After a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes. The writer records the ids that it flushed. | answered || `test_buffered_put_is_visible_after_flush` | | Two processes write to the same table. What happens? | The engine refuses the second load with 409. The driver retries with backoff and stops after a bound. Inside one process, the writer sends one load at a time. | answered [measured], M3 || phase 2 | | Two writers put the same key. What happens? | The last writer wins at the row level. Revisions are new rows, so both revisions exist and the later one is current. | answered || `test_last_writer_wins_on_one_key` | | Does `delete` remove retained revisions and their embeddings? | Yes. An embedding is a column of its row. After a keyed delete, neither a provider-backed vector index nor a BM25 index returned the deleted row. | answered [measured], M2 || `test_delete_removes_every_revision` | diff --git a/docs/internal/brief.md b/docs/internal/brief.md index ee33686..b934626 100644 --- a/docs/internal/brief.md +++ b/docs/internal/brief.md @@ -1,7 +1,8 @@ # Brief: hotmemory, agent memory as tables on Hotdata Status: draft, revised 2026-10-05, phases moved to `roadmap.md` and `plan.md`, and the phase 0 -measurements applied (results in `docs/guarantees.md`). Nothing in this brief is built. It fixes the two +measurements applied (results in `docs/guarantees.md`). Phase 1 built the storage contract with +`MemoryStore`, and `docs/contracts.md` states the details that it fixed. It fixes the two contracts, the guarantees behind them, and the harness that proves them, before the first line of code. The revision applies section 4 of `survey.md`, in this folder. @@ -136,16 +137,16 @@ A record is the unit the store holds. Its fields are fixed for schema version 1. | Field | Type | Meaning | |---|---|---| -| `namespace` | tuple of strings | Where the record lives. Stored as a path string joined with `/`, and matched on whole labels, never on a string prefix. No label contains `.`. | -| `key` | string | The caller's stable identifier inside the namespace. | +| `namespace` | tuple of strings | Where the record lives. Stored as a path string joined with `/`, and matched on whole labels, never on a string prefix. A label is not empty and contains no `.` and no `/`. | +| `key` | string | The caller's stable identifier inside the namespace. It is not empty and contains no `/` and no `@`. | | `revision` | integer | 1 for the first put under a key, then counting up. | | `kind` | string | One of `fact`, `profile`, `procedure`, `episode`. | | `subject` | string | What the record is about, for example an alert key, a person, a service. Empty when unknown. | | `content` | string | The text a model reads. This is the column a provider-backed index embeds. | -| `cues` | list of strings | Questions or phrases this record answers. Optional. Embedded separately from `content`. Section 3.4. | +| `cues` | tuple of strings | Questions or phrases this record answers. Optional. Embedded separately from `content`. Section 3.4. | | `payload` | JSON object | Structured data the consumer defines. The store never reads it. | -| `tags` | list of strings | Free labels. Filterable. | -| `sources` | list of strings | References to where the record came from: a thread id, a document path, a run id, an episode key. The length of this list is the corroboration count. | +| `tags` | tuple of strings | Free labels. Filterable. | +| `sources` | tuple of strings | References to where the record came from: a thread id, a document path, a run id, an episode key. The length of this list is the corroboration count. | | `actor` | string | Who wrote this revision: a user id, an agent name, an extractor name. | | `created_at` | timestamp | When this revision was written. System clock. | | `observed_at` | timestamp or null | The time of the source the record came from. A post-mortem loaded a year later keeps the incident date here. | @@ -176,7 +177,7 @@ store found out. A replay that asks what memory held on a given day reads `creat | `search` | query text or none, namespace prefixes, optional filter, k | Returns up to k current revisions ranked by relevance, closest first, with a distance. The same filter as `list`. With no query text it is `list`. | | `delete` | namespace, key | Removes every revision of the key. Hard delete. | | `list_namespaces` | optional prefix | Returns the distinct namespaces under the prefix. | -| `writer` | none | A context manager. Every `put` inside it is buffered. The buffer flushes on exit, or at a row count, or at an interval, and returns the ids it flushed. | +| `writer` | optional row count, optional interval | A context manager. Every `put` inside it is buffered. The buffer flushes on exit, or at a row count, or at an interval, and the writer records the ids it flushed. | Deleted revisions, superseded revisions, and records past `forget_after` never appear in `list` or `search`. `history` shows superseded revisions. Nothing shows deleted ones. @@ -294,7 +295,7 @@ gets a conformance test in phase 1, and `docs/guarantees.md` names the test. | Is a paraphrase a duplicate? | No. Deduplication is exact on normalized content. `candidates` exists so a caller can decide. | answered | | When a synchronous `put` returns, is the record visible to `list`? | Yes. Read after write was never stale in 30 trials. | answered [measured] | | When a synchronous `put` returns, is the record visible to `search`? | Yes. Without an index the ranking query scans the table. With a provider-backed vector index or a BM25 index, the first search after the load returned the new row. | answered [measured], M1 | -| When a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes, and `writer` returns the ids it flushed. | answered | +| When a buffered `put` returns, is the record visible? | No. It is visible after the writer flushes, and the writer records the ids it flushed. | answered | | What happens when two processes write the same table? | The second load is refused with 409. The driver retries with backoff and gives up after a bound. Within one process the writer serializes. | answered [measured], M3: with two concurrent writers no load needed more than 3 of 8 attempts | | What happens when two writers put the same key? | Last writer wins at the row level. Because revisions are new rows, both revisions exist and the later one is current. | answered | | Does `delete` remove retained revisions and derived embeddings? | Yes. The embedding is a column of the row, and after a keyed delete neither a provider-backed vector index nor a BM25 index returned the row. | answered [measured], M2 | diff --git a/docs/internal/plan.md b/docs/internal/plan.md index 3668046..5471cf1 100644 --- a/docs/internal/plan.md +++ b/docs/internal/plan.md @@ -98,6 +98,11 @@ Worked in order on one branch. Each task is one commit or a few. fused search, the test has to compare something looser, such as the set of records returned. Phase 2 decides which, and this phase keeps `MemoryStore` search vector-only so that either choice stays open. +- How `supersede` closes the old record. The memory contract sets the old record's + `valid_until` and `expired_at`. `Store.put` sets only `superseded_by` on the old + revision, and no `Store` operation can change another field of a written revision. Phase + 3 decides how `Memory` writes those two fields, and the change to `Store` needs a + changelog entry. ## Stop and ask if diff --git a/docs/local.md b/docs/local.md index 2457e91..502e15b 100644 --- a/docs/local.md +++ b/docs/local.md @@ -2,7 +2,8 @@ RuntimeDB is the Hotdata query engine. The library can use a local RuntimeDB in place of a cloud workspace, with no API key and no cloud service. This page tells you how to start it -and how to point the library at it. +and how to point the library at it. The Hotdata driver that reads the variables below +comes in phase 2. Today, the measurement scripts use the stack. ## Start the stack From f6c837df83cc20606bb9498acca0baa019b09b89 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 5 Oct 2026 22:39:51 +0530 Subject: [PATCH 11/12] Reword the frozen-surface failure message Refs #4 --- tests/test_frozen.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_frozen.py b/tests/test_frozen.py index 6d5c0c6..ee5327c 100644 --- a/tests/test_frozen.py +++ b/tests/test_frozen.py @@ -9,8 +9,8 @@ def changed(surface: str) -> str: return ( - f"{surface} is a frozen surface. A change to it is a public contract change: " - "add an entry to CHANGELOG.md under Unreleased, then update the literal in this test." + f"Frozen surface changed: {surface}. This is a public contract change. " + "Add an entry to CHANGELOG.md under Unreleased, then update the literal in this test." ) @@ -45,11 +45,11 @@ def test_store_methods_are_frozen() -> None: "delete", "list_namespaces", "writer", - }, changed("The method set of Store") + }, changed("the method set of Store") def test_record_fields_are_frozen() -> None: - assert hotmemory.SCHEMA_VERSION == 1, changed("The schema version") + assert hotmemory.SCHEMA_VERSION == 1, changed("the schema version") assert {field.name: field.type for field in fields(Record)} == { "namespace": "tuple[str, ...]", "key": "str", @@ -71,9 +71,9 @@ def test_record_fields_are_frozen() -> None: "forget_after": "datetime | None", "forget_reason": "str", "id": "str", - }, changed("The fields and field types of Record, schema version 1,") + }, changed("the fields and field types of Record, schema version 1") assert set(get_args(Kind)) == {"fact", "profile", "procedure", "episode"}, changed( - "The values of Kind" + "the values of Kind" ) @@ -87,4 +87,4 @@ def test_filter_keys_are_frozen() -> None: "valid_until", "created_at", "expired_at", - }, changed("The filter keys of list and search") + }, changed("the filter keys of list and search") From 80a36ac3f63855999e4919bd55373cb7d1142754 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Tue, 6 Oct 2026 13:24:26 +0530 Subject: [PATCH 12/12] Address the review: forgotten duplicates, payload copies, empty content A put whose content equals a current revision past its forget_after now writes a new revision, so a forgotten fact can come back. The record deep-copies payload, and MemoryStore returns copies, so a caller cannot change a stored record. A record refuses content that is empty after normalization, so no record gets a zero vector from its own text. Each rule has a conformance test that the ledger names, and each test was checked to fail with its fix undone. contracts.md, the brief, and the ledger state the rules. plan.md records for phase 3 that deduplication on content alone blocks adding a source. Refs #4 --- docs/contracts.md | 8 +++--- docs/guarantees.md | 3 +++ docs/internal/brief.md | 4 +-- docs/internal/plan.md | 3 +++ src/hotmemory/_rules.py | 13 ++++++--- src/hotmemory/memory.py | 24 +++++++++++------ src/hotmemory/record.py | 7 ++++- tests/test_conformance.py | 57 ++++++++++++++++++++++++++++++++++++++- tests/test_record.py | 13 +++++++++ 9 files changed, 113 insertions(+), 19 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index 8651966..e69a8f0 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -82,7 +82,7 @@ A record is the unit that the store holds. Schema version 1 fixes these fields. | `revision` | integer | 1 for the first put under a key. Each later put adds 1. | | `kind` | string | One of `fact`, `profile`, `procedure`, `episode`. | | `subject` | string | What the record is about, for example an alert key, a person, or a service. Empty if unknown. | -| `content` | string | The text that a model reads. | +| `content` | string | The text that a model reads. It is not empty after normalization. | | `cues` | tuple of strings | Questions or phrases that this record answers. Optional. The driver embeds them apart from `content`. | | `payload` | JSON object | Structured data that the consumer defines. The store never reads it. | | `tags` | tuple of strings | Free labels. You can filter on them. | @@ -99,7 +99,9 @@ A record is the unit that the store holds. Schema version 1 fixes these fields. | `id` | string | `namespace/key@revision`. Derived. The load key. | In Python, the record is a frozen dataclass. The list fields are tuples, so a record -cannot change after the store writes it. Every timestamp carries a time zone. The record +cannot change after the store writes it. The store copies `payload` when it writes a +record and when it returns one, so a change to a dict that a caller holds never reaches the +store. Every timestamp carries a time zone. The record refuses a value that the table above does not allow. The public record has no embedding field. If the caller supplies an embedder, the Hotdata @@ -115,7 +117,7 @@ records the moment that the store found out. To ask what memory held on a given | Operation | Arguments | Behavior | |---|---|---| -| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision, and the previous current row gets `superseded_by`. Returns the id. If the normalized content is equal to the content of the current revision, it writes nothing and returns the current id. | +| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision, and the previous current row gets `superseded_by`. Returns the id. If the normalized content is equal to the content of the current revision, it writes nothing and returns the current id. This rule compares content only. It does not apply when the current revision is past its `forget_after`, so a `put` of the same content brings the fact back as a new revision. | | `get` | namespace, key, optional revision | Returns the current revision, or the named revision. Returns None if the record does not exist. | | `history` | namespace, key | Returns every revision, oldest first. | | `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first, with ties in `created_at` ordered by id. `since` keeps the revisions whose `created_at` is at or after it. It uses no model and no embedding. | diff --git a/docs/guarantees.md b/docs/guarantees.md index 622b576..5f6d78d 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -38,6 +38,9 @@ contract. | Who enforces scope? | The library filters on the allowed scopes of the caller. The platform enforces the database boundary through the API token. A caller that holds the token can go around the library. | answered || phase 3 | | Can a consumer tell sources, extractions, and hypotheses apart? | Yes, through `kind`, `sources`, and `actor`. An extracted fact carries the name of the extractor in `actor`. | answered || phase 3 | | Is a record deleted after its `forget_after` time passes? | No. It stops appearing in `list` and `search`. The sweeper deletes it on its next run. | answered [measured] || `test_forget_after_hides_without_deleting`, and the sweeper in phase 2 | +| A record is past its `forget_after`. Does a `put` of the same content write it again? | Yes. Deduplication applies only to a current revision that `list` can return. The put writes a new revision, and `list` and `search` return it. | answered | `test_put_after_forget_after_writes_a_new_revision` | +| Can a caller change a stored record? | No. A record is frozen, and the store copies `payload` on the way in and on the way out. A change to a dict that the caller holds does not reach the store. | answered | `test_stored_record_cannot_change` | +| Can a record have empty content? | No. `put` and a buffered `put` refuse content that is empty after normalization, and write nothing. | answered | `test_put_refuses_empty_content` | | Does a write inside a turn reach a `recall` in the same turn? | No, by contract. A consumer reads what was there before its own capture. | answered || phase 3 | ## Measurements diff --git a/docs/internal/brief.md b/docs/internal/brief.md index b934626..3952465 100644 --- a/docs/internal/brief.md +++ b/docs/internal/brief.md @@ -142,7 +142,7 @@ A record is the unit the store holds. Its fields are fixed for schema version 1. | `revision` | integer | 1 for the first put under a key, then counting up. | | `kind` | string | One of `fact`, `profile`, `procedure`, `episode`. | | `subject` | string | What the record is about, for example an alert key, a person, a service. Empty when unknown. | -| `content` | string | The text a model reads. This is the column a provider-backed index embeds. | +| `content` | string | The text a model reads. Not empty after normalization. This is the column a provider-backed index embeds. | | `cues` | tuple of strings | Questions or phrases this record answers. Optional. Embedded separately from `content`. Section 3.4. | | `payload` | JSON object | Structured data the consumer defines. The store never reads it. | | `tags` | tuple of strings | Free labels. Filterable. | @@ -170,7 +170,7 @@ store found out. A replay that asks what memory held on a given day reads `creat | Operation | Signature, in words | Behaviour | |---|---|---| -| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision and the previous current row gets `superseded_by` set. Returns the id. If the normalized content equals the current revision's, writes nothing and returns the current id. | +| `put` | namespace, key, record fields | Writes a new revision. If the key exists, the new row gets the next revision and the previous current row gets `superseded_by` set. Returns the id. If the normalized content equals the current revision's, writes nothing and returns the current id, unless the current revision is past its `forget_after`. | | `get` | namespace, key, optional revision | Returns the current revision, or the named one. Returns None when absent. | | `history` | namespace, key | Returns every revision, oldest first. | | `list` | namespace prefix, optional filter, optional since, limit | Returns current revisions under the prefix, newest first. No model, no embedding. The filter is equality on `kind`, `subject`, `tags`, and `actor`, and a range on `valid_from`, `valid_until`, `created_at`, and `expired_at`. | diff --git a/docs/internal/plan.md b/docs/internal/plan.md index 5471cf1..20975cc 100644 --- a/docs/internal/plan.md +++ b/docs/internal/plan.md @@ -103,6 +103,9 @@ Worked in order on one branch. Each task is one commit or a few. revision, and no `Store` operation can change another field of a written revision. Phase 3 decides how `Memory` writes those two fields, and the change to `Store` needs a changelog entry. +- Deduplication compares content only. A `put` that keeps the content and adds a source, + a tag, or a new `forget_after` writes nothing. The contract counts sources as + corroboration, so phase 3 decides how `remember` adds a source to an existing fact. ## Stop and ask if diff --git a/src/hotmemory/_rules.py b/src/hotmemory/_rules.py index c88d690..6dd67ec 100644 --- a/src/hotmemory/_rules.py +++ b/src/hotmemory/_rules.py @@ -30,9 +30,14 @@ def next_revision(current: Record | None) -> int: return 1 if current is None else current.revision + 1 -def is_duplicate(current: Record, content: str) -> bool: - """Return True if `content` normalizes to the normalized content of `current`.""" - return normalize(current.content) == normalize(content) +def is_duplicate(current: Record, content: str, now: datetime) -> bool: + """Return True if a put of `content` writes nothing because `current` holds it. + + That is the case when `current` is listed at `now` and its normalized content equals + the normalized `content`. A current revision past its `forget_after` is never a + duplicate, so a put brings the fact back as a new revision. + """ + return is_listed(current, now) and normalize(current.content) == normalize(content) def is_listed(record: Record, now: datetime) -> bool: @@ -101,7 +106,7 @@ def build_record( subject=subject, content=content, cues=tuple(cues), - payload={} if payload is None else dict(payload), + payload={} if payload is None else payload, tags=tuple(tags), sources=tuple(sources), actor=actor, diff --git a/src/hotmemory/memory.py b/src/hotmemory/memory.py index 4f31f4b..50c29e1 100644 --- a/src/hotmemory/memory.py +++ b/src/hotmemory/memory.py @@ -88,11 +88,13 @@ def put( def get(self, namespace: Sequence[str], key: str, revision: int | None = None) -> Record | None: revisions = self._revisions.get(self._slot(namespace, key), []) if revision is None: - return revisions[-1] if revisions else None - return next((record for record in revisions if record.revision == revision), None) + found = revisions[-1] if revisions else None + else: + found = next((record for record in revisions if record.revision == revision), None) + return None if found is None else _copy(found) def history(self, namespace: Sequence[str], key: str) -> builtins.list[Record]: - return builtins.list(self._revisions.get(self._slot(namespace, key), [])) + return [_copy(record) for record in self._revisions.get(self._slot(namespace, key), [])] def list( self, @@ -105,7 +107,7 @@ def list( records = self._listed([prefix], filter) if since is not None: records = [record for record in records if record.created_at >= since] - return records[:limit] + return [_copy(record) for record in records[:limit]] def search( self, @@ -117,7 +119,7 @@ def search( _check_count("k", k) records = self._listed(prefixes, filter) if query is None: - return [Hit(record, None) for record in records[:k]] + return [Hit(_copy(record), None) for record in records[:k]] query_vector = self._embed_query(query) self._embed_missing(records) hits = [ @@ -125,7 +127,7 @@ def search( for record in records ] hits.sort(key=lambda hit: (hit.distance, newest_first(hit.record))) - return hits[:k] + return [Hit(_copy(hit.record), hit.distance) for hit in hits[:k]] def delete(self, namespace: Sequence[str], key: str) -> None: for record in self._revisions.pop(self._slot(namespace, key), []): @@ -146,9 +148,10 @@ def _write(self, draft: Record) -> str: slot = (draft.namespace, draft.key) revisions = self._revisions.get(slot, []) current = revisions[-1] if revisions else None - if current is not None and is_duplicate(current, draft.content): + now = self._clock() + if current is not None and is_duplicate(current, draft.content, now): return current.id - record = replace(draft, revision=next_revision(current), created_at=self._clock()) + record = replace(draft, revision=next_revision(current), created_at=now) if current is not None: revisions = [*revisions[:-1], replace(current, superseded_by=record.id)] self._revisions[slot] = [*revisions, record] @@ -281,6 +284,11 @@ def __exit__( self._buffer.clear() +def _copy(record: Record) -> Record: + """Return a record equal to `record` that shares no mutable value with it.""" + return replace(record) + + def _check_count(name: str, value: int) -> None: if isinstance(value, bool) or not isinstance(value, int): raise TypeError(f"{name} must be an integer") diff --git a/src/hotmemory/record.py b/src/hotmemory/record.py index 2938147..2b3315f 100644 --- a/src/hotmemory/record.py +++ b/src/hotmemory/record.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime @@ -66,7 +67,8 @@ class Record: """One revision of a memory record, schema version 1. The constructor refuses a value that the contract does not allow, and derives `id`. - Every timestamp must carry a time zone. + Every timestamp must carry a time zone. `content` must not be empty after + normalization. The constructor stores a deep copy of `payload`. """ namespace: tuple[str, ...] @@ -104,8 +106,11 @@ def __post_init__(self) -> None: raise TypeError(f"{name} must be a string") for name in ("cues", "tags", "sources"): object.__setattr__(self, name, _strings(name, getattr(self, name))) + if not normalize(self.content): + raise ValueError("content must not be empty") if not isinstance(self.payload, dict): raise TypeError("payload must be a dict") + object.__setattr__(self, "payload", copy.deepcopy(self.payload)) if self.created_at is None: raise TypeError("created_at must be a datetime") for name in ( diff --git a/tests/test_conformance.py b/tests/test_conformance.py index c3d5144..fbac767 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -5,7 +5,7 @@ import pytest -from hotmemory import Filter, Store, TimeRange +from hotmemory import Filter, JSONValue, Store, TimeRange from .conftest import FakeClock @@ -181,3 +181,58 @@ def test_forget_after_hides_without_deleting(store: Store, clock: FakeClock) -> assert store.search("disk", [NS]) == [] assert store.get(NS, "disk") is not None assert len(store.history(NS, "disk")) == 1 + + +def test_put_after_forget_after_writes_a_new_revision(store: Store, clock: FakeClock) -> None: + store.put( + NS, + "disk", + kind="fact", + content="The disk fills at night.", + forget_after=clock.now + timedelta(days=1), + forget_reason="temporary", + ) + clock.advance(timedelta(days=1)) + record_id = store.put(NS, "disk", kind="fact", content="The disk fills at night.") + + assert record_id == "team/alerts/disk@2" + assert [record.id for record in store.list(NS)] == [record_id] + assert [hit.record.id for hit in store.search("disk fills at night", [NS])] == [record_id] + + +def test_stored_record_cannot_change(store: Store) -> None: + payload: dict[str, JSONValue] = {"hosts": ["db-1"], "limits": {"disk": 90}} + store.put(NS, "disk", kind="fact", content="The disk fills at night.", payload=payload) + expected = {"hosts": ["db-1"], "limits": {"disk": 90}} + + payload["hosts"] = ["changed"] + returned = [ + store.get(NS, "disk"), + store.get(NS, "disk", revision=1), + *store.history(NS, "disk"), + *store.list(NS), + *(hit.record for hit in store.search(None, [NS])), + *(hit.record for hit in store.search("disk", [NS])), + ] + for record in returned: + assert record is not None + assert record.payload == expected + hosts = record.payload["hosts"] + assert isinstance(hosts, list) + hosts.append("db-2") + record.payload["new"] = True + + stored = store.get(NS, "disk") + assert stored is not None + assert stored.payload == expected + + +@pytest.mark.parametrize("content", ["", " \n\t "]) +def test_put_refuses_empty_content(store: Store, content: str) -> None: + with pytest.raises(ValueError, match="content"): + store.put(NS, "disk", kind="fact", content=content) + with pytest.raises(ValueError, match="content"), store.writer() as writer: + writer.put(NS, "disk", kind="fact", content=content) + + assert store.get(NS, "disk") is None + assert store.list_namespaces() == [] diff --git a/tests/test_record.py b/tests/test_record.py index d293855..12474da 100644 --- a/tests/test_record.py +++ b/tests/test_record.py @@ -97,3 +97,16 @@ def test_record_is_frozen() -> None: def test_normalize_lowercases_and_collapses_whitespace() -> None: assert normalize(" The Disk\n\tFILLS at night. ") == "the disk fills at night." + + +@pytest.mark.parametrize("content", ["", " \n\t"]) +def test_refuses_empty_content(content: str) -> None: + with pytest.raises(ValueError, match="content"): + make(content=content) + + +def test_payload_is_copied_on_the_way_in() -> None: + payload: dict[str, Any] = {"hosts": ["db-1"]} + record = make(payload=payload) + payload["hosts"].append("db-2") + assert record.payload == {"hosts": ["db-1"]}