kvgit 0.1.10__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {kvgit-0.1.10 → kvgit-0.2.0}/PKG-INFO +1 -1
  2. kvgit-0.2.0/kvgit/encoding.py +32 -0
  3. kvgit-0.2.0/kvgit/hamt.py +657 -0
  4. kvgit-0.2.0/kvgit/kv/base.py +131 -0
  5. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/composite.py +17 -10
  6. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/disk.py +30 -11
  7. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/indexeddb.py +26 -19
  8. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/memory.py +15 -7
  9. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/store.py +1 -1
  10. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/base.py +14 -5
  11. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/gp.py +14 -9
  12. kvgit-0.2.0/kvgit/versioned/keyset.py +234 -0
  13. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/kv.py +279 -208
  14. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/PKG-INFO +1 -1
  15. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/SOURCES.txt +3 -0
  16. {kvgit-0.1.10 → kvgit-0.2.0}/pyproject.toml +1 -1
  17. kvgit-0.2.0/tests/test_hamt.py +781 -0
  18. kvgit-0.2.0/tests/test_store_factory.py +99 -0
  19. kvgit-0.1.10/kvgit/encoding.py +0 -33
  20. kvgit-0.1.10/kvgit/kv/base.py +0 -62
  21. kvgit-0.1.10/tests/test_store_factory.py +0 -47
  22. {kvgit-0.1.10 → kvgit-0.2.0}/LICENSE +0 -0
  23. {kvgit-0.1.10 → kvgit-0.2.0}/README.md +0 -0
  24. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/__init__.py +0 -0
  25. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/content_types.py +0 -0
  26. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/errors.py +0 -0
  27. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/__init__.py +0 -0
  28. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/namespaced.py +0 -0
  29. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/py.typed +0 -0
  30. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/staged.py +0 -0
  31. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/__init__.py +0 -0
  32. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/helpers.py +0 -0
  33. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/merge.py +0 -0
  34. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/protocol.py +0 -0
  35. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/dependency_links.txt +0 -0
  36. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/requires.txt +0 -0
  37. {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/top_level.txt +0 -0
  38. {kvgit-0.1.10 → kvgit-0.2.0}/setup.cfg +0 -0
  39. {kvgit-0.1.10 → kvgit-0.2.0}/tests/test_content_types.py +0 -0
  40. {kvgit-0.1.10 → kvgit-0.2.0}/tests/test_namespaced.py +0 -0
  41. {kvgit-0.1.10 → kvgit-0.2.0}/tests/test_staged.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.1.10
3
+ Version: 0.2.0
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -0,0 +1,32 @@
1
+ """JSON byte helpers used by the versioned storage layer.
2
+
3
+ Thin wrappers around ``json.dumps``/``json.loads`` that fix the byte
4
+ encoding to a deterministic compact form. Centralized here so that
5
+ the on-disk wire format is defined in exactly one place — anyone
6
+ who needs to construct or inspect kvgit storage bytes (including
7
+ tests and tooling) goes through these.
8
+ """
9
+
10
+ import json
11
+
12
+
13
+ def dumps(obj) -> bytes:
14
+ """Serialize a JSON-safe Python object to bytes (compact, deterministic)."""
15
+ return json.dumps(obj, separators=(",", ":")).encode()
16
+
17
+
18
+ def loads(raw: bytes):
19
+ """Deserialize JSON bytes to a Python object."""
20
+ return json.loads(raw)
21
+
22
+
23
+ def safe_loads(raw: bytes):
24
+ """Like ``loads`` but returns None on any decode/parse error.
25
+
26
+ Useful when reading values from a store that may contain garbage
27
+ (corruption, partial writes, version skew).
28
+ """
29
+ try:
30
+ return json.loads(raw)
31
+ except Exception:
32
+ return None
@@ -0,0 +1,657 @@
1
+ """Content-addressable Hash Array Mapped Trie (HAMT).
2
+
3
+ A persistent ``str -> bytes`` map laid out in a ``KVStore`` so that
4
+ unchanged subtrees are shared across versions by hash equality.
5
+
6
+ Each node is JSON-serialized and stored under its SHA-256 hash. A
7
+ HAMT is identified by its root node hash; mutations produce a new
8
+ root and a set of new node bytes that the caller persists (atomically,
9
+ if desired) by writing them to the underlying store.
10
+
11
+ Layering: this module knows nothing about kvgit's commit semantics.
12
+ It is a generic content-addressable map. See ``kvgit/versioned/keyset.py``
13
+ for the kvgit-specific wrapper that adds blob/meta entry semantics.
14
+ """
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ from collections.abc import Iterable, Iterator, Mapping
20
+ from typing import NamedTuple
21
+
22
+ from .kv.base import KVStore
23
+
24
+ # SHA-256 hex digest length. Each nibble is consumed once as the trie
25
+ # is descended, so this also bounds the maximum trie depth.
26
+ _HASH_LEN = 64
27
+
28
+
29
+ def _node_bytes(node: dict) -> bytes:
30
+ """Serialize a node deterministically."""
31
+ return json.dumps(node, sort_keys=True, separators=(",", ":")).encode()
32
+
33
+
34
+ def _hash_bytes(b: bytes) -> str:
35
+ return hashlib.sha256(b).hexdigest()
36
+
37
+
38
+ def _key_hash(key: str) -> str:
39
+ return hashlib.sha256(key.encode()).hexdigest()
40
+
41
+
42
+ def _encode_value(value: bytes) -> str:
43
+ return base64.b64encode(value).decode("ascii")
44
+
45
+
46
+ def _decode_value(s: str) -> bytes:
47
+ return base64.b64decode(s.encode("ascii"))
48
+
49
+
50
+ # Canonical empty leaf. Computed once at module load. The empty HAMT
51
+ # is represented by this hash; the node itself is never written to the
52
+ # store. Reads short-circuit on EMPTY_HASH; writes materialize a fresh
53
+ # leaf when needed.
54
+ _EMPTY_LEAF = {"items": {}, "kind": "leaf"}
55
+ _EMPTY_LEAF_BYTES = _node_bytes(_EMPTY_LEAF)
56
+ EMPTY_HASH: str = _hash_bytes(_EMPTY_LEAF_BYTES)
57
+
58
+
59
+ class HamtDiff(NamedTuple):
60
+ """Structural diff between two HAMT roots."""
61
+
62
+ added: dict[str, bytes]
63
+ removed: dict[str, bytes]
64
+ modified: dict[str, tuple[bytes, bytes]] # key -> (old, new)
65
+
66
+
67
+ class Hamt:
68
+ """Immutable, content-addressable HAMT view over a ``KVStore``.
69
+
70
+ Mutating methods (``updated``) return a new ``Hamt`` whose
71
+ ``pending`` dict contains any new node bytes not yet flushed to
72
+ the store. Reads on the new view resolve through ``pending``
73
+ first, falling back to the store. Use ``flush()`` or ``commit()``
74
+ to persist, or merge ``pending`` into a larger write batch.
75
+
76
+ Two HAMTs with the same logical contents and the same
77
+ ``bucket_max`` will have the same root hash, regardless of how
78
+ they were constructed. This invariant is what enables structural
79
+ sharing across versions.
80
+
81
+ The ``bucket_max`` parameter controls how many entries fit in a
82
+ leaf before it splits into a branch. Larger buckets mean fewer
83
+ nodes but larger leaves; smaller buckets mean more nodes with
84
+ finer-grained sharing. Note: a HAMT built with one ``bucket_max``
85
+ will hash differently from the same logical contents built with
86
+ another ``bucket_max``.
87
+ """
88
+
89
+ store: KVStore
90
+ root: str
91
+ prefix: str
92
+ bucket_max: int
93
+ pending: dict[str, bytes] # prefixed key -> node bytes
94
+
95
+ def __init__(
96
+ self,
97
+ store: KVStore,
98
+ root: str = EMPTY_HASH,
99
+ *,
100
+ prefix: str = "hamt:",
101
+ bucket_max: int = 8,
102
+ pending: dict[str, bytes] | None = None,
103
+ ) -> None:
104
+ if bucket_max < 1:
105
+ raise ValueError(f"bucket_max must be >= 1, got {bucket_max}")
106
+ self.store = store
107
+ self.root = root
108
+ self.prefix = prefix
109
+ self.bucket_max = bucket_max
110
+ self.pending = pending if pending is not None else {}
111
+
112
+ # ---- internal helpers ----
113
+
114
+ def _load(
115
+ self, node_hash: str, pending: dict[str, bytes] | None = None
116
+ ) -> dict | None:
117
+ """Load a node by hash. Checks the supplied pending dict first
118
+ (used during in-progress batch updates), then ``self.pending``,
119
+ then the store."""
120
+ if node_hash == EMPTY_HASH:
121
+ return {"items": {}, "kind": "leaf"}
122
+ prefixed = self.prefix + node_hash
123
+ if pending is not None and prefixed in pending:
124
+ return json.loads(pending[prefixed])
125
+ if prefixed in self.pending:
126
+ return json.loads(self.pending[prefixed])
127
+ raw = self.store.get(prefixed)
128
+ if raw is None:
129
+ return None
130
+ return json.loads(raw)
131
+
132
+ def _store_leaf(
133
+ self, encoded_items: Mapping[str, str], pending: dict[str, bytes]
134
+ ) -> str:
135
+ """Materialize a leaf with the given (already-encoded) items."""
136
+ node = {"items": dict(encoded_items), "kind": "leaf"}
137
+ b = _node_bytes(node)
138
+ h = _hash_bytes(b)
139
+ pending[self.prefix + h] = b
140
+ return h
141
+
142
+ def _store_branch(
143
+ self, children: Mapping[str, str], pending: dict[str, bytes]
144
+ ) -> str:
145
+ """Materialize a branch with the given child hashes."""
146
+ node = {"children": dict(children), "kind": "branch"}
147
+ b = _node_bytes(node)
148
+ h = _hash_bytes(b)
149
+ pending[self.prefix + h] = b
150
+ return h
151
+
152
+ # ---- reads ----
153
+
154
+ def get(self, key: str) -> bytes | None:
155
+ """Look up a key. Returns None if absent."""
156
+ if self.root == EMPTY_HASH:
157
+ return None
158
+ kh = _key_hash(key)
159
+ node_hash = self.root
160
+ depth = 0
161
+ while True:
162
+ node = self._load(node_hash)
163
+ if node is None:
164
+ return None
165
+ if node["kind"] == "leaf":
166
+ encoded = node["items"].get(key)
167
+ if encoded is None:
168
+ return None
169
+ return _decode_value(encoded)
170
+ chunk = kh[depth]
171
+ if chunk not in node["children"]:
172
+ return None
173
+ node_hash = node["children"][chunk]
174
+ depth += 1
175
+
176
+ def __contains__(self, key: str) -> bool:
177
+ return self.get(key) is not None
178
+
179
+ def items(self) -> Iterator[tuple[str, bytes]]:
180
+ """Iterate over all (key, value) pairs lazily.
181
+
182
+ One store read per visited node. Use ``materialize()`` if
183
+ you want the whole map and the underlying store has
184
+ non-trivial per-call latency.
185
+ """
186
+ if self.root == EMPTY_HASH:
187
+ return
188
+ yield from self._items_from(self.root)
189
+
190
+ def _items_from(self, node_hash: str) -> Iterator[tuple[str, bytes]]:
191
+ node = self._load(node_hash)
192
+ if node is None:
193
+ return
194
+ if node["kind"] == "leaf":
195
+ for k, v in node["items"].items():
196
+ yield k, _decode_value(v)
197
+ else:
198
+ for child_hash in node["children"].values():
199
+ yield from self._items_from(child_hash)
200
+
201
+ def materialize(self) -> dict[str, bytes]:
202
+ """Walk the entire HAMT and return its contents as a dict.
203
+
204
+ Uses batched store reads — one ``get_many`` call per tree
205
+ level — so the cost is roughly O(log_branching N) round-trips
206
+ instead of one per node. For backends with non-trivial
207
+ per-call latency (Redis, IndexedDB) this is dramatically
208
+ faster than draining ``items()``.
209
+
210
+ For local backends (Memory, Disk) the speedup over ``items()``
211
+ is small because there's no per-call latency to amortize.
212
+ Use ``items()`` when you want laziness (e.g. to break out
213
+ early); use ``materialize()`` when you know you want the
214
+ whole map.
215
+
216
+ Equivalent to ``walk()[0]``.
217
+ """
218
+ return self.walk()[0]
219
+
220
+ def walk(self) -> tuple[dict[str, bytes], set[str]]:
221
+ """Walk the entire HAMT, returning (items, node_hashes).
222
+
223
+ Single batched BFS that collects both the key→value entries
224
+ and the set of every visited node hash. Equivalent to
225
+ calling ``materialize()`` and ``reachable_nodes()`` separately
226
+ but in one tree traversal — used by GC mark phases that want
227
+ both, like ``clean_orphans``.
228
+
229
+ Same batching properties as ``materialize()``: one
230
+ ``get_many`` call per tree level, O(log_branching N)
231
+ round-trips total.
232
+ """
233
+ if self.root == EMPTY_HASH:
234
+ return {}, set()
235
+
236
+ items: dict[str, bytes] = {}
237
+ nodes: set[str] = set()
238
+ current_level: list[str] = [self.root]
239
+
240
+ while current_level:
241
+ # Partition this level: nodes already in pending vs
242
+ # nodes that need to be fetched from the store.
243
+ cached_nodes: dict[str, dict] = {}
244
+ to_fetch: list[str] = []
245
+ for node_hash in current_level:
246
+ if node_hash == EMPTY_HASH:
247
+ continue
248
+ prefixed = self.prefix + node_hash
249
+ if prefixed in self.pending:
250
+ cached_nodes[node_hash] = json.loads(self.pending[prefixed])
251
+ else:
252
+ to_fetch.append(prefixed)
253
+
254
+ # Single batched fetch for everything at this level.
255
+ fetched: Mapping[str, bytes] = (
256
+ self.store.get_many(to_fetch) if to_fetch else {}
257
+ )
258
+
259
+ # Walk the level: leaves contribute entries, branches
260
+ # contribute the next level's node hashes. Track every
261
+ # node hash we successfully load.
262
+ next_level: list[str] = []
263
+ for node_hash in current_level:
264
+ if node_hash == EMPTY_HASH:
265
+ continue
266
+ if node_hash in cached_nodes:
267
+ node = cached_nodes[node_hash]
268
+ else:
269
+ raw = fetched.get(self.prefix + node_hash)
270
+ if raw is None:
271
+ continue # missing — skip rather than crash
272
+ node = json.loads(raw)
273
+
274
+ nodes.add(node_hash)
275
+
276
+ if node["kind"] == "leaf":
277
+ for k, v in node["items"].items():
278
+ items[k] = _decode_value(v)
279
+ else: # branch
280
+ next_level.extend(node["children"].values())
281
+
282
+ current_level = next_level
283
+
284
+ return items, nodes
285
+
286
+ def keys(self) -> Iterator[str]:
287
+ for k, _ in self.items():
288
+ yield k
289
+
290
+ def values(self) -> Iterator[bytes]:
291
+ for _, v in self.items():
292
+ yield v
293
+
294
+ def __iter__(self) -> Iterator[str]:
295
+ return self.keys()
296
+
297
+ def __len__(self) -> int:
298
+ """Total entry count. O(N) — walks the tree."""
299
+ return sum(1 for _ in self.items())
300
+
301
+ # ---- writes ----
302
+
303
+ def updated(
304
+ self,
305
+ updates: Mapping[str, bytes] | None = None,
306
+ removals: Iterable[str] = (),
307
+ ) -> tuple["Hamt", dict[str, bytes]]:
308
+ """Apply updates and removals.
309
+
310
+ Returns ``(new_hamt, pending_writes)`` where ``pending_writes``
311
+ is a dict of prefixed-key -> node-bytes ready to merge into a
312
+ store write batch. The returned ``new_hamt.pending`` is the
313
+ same dict, so reads on the new view work before flushing.
314
+ """
315
+ pending = dict(self.pending)
316
+ current_root = self.root
317
+
318
+ for key, value in (updates or {}).items():
319
+ current_root = self._insert(current_root, key, value, pending)
320
+ for key in removals:
321
+ current_root = self._delete(current_root, key, pending)
322
+
323
+ # Drop any pending node that's no longer reachable from the new root
324
+ # (intermediate nodes that were superseded by later updates).
325
+ reachable_pending = self._filter_pending(current_root, pending)
326
+
327
+ new_hamt = Hamt(
328
+ self.store,
329
+ current_root,
330
+ prefix=self.prefix,
331
+ bucket_max=self.bucket_max,
332
+ pending=reachable_pending,
333
+ )
334
+ return new_hamt, reachable_pending
335
+
336
+ def persist(
337
+ self,
338
+ updates: Mapping[str, bytes] | None = None,
339
+ removals: Iterable[str] = (),
340
+ ) -> "Hamt":
341
+ """Apply updates and write any new nodes to the store immediately.
342
+
343
+ Convenience for callers that don't need to batch writes with
344
+ other store operations. Returns a fresh ``Hamt`` with empty
345
+ pending. Distinct from ``Versioned.commit``: a HAMT has no
346
+ notion of a commit history — this just flushes node bytes.
347
+ """
348
+ new_hamt, pending = self.updated(updates, removals)
349
+ if pending:
350
+ self.store.set_many(pending)
351
+ return Hamt(
352
+ self.store,
353
+ new_hamt.root,
354
+ prefix=self.prefix,
355
+ bucket_max=self.bucket_max,
356
+ )
357
+
358
+ def flush(self) -> "Hamt":
359
+ """Persist any pending node writes. Returns a fresh ``Hamt``."""
360
+ if self.pending:
361
+ self.store.set_many(**self.pending)
362
+ return Hamt(
363
+ self.store,
364
+ self.root,
365
+ prefix=self.prefix,
366
+ bucket_max=self.bucket_max,
367
+ )
368
+
369
+ # ---- insert ----
370
+
371
+ def _insert(
372
+ self, root_hash: str, key: str, value: bytes, pending: dict[str, bytes]
373
+ ) -> str:
374
+ if root_hash == EMPTY_HASH:
375
+ return self._store_leaf({key: _encode_value(value)}, pending)
376
+ kh = _key_hash(key)
377
+ return self._insert_at(root_hash, 0, kh, key, value, pending)
378
+
379
+ def _insert_at(
380
+ self,
381
+ node_hash: str,
382
+ depth: int,
383
+ key_hash: str,
384
+ key: str,
385
+ value: bytes,
386
+ pending: dict[str, bytes],
387
+ ) -> str:
388
+ node = self._load(node_hash, pending)
389
+ if node is None:
390
+ # Dangling reference — treat as missing and materialize a leaf.
391
+ return self._store_leaf({key: _encode_value(value)}, pending)
392
+
393
+ if node["kind"] == "leaf":
394
+ encoded = _encode_value(value)
395
+ existing = node["items"].get(key)
396
+ if existing == encoded:
397
+ return node_hash # no-op
398
+ new_items = dict(node["items"])
399
+ new_items[key] = encoded
400
+ if len(new_items) <= self.bucket_max:
401
+ return self._store_leaf(new_items, pending)
402
+ # Overflow: split into a branch.
403
+ return self._split_leaf(new_items, depth, pending)
404
+
405
+ # branch
406
+ chunk = key_hash[depth]
407
+ existing_children = node["children"]
408
+ if chunk in existing_children:
409
+ new_child_hash = self._insert_at(
410
+ existing_children[chunk], depth + 1, key_hash, key, value, pending
411
+ )
412
+ if new_child_hash == existing_children[chunk]:
413
+ return node_hash
414
+ new_children = dict(existing_children)
415
+ new_children[chunk] = new_child_hash
416
+ else:
417
+ new_leaf_hash = self._store_leaf({key: _encode_value(value)}, pending)
418
+ new_children = dict(existing_children)
419
+ new_children[chunk] = new_leaf_hash
420
+ return self._store_branch(new_children, pending)
421
+
422
+ def _split_leaf(
423
+ self,
424
+ encoded_items: Mapping[str, str],
425
+ depth: int,
426
+ pending: dict[str, bytes],
427
+ ) -> str:
428
+ """Convert an overflowing leaf at ``depth`` into a branch."""
429
+ if depth >= _HASH_LEN:
430
+ # Hash exhausted — full SHA-256 collision. Astronomically rare;
431
+ # we just keep them in one (over-sized) leaf to avoid recursing
432
+ # forever.
433
+ return self._store_leaf(encoded_items, pending)
434
+
435
+ groups: dict[str, dict[str, str]] = {}
436
+ for k, v in encoded_items.items():
437
+ nibble = _key_hash(k)[depth]
438
+ groups.setdefault(nibble, {})[k] = v
439
+
440
+ if len(groups) == 1:
441
+ # All entries share the next nibble too — recurse deeper, then
442
+ # wrap in a single-child branch at this depth.
443
+ nibble, group_items = next(iter(groups.items()))
444
+ child_hash = self._split_leaf(group_items, depth + 1, pending)
445
+ return self._store_branch({nibble: child_hash}, pending)
446
+
447
+ children: dict[str, str] = {}
448
+ for nibble, group_items in groups.items():
449
+ if len(group_items) <= self.bucket_max:
450
+ children[nibble] = self._store_leaf(group_items, pending)
451
+ else:
452
+ children[nibble] = self._split_leaf(group_items, depth + 1, pending)
453
+ return self._store_branch(children, pending)
454
+
455
+ # ---- delete ----
456
+
457
+ def _delete(self, root_hash: str, key: str, pending: dict[str, bytes]) -> str:
458
+ if root_hash == EMPTY_HASH:
459
+ return EMPTY_HASH
460
+ kh = _key_hash(key)
461
+ result = self._delete_at(root_hash, 0, kh, key, pending)
462
+ return EMPTY_HASH if result is None else result
463
+
464
+ def _delete_at(
465
+ self,
466
+ node_hash: str,
467
+ depth: int,
468
+ key_hash: str,
469
+ key: str,
470
+ pending: dict[str, bytes],
471
+ ) -> str | None:
472
+ """Delete ``key`` from the subtree. Returns new node hash, or
473
+ None if the subtree is now empty."""
474
+ node = self._load(node_hash, pending)
475
+ if node is None:
476
+ return node_hash
477
+
478
+ if node["kind"] == "leaf":
479
+ if key not in node["items"]:
480
+ return node_hash
481
+ new_items = {k: v for k, v in node["items"].items() if k != key}
482
+ if not new_items:
483
+ return None
484
+ return self._store_leaf(new_items, pending)
485
+
486
+ # branch
487
+ chunk = key_hash[depth]
488
+ existing_children = node["children"]
489
+ if chunk not in existing_children:
490
+ return node_hash
491
+
492
+ new_child_hash = self._delete_at(
493
+ existing_children[chunk], depth + 1, key_hash, key, pending
494
+ )
495
+ if new_child_hash == existing_children[chunk]:
496
+ return node_hash
497
+
498
+ new_children = dict(existing_children)
499
+ if new_child_hash is None:
500
+ del new_children[chunk]
501
+ else:
502
+ new_children[chunk] = new_child_hash
503
+
504
+ if not new_children:
505
+ return None
506
+
507
+ # Canonicalization: if all children are leaves and their combined
508
+ # entries fit in a single bucket, collapse the whole branch into
509
+ # one leaf. This preserves the invariant that the same logical
510
+ # contents always produce the same root hash.
511
+ collapsed = self._try_collapse(new_children, pending)
512
+ if collapsed is not None:
513
+ return collapsed
514
+
515
+ return self._store_branch(new_children, pending)
516
+
517
+ def _try_collapse(
518
+ self, children: Mapping[str, str], pending: dict[str, bytes]
519
+ ) -> str | None:
520
+ """If every child is a leaf and the union of their entries fits
521
+ in ``bucket_max``, return the merged leaf hash. Otherwise None."""
522
+ merged: dict[str, str] = {}
523
+ for child_hash in children.values():
524
+ child = self._load(child_hash, pending)
525
+ if child is None or child["kind"] != "leaf":
526
+ return None
527
+ for k, v in child["items"].items():
528
+ if k not in merged:
529
+ merged[k] = v
530
+ if len(merged) > self.bucket_max:
531
+ return None
532
+ return self._store_leaf(merged, pending)
533
+
534
+ # ---- pending management ----
535
+
536
+ def _filter_pending(self, root: str, pending: dict[str, bytes]) -> dict[str, bytes]:
537
+ """Walk from ``root``, returning only pending entries that are
538
+ actually reachable. Drops orphans created by superseded inserts."""
539
+ if root == EMPTY_HASH:
540
+ return {}
541
+ result: dict[str, bytes] = {}
542
+ queue = [root]
543
+ while queue:
544
+ h = queue.pop()
545
+ prefixed = self.prefix + h
546
+ if prefixed in result or prefixed not in pending:
547
+ # Either already visited or already in the store — done with this branch.
548
+ continue
549
+ node_bytes = pending[prefixed]
550
+ result[prefixed] = node_bytes
551
+ node = json.loads(node_bytes)
552
+ if node["kind"] == "branch":
553
+ queue.extend(node["children"].values())
554
+ return result
555
+
556
+ # ---- structural ops ----
557
+
558
+ def reachable_nodes(self) -> Iterator[str]:
559
+ """Yield every node hash reachable from this root.
560
+
561
+ Used by GC layers to mark live nodes. Includes pending nodes,
562
+ so this works correctly on a Hamt that hasn't been flushed.
563
+ """
564
+ if self.root == EMPTY_HASH:
565
+ return
566
+ seen: set[str] = set()
567
+ queue = [self.root]
568
+ while queue:
569
+ h = queue.pop()
570
+ if h in seen:
571
+ continue
572
+ seen.add(h)
573
+ yield h
574
+ node = self._load(h)
575
+ if node is None:
576
+ continue
577
+ if node["kind"] == "branch":
578
+ queue.extend(node["children"].values())
579
+
580
+ def diff(self, other: "Hamt") -> HamtDiff:
581
+ """Structural diff against ``other``.
582
+
583
+ Cost is O(changes + log N), not O(N), because identical
584
+ subtrees (same node hash) are skipped wholesale. This is the
585
+ primary payoff of structural sharing.
586
+ """
587
+ added: dict[str, bytes] = {}
588
+ removed: dict[str, bytes] = {}
589
+ modified: dict[str, tuple[bytes, bytes]] = {}
590
+ self._diff_walk(self.root, other.root, other, added, removed, modified)
591
+ return HamtDiff(added=added, removed=removed, modified=modified)
592
+
593
+ def _diff_walk(
594
+ self,
595
+ a_hash: str,
596
+ b_hash: str,
597
+ other: "Hamt",
598
+ added: dict[str, bytes],
599
+ removed: dict[str, bytes],
600
+ modified: dict[str, tuple[bytes, bytes]],
601
+ ) -> None:
602
+ if a_hash == b_hash:
603
+ return # identical subtrees — skip entirely
604
+
605
+ if a_hash == EMPTY_HASH:
606
+ for k, v in other._items_from(b_hash):
607
+ added[k] = v
608
+ return
609
+ if b_hash == EMPTY_HASH:
610
+ for k, v in self._items_from(a_hash):
611
+ removed[k] = v
612
+ return
613
+
614
+ a_node = self._load(a_hash)
615
+ b_node = other._load(b_hash)
616
+ if a_node is None or b_node is None:
617
+ # Missing node — fall back to full walk for whichever side is intact.
618
+ if a_node is not None:
619
+ for k, v in self._items_from(a_hash):
620
+ removed[k] = v
621
+ if b_node is not None:
622
+ for k, v in other._items_from(b_hash):
623
+ added[k] = v
624
+ return
625
+
626
+ if a_node["kind"] == "leaf" and b_node["kind"] == "leaf":
627
+ a_items = {k: _decode_value(v) for k, v in a_node["items"].items()}
628
+ b_items = {k: _decode_value(v) for k, v in b_node["items"].items()}
629
+ for k, v in a_items.items():
630
+ if k not in b_items:
631
+ removed[k] = v
632
+ elif b_items[k] != v:
633
+ modified[k] = (v, b_items[k])
634
+ for k, v in b_items.items():
635
+ if k not in a_items:
636
+ added[k] = v
637
+ return
638
+
639
+ if a_node["kind"] == "branch" and b_node["kind"] == "branch":
640
+ chunks = set(a_node["children"]) | set(b_node["children"])
641
+ for chunk in chunks:
642
+ a_child = a_node["children"].get(chunk, EMPTY_HASH)
643
+ b_child = b_node["children"].get(chunk, EMPTY_HASH)
644
+ self._diff_walk(a_child, b_child, other, added, removed, modified)
645
+ return
646
+
647
+ # Mixed kinds (one leaf, one branch). Walk both fully and reconcile.
648
+ a_items = dict(self._items_from(a_hash))
649
+ b_items = dict(other._items_from(b_hash))
650
+ for k, v in a_items.items():
651
+ if k not in b_items:
652
+ removed[k] = v
653
+ elif b_items[k] != v:
654
+ modified[k] = (v, b_items[k])
655
+ for k, v in b_items.items():
656
+ if k not in a_items:
657
+ added[k] = v