From 2d3b2ac2b5c8af3d045a088aec61f280c47def55 Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Wed, 08 Apr 2026 06:26:52 +0000 Subject: [PATCH] chore: add mst-test-suite --- .gitignore | 4 ++++ README.md | 8 ++++---- flake.nix | 2 +- test/fixtures/mst-test-suite/LICENSE | 21 +++++++++++++++++++++ test/fixtures/mst-test-suite/README.md | 14 ++++++++++++++ test/fixtures/mst-test-suite/cars/exhaustive/.gitkeep | 0 test/fixtures/mst-test-suite/pyproject.toml | 8 ++++++++ test/fixtures/mst-test-suite/scripts/generate_exhaustive_cars.py | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/fixtures/mst-test-suite/tests/diff/exhaustive/.gitkeep | 0 test/fixtures/mst-test-suite/uv.lock | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 10 file(s) changed, 307 insertion(s)(+), 5 deletion(s)(-) diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ /tmp/ .direnv .envrc +.venv + +/test/fixtures/mst-test-suite/cars/**/*.car +/test/fixtures/mst-test-suite/tests/**/*.json diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ] end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . - +Documentation can be generated with +[ExDoc](https://github.com/elixir-lang/ex_doc) and published on +[HexDocs](https://hexdocs.pm). Once published, the docs can be found at +. diff --git a/flake.nix b/flake.nix --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ defaultForSystems = fn: forSystems (pkgs: {default = fn pkgs;}); in { devShells = defaultForSystems (pkgs: pkgs.mkShell { - nativeBuildInputs = with pkgs; [elixir erlang]; + nativeBuildInputs = with pkgs; [elixir erlang uv]; }); }; } diff --git a/test/fixtures/mst-test-suite/LICENSE b/test/fixtures/mst-test-suite/LICENSE new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 David Buchanan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/fixtures/mst-test-suite/README.md b/test/fixtures/mst-test-suite/README.md new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/README.md @@ -0,0 +1,14 @@ +# mst-test-suite + +Test suite for [atproto-flavour](https://atproto.com/specs/repository) merkle +search tree ops. + +Install [`uv`](https://docs.astral.sh/uv/), run `uv sync`, and then run +`uv run ./scripts/generate_exhaustive_cars.py` to generate the fixtures for the +test suite. + +Copied from +[DavidBuchanan314/mst-test-suite](https://github.com/DavidBuchanan314/mst-test-suite) +in order to comprehensively test MST functionality. + +mst-test-suite is licensed under the [MIT License](./LICENSE). diff --git a/test/fixtures/mst-test-suite/cars/exhaustive/.gitkeep b/test/fixtures/mst-test-suite/cars/exhaustive/.gitkeep new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/cars/exhaustive/.gitkeep diff --git a/test/fixtures/mst-test-suite/pyproject.toml b/test/fixtures/mst-test-suite/pyproject.toml new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "mst-test-suite" +version = "0.1.0" +requires-python = ">=3.15" +dependencies = [ + "atmst>=0.0.6", + "cbrrr>=1.1.0", +] diff --git a/test/fixtures/mst-test-suite/scripts/generate_exhaustive_cars.py b/test/fixtures/mst-test-suite/scripts/generate_exhaustive_cars.py new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/scripts/generate_exhaustive_cars.py @@ -0,0 +1,202 @@ +from typing import BinaryIO, Optional +import json + +from atmst.mst.node import MSTNode +from atmst.mst.node_store import NodeStore +from atmst.mst.node_wrangler import NodeWrangler +from atmst.mst.node_walker import NodeWalker +from atmst.mst.diff import very_slow_mst_diff, record_diff +from atmst.blockstore import MemoryBlockStore, OverlayBlockStore, BlockStore +from atmst.blockstore.car_file import encode_varint +from atmst.mst import proof +import cbrrr +from cbrrr import CID + +class LoggingBlockStoreWrapper(BlockStore): + def __init__(self, bs: BlockStore): + self.bs = bs + self.gets = set() + + def put_block(self, key: bytes, value: bytes) -> None: + self.bs.put_block(key, value) + + def get_block(self, key: bytes) -> bytes: + self.gets.add(key) + return self.bs.get_block(key) + + def del_block(self, key: bytes) -> None: + self.bs.del_block(key) + +""" +class LoggingNodeStore(NodeStore): + def __init__(self, bs): + self.read_cids = set() + self.stored_cids = set() + super().__init__(bs) + + def get_node(self, cid: Optional[CID]) -> MSTNode: + if cid is None: + self.read_cids.add(MSTNode.empty_root().cid) + else: + self.read_cids.add(cid) + return super().get_node(cid) + + def stored_node(self, node: MSTNode) -> MSTNode: + self.stored_cids.add(node.cid) + return super().stored_node(node) +""" + +class CarWriter: + def __init__(self, stream: BinaryIO, root: cbrrr.CID) -> None: + self.stream = stream + header_bytes = cbrrr.encode_dag_cbor( + {"version": 1, "roots": [root]} + ) + stream.write(encode_varint(len(header_bytes))) + stream.write(header_bytes) + + def write_block(self, cid: cbrrr.CID, value: bytes): + cid_bytes = bytes(cid) + self.stream.write(encode_varint(len(cid_bytes) + len(value))) + self.stream.write(cid_bytes) + self.stream.write(value) + +keys = [] +key_heights = [0, 1, 0, 2, 0, 1, 0] # if all these keys are added to a MST, it'll form a perfect binary tree. +i = 0 +for height in key_heights: + while True: + key = f"k/{i:02d}" + i += 1 + if MSTNode.key_height(key) == height: + keys.append(key) + break + +vals = [CID.cidv1_dag_cbor_sha256_32_from(cbrrr.encode_dag_cbor({"$type": "mst-test-data", "value_for": k})) for k in keys] + +val_for_key = dict(zip(keys, vals)) + +print(keys) +print(vals) + +# we can reuse these +bs = MemoryBlockStore() +ns = NodeStore(bs) +wrangler = NodeWrangler(ns) + +roots = [] + +for i in range(2**len(keys)): + filename = f"./cars/exhaustive/exhaustive_{i:03d}.car" + root = ns.get_node(None).cid + for j in range(len(keys)): + if (i>>j) & 1: + #filename += f"_{keys[j]}h{key_heights[j]}" + root = wrangler.put_record(root, keys[j], vals[j]) + #filename += ".car" + print(i, filename) + + car_blocks = [] + for node in NodeWalker(ns, root).iter_nodes(): + car_blocks.append((node.cid, node.serialised)) + + assert(len(set(cid for cid, val in car_blocks)) == len(car_blocks)) # no dupes + + with open(filename, "wb") as carfile: + car = CarWriter(carfile, root) + for cid, val in sorted(car_blocks, key=lambda x: bytes(x[0])): + car.write_block(cid, val) + + roots.append(root) + +# collecting these stats just for the sake of curiosity +#identical_proof_and_creation_count = 0 +#proof_superset_of_creation_count = 0 +#creation_superset_of_proof_count = 0 +inversion_needs_extra_blocks = 0 +clusion_proof_nodes_not_in_inversion_proof = 0 + +# generate exhaustive test cases +for ai, root_a in enumerate(roots): + for bi, root_b in enumerate(roots): + filename = f"./tests/diff/exhaustive/exhaustive_{ai:03d}_{bi:03d}.json" + print(filename) + car_a = f"./cars/exhaustive/exhaustive_{ai:03d}.car" + car_b = f"./cars/exhaustive/exhaustive_{bi:03d}.car" + created_nodes, deleted_nodes = very_slow_mst_diff(ns, root_a, root_b) + record_ops = [] + proof_nodes = set() + no_deletions = True + for delta in record_diff(ns, created_nodes, deleted_nodes): + record_ops.append({ + "rpath": delta.path, + "old_value": None if delta.prior_value is None else delta.prior_value.encode(), + "new_value": None if delta.later_value is None else delta.later_value.encode() + }) + if delta.later_value is None: # deletion + proof_nodes.update(proof.build_exclusion_proof(ns, root_b, delta.path)) + no_deletions = False + else: # update or create + proof_nodes.update(proof.build_inclusion_proof(ns, root_b, delta.path)) + + if no_deletions: # commits with no deletions are more well-behaved + assert(proof_nodes.issubset(created_nodes)) + + # my inductive-proof-generation logic is ops order sensitive, so we do the sort beforehand + # TODO: maybe "deletes first" or similar produces smaller proofs on average? + record_ops.sort(key=lambda x: x["rpath"]) + + # figure out which blocks are required for inductive proofs. + # the idea here is that we use an overlay blockstore and log every "get" that has to fall thru to the lower layer. + # those gets are therefore the blocks required for a stateless consumer to verify the proof. + upper = MemoryBlockStore() + lbs = LoggingBlockStoreWrapper(bs) + lns = NodeStore(OverlayBlockStore(upper, lbs)) + lnw = NodeWrangler(lns) + proof_root = root_b + for op in record_ops[::-1]: # while the order does not effect the final root CID, it does affect the set of CIDs that fall thru + if op["old_value"] is None: + proof_root = lnw.del_record(proof_root, op["rpath"]) + else: + proof_root = lnw.put_record(proof_root, op["rpath"], val_for_key[op["rpath"]]) + assert(proof_root == root_a) # we're back to where we started + inductive_proof_nodes = set(CID(cid) for cid in lbs.gets) + + if inductive_proof_nodes - (created_nodes | proof_nodes): + #print(delta) + inversion_needs_extra_blocks += 1 + + if proof_nodes - inductive_proof_nodes: + clusion_proof_nodes_not_in_inversion_proof += 1 + + #if proof_nodes == created_nodes: + # identical_proof_and_creation_count += 1 + #if proof_nodes.issuperset(created_nodes): + # proof_superset_of_creation_count += 1 + #if created_nodes.issuperset(proof_nodes): + # creation_superset_of_proof_count += 1 + + testcase = { + "$type": "mst-diff", + "description": f'procedurally generated MST diff test case between MST {ai} and {bi}', + "inputs": { + "mst_a": car_a, + "mst_b": car_b + }, + "results": { + "created_nodes": sorted([cid.encode() for cid in created_nodes]), + "deleted_nodes": sorted([cid.encode() for cid in deleted_nodes]), + "record_ops": record_ops, # these were sorted earlier + "proof_nodes": sorted([cid.encode() for cid in proof_nodes]), + "inductive_proof_nodes": sorted([cid.encode() for cid in inductive_proof_nodes]), + "firehose_cids": "TODO" + } + } + with open(filename, "w") as jsonfile: + json.dump(testcase, jsonfile, indent="\t") + +#print("identical_proof_and_creation_count", identical_proof_and_creation_count / (len(roots)**2)) # 0.75 +#print("proof_superset_of_creation_count", proof_superset_of_creation_count / (len(roots)**2)) # 0.84 +#print("creation_superset_of_proof_count", creation_superset_of_proof_count / (len(roots)**2)) # 0.91 +print("inversion_needs_extra_blocks", inversion_needs_extra_blocks / (len(roots)**2)) # 0.04 +print(clusion_proof_nodes_not_in_inversion_proof) diff --git a/test/fixtures/mst-test-suite/tests/diff/exhaustive/.gitkeep b/test/fixtures/mst-test-suite/tests/diff/exhaustive/.gitkeep new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/tests/diff/exhaustive/.gitkeep diff --git a/test/fixtures/mst-test-suite/uv.lock b/test/fixtures/mst-test-suite/uv.lock new file mode 100644 --- /dev/null +++ b/test/fixtures/mst-test-suite/uv.lock @@ -0,0 +1,53 @@ +version = 1 +revision = 3 +requires-python = ">=3.15" + +[[package]] +name = "atmst" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbrrr" }, + { name = "lru-dict" }, + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/7a/2cca04368b664d372473504615e37150466ecc796dff018504d8daf5de6d/atmst-0.0.6.tar.gz", hash = "sha256:bdc3ada3f234e28dada73f50cd40359534f99208436e831fe035f6fc7c7b188e", size = 18577, upload-time = "2024-12-21T11:37:20.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/ec/d743d809cadfaae230ebed08c00f63a68f1bfe82042f74ea98c51965ed8f/atmst-0.0.6-py3-none-any.whl", hash = "sha256:e63801225f31b602a3aacfe73360561fa5f488adb1a56d64e0746d462981ecff", size = 19744, upload-time = "2024-12-21T11:37:17.804Z" }, +] + +[[package]] +name = "cbrrr" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/e9/ccc7a90618e5d67da4bd7b98d9602d1ff791ee65792918659d161bac93a5/cbrrr-1.1.0.tar.gz", hash = "sha256:673e5bc27d213a549946886c22a4de8bbc5a091ebb8f0dee4317694a655f23f5", size = 18364, upload-time = "2026-03-28T22:56:15.309Z" } + +[[package]] +name = "lru-dict" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/0a/dec86efe38b350314c49a8d39ef01ba7cf8bbbef1d177646320eedea7159/lru_dict-1.4.1.tar.gz", hash = "sha256:cc518ff2d38cc7a8ab56f9a6ae557f91e2e1524b57ed8e598e97f45a2bd708fc", size = 13439, upload-time = "2025-11-02T10:02:13.548Z" } + +[[package]] +name = "more-itertools" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/24/e0acc4bf54cba50c1d432c70a72a3df96db4a321b2c4c68432a60759044f/more_itertools-11.0.1.tar.gz", hash = "sha256:fefaf25b7ab08f0b45fa9f1892cae93b9fc0089ef034d39213bce15f1cc9e199", size = 144739, upload-time = "2026-04-02T16:17:45.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl", hash = "sha256:eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d", size = 72182, upload-time = "2026-04-02T16:17:43.724Z" }, +] + +[[package]] +name = "mst-test-suite" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "atmst" }, + { name = "cbrrr" }, +] + +[package.metadata] +requires-dist = [ + { name = "atmst", specifier = ">=0.0.6" }, + { name = "cbrrr", specifier = ">=1.1.0" }, +] -- tangled.sh