lorebase-core 0.2.3__py3-none-any.whl
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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tree diff by simultaneous walk, skipping equal subtrees (design 6.7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from lorebase_core.tree.node import (
|
|
9
|
+
BranchNode,
|
|
10
|
+
LeafNode,
|
|
11
|
+
NodeFormatError,
|
|
12
|
+
PartitionRef,
|
|
13
|
+
key_hash,
|
|
14
|
+
)
|
|
15
|
+
from lorebase_core.tree.root import Tree
|
|
16
|
+
from lorebase_core.tree.store import NodeStore, read_node
|
|
17
|
+
|
|
18
|
+
__all__ = ["DiffEntry", "tree_diff"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DiffEntry(NamedTuple):
|
|
22
|
+
"""One differing key: ``hash_a`` is its value in tree A, ``hash_b`` in tree B.
|
|
23
|
+
|
|
24
|
+
``None`` means the key is absent from that tree.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
partition: str
|
|
28
|
+
key: str
|
|
29
|
+
hash_a: bytes | None
|
|
30
|
+
hash_b: bytes | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_Entry = tuple[bytes, str, bytes]
|
|
34
|
+
# A side of the walk: a stored node, entries already in memory, or nothing.
|
|
35
|
+
_Side = bytes | list[_Entry] | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tree_diff(store: NodeStore, root_a: bytes | None, root_b: bytes | None) -> Iterator[DiffEntry]:
|
|
39
|
+
"""Stream the differences between two trees.
|
|
40
|
+
|
|
41
|
+
Partitions and subtrees with equal hashes are skipped without being read,
|
|
42
|
+
so the cost is proportional to the differing leaves. Output is ordered by
|
|
43
|
+
partition id (UTF-8 bytes), then by key hash. ``None`` stands for the empty tree.
|
|
44
|
+
"""
|
|
45
|
+
if root_a == root_b:
|
|
46
|
+
return
|
|
47
|
+
partitions_a = _partitions(store, root_a)
|
|
48
|
+
partitions_b = _partitions(store, root_b)
|
|
49
|
+
for partition in sorted(partitions_a.keys() | partitions_b.keys(), key=_utf8):
|
|
50
|
+
ref_a = partitions_a.get(partition)
|
|
51
|
+
ref_b = partitions_b.get(partition)
|
|
52
|
+
node_a = None if ref_a is None else ref_a.root
|
|
53
|
+
node_b = None if ref_b is None else ref_b.root
|
|
54
|
+
if node_a != node_b:
|
|
55
|
+
yield from _diff(store, partition, node_a, node_b, 0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _partitions(store: NodeStore, root: bytes | None) -> dict[str, PartitionRef]:
|
|
59
|
+
return {} if root is None else dict(Tree.load(store, root).partitions)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _diff(store: NodeStore, partition: str, a: _Side, b: _Side, depth: int) -> Iterator[DiffEntry]:
|
|
63
|
+
if isinstance(a, bytes) and a == b:
|
|
64
|
+
return
|
|
65
|
+
children_a = _expand(store, a, depth)
|
|
66
|
+
children_b = _expand(store, b, depth)
|
|
67
|
+
if isinstance(children_a, list) and isinstance(children_b, list):
|
|
68
|
+
yield from _diff_entries(partition, children_a, children_b)
|
|
69
|
+
return
|
|
70
|
+
buckets_a = _buckets(children_a, depth)
|
|
71
|
+
buckets_b = _buckets(children_b, depth)
|
|
72
|
+
for index in sorted(buckets_a.keys() | buckets_b.keys()):
|
|
73
|
+
yield from _diff(store, partition, buckets_a.get(index), buckets_b.get(index), depth + 1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _expand(store: NodeStore, side: _Side, depth: int) -> dict[int, bytes] | list[_Entry]:
|
|
77
|
+
"""Load a side: a branch becomes its children by byte, a leaf its entries."""
|
|
78
|
+
if side is None:
|
|
79
|
+
return []
|
|
80
|
+
if isinstance(side, list):
|
|
81
|
+
return side
|
|
82
|
+
node = read_node(store, side)
|
|
83
|
+
if isinstance(node, LeafNode) and node.depth == depth:
|
|
84
|
+
return [(key_hash(key), key, value) for key, value in node.entries]
|
|
85
|
+
if isinstance(node, BranchNode) and node.depth == depth:
|
|
86
|
+
return dict(node.children)
|
|
87
|
+
raise NodeFormatError(f"expected a partition node at depth {depth}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _buckets(side: dict[int, bytes] | list[_Entry], depth: int) -> dict[int, _Side]:
|
|
91
|
+
buckets: dict[int, _Side] = {}
|
|
92
|
+
if isinstance(side, dict):
|
|
93
|
+
buckets.update(side)
|
|
94
|
+
return buckets
|
|
95
|
+
for entry in side:
|
|
96
|
+
bucket = buckets.setdefault(entry[0][depth], [])
|
|
97
|
+
assert isinstance(bucket, list)
|
|
98
|
+
bucket.append(entry)
|
|
99
|
+
return buckets
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _diff_entries(partition: str, a: list[_Entry], b: list[_Entry]) -> Iterator[DiffEntry]:
|
|
103
|
+
i = j = 0
|
|
104
|
+
while i < len(a) or j < len(b):
|
|
105
|
+
if j == len(b) or (i < len(a) and a[i][0] < b[j][0]):
|
|
106
|
+
yield DiffEntry(partition, a[i][1], a[i][2], None)
|
|
107
|
+
i += 1
|
|
108
|
+
elif i == len(a) or b[j][0] < a[i][0]:
|
|
109
|
+
yield DiffEntry(partition, b[j][1], None, b[j][2])
|
|
110
|
+
j += 1
|
|
111
|
+
else:
|
|
112
|
+
if a[i][2] != b[j][2]:
|
|
113
|
+
yield DiffEntry(partition, a[i][1], a[i][2], b[j][2])
|
|
114
|
+
i += 1
|
|
115
|
+
j += 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _utf8(text: str) -> bytes:
|
|
119
|
+
return text.encode("utf-8")
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Partitioned canonical HAMT: lookups, batch updates and iteration (design 6.2).
|
|
2
|
+
|
|
3
|
+
Inside a partition an entry ``(element_key, object_hash)`` is placed by
|
|
4
|
+
``h = sha256(element_key)``. A node at depth ``d`` is a leaf iff its subtree
|
|
5
|
+
holds at most ``L`` entries; otherwise it is a branch whose children are
|
|
6
|
+
selected by ``h[d]``. The shape depends only on the entry set, so equal sets
|
|
7
|
+
always produce equal root hashes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import NamedTuple
|
|
15
|
+
|
|
16
|
+
from lorebase_core.canonical import HASH_SIZE, node_hash
|
|
17
|
+
from lorebase_core.tree.node import (
|
|
18
|
+
BranchNode,
|
|
19
|
+
LeafNode,
|
|
20
|
+
NodeFormatError,
|
|
21
|
+
PartitionRef,
|
|
22
|
+
encode_node,
|
|
23
|
+
key_hash,
|
|
24
|
+
)
|
|
25
|
+
from lorebase_core.tree.params import TreeParams
|
|
26
|
+
from lorebase_core.tree.root import Tree, write_root
|
|
27
|
+
from lorebase_core.tree.store import NodeStore, read_node
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"TreeChange",
|
|
31
|
+
"empty_tree",
|
|
32
|
+
"iter_partition",
|
|
33
|
+
"tree_apply",
|
|
34
|
+
"tree_count",
|
|
35
|
+
"tree_entries",
|
|
36
|
+
"tree_get",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_DEFAULT_PARAMS = TreeParams()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TreeChange(NamedTuple):
|
|
43
|
+
"""Set ``key`` in ``partition`` to ``new_hash``; ``None`` removes it."""
|
|
44
|
+
|
|
45
|
+
partition: str
|
|
46
|
+
key: str
|
|
47
|
+
new_hash: bytes | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# (key hash, element key, object hash), ordered by key hash.
|
|
51
|
+
_Entry = tuple[bytes, str, bytes]
|
|
52
|
+
# (key hash, element key, new object hash or None for removal), ordered by key hash.
|
|
53
|
+
_Change = tuple[bytes, str, bytes | None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class _Small:
|
|
58
|
+
"""A subtree small enough to be a leaf, not yet written. ``origin`` is its unchanged node."""
|
|
59
|
+
|
|
60
|
+
entries: list[_Entry]
|
|
61
|
+
origin: bytes | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def count(self) -> int:
|
|
65
|
+
return len(self.entries)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class _Large:
|
|
70
|
+
"""A subtree over the leaf threshold, already written as a branch node."""
|
|
71
|
+
|
|
72
|
+
node: bytes
|
|
73
|
+
count: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_Subtree = _Small | _Large
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def empty_tree(store: NodeStore) -> bytes:
|
|
80
|
+
"""Store the empty tree root and return its hash."""
|
|
81
|
+
return write_root(store, {})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tree_get(store: NodeStore, root: bytes | None, partition: str, key: str) -> bytes | None:
|
|
85
|
+
"""Return the object hash stored under ``key`` in ``partition``, or ``None``."""
|
|
86
|
+
if root is None:
|
|
87
|
+
return None
|
|
88
|
+
ref = Tree.load(store, root).partitions.get(partition)
|
|
89
|
+
if ref is None:
|
|
90
|
+
return None
|
|
91
|
+
target = key_hash(key)
|
|
92
|
+
current = ref.root
|
|
93
|
+
depth = 0
|
|
94
|
+
while True:
|
|
95
|
+
node = read_node(store, current)
|
|
96
|
+
_check_partition_node(node, depth)
|
|
97
|
+
if isinstance(node, LeafNode):
|
|
98
|
+
for entry_key, value in node.entries:
|
|
99
|
+
if entry_key == key:
|
|
100
|
+
return value
|
|
101
|
+
return None
|
|
102
|
+
assert isinstance(node, BranchNode)
|
|
103
|
+
child = _child(node, target[depth])
|
|
104
|
+
if child is None:
|
|
105
|
+
return None
|
|
106
|
+
current = child
|
|
107
|
+
depth += 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tree_count(store: NodeStore, root: bytes | None, partition: str) -> int:
|
|
111
|
+
"""Number of entries in ``partition``, read from the root in O(1)."""
|
|
112
|
+
if root is None:
|
|
113
|
+
return 0
|
|
114
|
+
ref = Tree.load(store, root).partitions.get(partition)
|
|
115
|
+
return 0 if ref is None else ref.count
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tree_entries(
|
|
119
|
+
store: NodeStore, root: bytes | None, partition: str
|
|
120
|
+
) -> Iterator[tuple[str, bytes]]:
|
|
121
|
+
"""Iterate ``(element_key, object_hash)`` of ``partition`` in key-hash order."""
|
|
122
|
+
if root is None:
|
|
123
|
+
return
|
|
124
|
+
ref = Tree.load(store, root).partitions.get(partition)
|
|
125
|
+
if ref is None:
|
|
126
|
+
return
|
|
127
|
+
for _, key, value in iter_partition(store, ref.root, 0):
|
|
128
|
+
yield key, value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def iter_partition(store: NodeStore, node: bytes, depth: int) -> Iterator[_Entry]:
|
|
132
|
+
"""Iterate ``(key_hash, key, object_hash)`` of the subtree at ``node`` in key-hash order."""
|
|
133
|
+
decoded = read_node(store, node)
|
|
134
|
+
_check_partition_node(decoded, depth)
|
|
135
|
+
if isinstance(decoded, LeafNode):
|
|
136
|
+
for key, value in decoded.entries:
|
|
137
|
+
yield key_hash(key), key, value
|
|
138
|
+
return
|
|
139
|
+
assert isinstance(decoded, BranchNode)
|
|
140
|
+
for _, child in decoded.children:
|
|
141
|
+
yield from iter_partition(store, child, depth + 1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tree_apply(
|
|
145
|
+
store: NodeStore,
|
|
146
|
+
root: bytes | None,
|
|
147
|
+
changes: Iterable[TreeChange | tuple[str, str, bytes | None]],
|
|
148
|
+
params: TreeParams = _DEFAULT_PARAMS,
|
|
149
|
+
) -> bytes:
|
|
150
|
+
"""Apply a batch of changes by path copying and return the new root hash.
|
|
151
|
+
|
|
152
|
+
``root=None`` stands for the empty tree. New nodes are written to ``store``;
|
|
153
|
+
unchanged subtrees are shared. Removing an absent key or setting a key to
|
|
154
|
+
its current hash is a no-op. A key may appear at most once per batch.
|
|
155
|
+
"""
|
|
156
|
+
grouped = _group_changes(changes)
|
|
157
|
+
if root is None:
|
|
158
|
+
partitions: dict[str, PartitionRef] = {}
|
|
159
|
+
else:
|
|
160
|
+
partitions = dict(Tree.load(store, root).partitions)
|
|
161
|
+
if not grouped:
|
|
162
|
+
return root
|
|
163
|
+
touched = False
|
|
164
|
+
for partition, partition_changes in grouped.items():
|
|
165
|
+
ref = partitions.get(partition)
|
|
166
|
+
subtree, _ = _apply(store, None if ref is None else ref.root, 0, partition_changes, params)
|
|
167
|
+
new_root = _materialize(store, subtree, 0)
|
|
168
|
+
if new_root is None:
|
|
169
|
+
touched |= partitions.pop(partition, None) is not None
|
|
170
|
+
continue
|
|
171
|
+
new_ref = PartitionRef(new_root, subtree.count)
|
|
172
|
+
if new_ref != ref:
|
|
173
|
+
partitions[partition] = new_ref
|
|
174
|
+
touched = True
|
|
175
|
+
if root is not None and not touched:
|
|
176
|
+
return root
|
|
177
|
+
return write_root(store, partitions)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _group_changes(
|
|
181
|
+
changes: Iterable[TreeChange | tuple[str, str, bytes | None]],
|
|
182
|
+
) -> dict[str, list[_Change]]:
|
|
183
|
+
grouped: dict[str, dict[str, bytes | None]] = {}
|
|
184
|
+
for partition, key, new_hash in changes:
|
|
185
|
+
if not isinstance(partition, str) or not partition:
|
|
186
|
+
raise ValueError("partition ids are non-empty strings")
|
|
187
|
+
if not isinstance(key, str):
|
|
188
|
+
raise TypeError("element keys are strings")
|
|
189
|
+
if new_hash is not None and len(new_hash) != HASH_SIZE:
|
|
190
|
+
raise ValueError(f"object hashes are {HASH_SIZE} bytes")
|
|
191
|
+
keys = grouped.setdefault(partition, {})
|
|
192
|
+
if key in keys:
|
|
193
|
+
raise ValueError(f"duplicate change for key {key!r} in partition {partition!r}")
|
|
194
|
+
keys[key] = new_hash
|
|
195
|
+
return {
|
|
196
|
+
partition: sorted((key_hash(k), k, v) for k, v in keys.items())
|
|
197
|
+
for partition, keys in grouped.items()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _apply(
|
|
202
|
+
store: NodeStore,
|
|
203
|
+
node: bytes | None,
|
|
204
|
+
depth: int,
|
|
205
|
+
changes: list[_Change],
|
|
206
|
+
params: TreeParams,
|
|
207
|
+
) -> tuple[_Subtree, int]:
|
|
208
|
+
"""Apply sorted ``changes`` to the subtree at ``node``; return it and its old count."""
|
|
209
|
+
if node is None:
|
|
210
|
+
return _finish(store, _merge([], changes), depth, params), 0
|
|
211
|
+
decoded = read_node(store, node)
|
|
212
|
+
_check_partition_node(decoded, depth)
|
|
213
|
+
if isinstance(decoded, LeafNode):
|
|
214
|
+
entries = [(key_hash(k), k, v) for k, v in decoded.entries]
|
|
215
|
+
merged = _merge(entries, changes)
|
|
216
|
+
if merged == entries:
|
|
217
|
+
return _Small(entries, origin=node), decoded.count
|
|
218
|
+
return _finish(store, merged, depth, params), decoded.count
|
|
219
|
+
assert isinstance(decoded, BranchNode)
|
|
220
|
+
children = dict(decoded.children)
|
|
221
|
+
results: dict[int, _Subtree] = {}
|
|
222
|
+
old_total = new_total = 0
|
|
223
|
+
for index, group in _by_byte(changes, depth).items():
|
|
224
|
+
subtree, old = _apply(store, children.get(index), depth + 1, group, params)
|
|
225
|
+
results[index] = subtree
|
|
226
|
+
old_total += old
|
|
227
|
+
new_total += subtree.count
|
|
228
|
+
count = decoded.count - old_total + new_total
|
|
229
|
+
if count > params.leaf_size:
|
|
230
|
+
changed = False
|
|
231
|
+
for index, subtree in results.items():
|
|
232
|
+
child = _materialize(store, subtree, depth + 1)
|
|
233
|
+
if child == children.get(index):
|
|
234
|
+
continue
|
|
235
|
+
changed = True
|
|
236
|
+
if child is None:
|
|
237
|
+
del children[index]
|
|
238
|
+
else:
|
|
239
|
+
children[index] = child
|
|
240
|
+
if not changed:
|
|
241
|
+
return _Large(node, count), decoded.count
|
|
242
|
+
return _Large(_write_branch(store, depth, children, count), count), decoded.count
|
|
243
|
+
# The subtree shrank to the leaf threshold: collapse it into one leaf.
|
|
244
|
+
entries = []
|
|
245
|
+
for index in sorted(children.keys() | results.keys()):
|
|
246
|
+
subtree_result = results.get(index)
|
|
247
|
+
if subtree_result is None:
|
|
248
|
+
entries.extend(iter_partition(store, children[index], depth + 1))
|
|
249
|
+
else:
|
|
250
|
+
assert isinstance(subtree_result, _Small)
|
|
251
|
+
entries.extend(subtree_result.entries)
|
|
252
|
+
return _Small(entries), decoded.count
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _merge(entries: list[_Entry], changes: list[_Change]) -> list[_Entry]:
|
|
256
|
+
"""Merge sorted changes into sorted entries."""
|
|
257
|
+
result: list[_Entry] = []
|
|
258
|
+
i = 0
|
|
259
|
+
for change_hash, change_key, new_value in changes:
|
|
260
|
+
while i < len(entries) and entries[i][0] < change_hash:
|
|
261
|
+
result.append(entries[i])
|
|
262
|
+
i += 1
|
|
263
|
+
if i < len(entries) and entries[i][0] == change_hash:
|
|
264
|
+
if entries[i][1] != change_key:
|
|
265
|
+
raise NodeFormatError("distinct keys with equal key hash")
|
|
266
|
+
i += 1
|
|
267
|
+
if new_value is not None:
|
|
268
|
+
result.append((change_hash, change_key, new_value))
|
|
269
|
+
result.extend(entries[i:])
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _finish(store: NodeStore, entries: list[_Entry], depth: int, params: TreeParams) -> _Subtree:
|
|
274
|
+
if len(entries) <= params.leaf_size:
|
|
275
|
+
return _Small(entries)
|
|
276
|
+
return _Large(_build(store, entries, depth, params), len(entries))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _build(store: NodeStore, entries: list[_Entry], depth: int, params: TreeParams) -> bytes:
|
|
280
|
+
"""Write the canonical subtree for more than ``L`` sorted entries; return its hash."""
|
|
281
|
+
if depth >= HASH_SIZE:
|
|
282
|
+
raise NodeFormatError("more entries than the leaf size share a full key hash")
|
|
283
|
+
children: dict[int, bytes] = {}
|
|
284
|
+
for index, group in _by_byte(entries, depth).items():
|
|
285
|
+
if len(group) <= params.leaf_size:
|
|
286
|
+
children[index] = _write_leaf(store, depth + 1, group)
|
|
287
|
+
else:
|
|
288
|
+
children[index] = _build(store, group, depth + 1, params)
|
|
289
|
+
return _write_branch(store, depth, children, len(entries))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _materialize(store: NodeStore, subtree: _Subtree, depth: int) -> bytes | None:
|
|
293
|
+
"""Return the node hash of a subtree result, writing a leaf when needed."""
|
|
294
|
+
if isinstance(subtree, _Large):
|
|
295
|
+
return subtree.node
|
|
296
|
+
if not subtree.entries:
|
|
297
|
+
return None
|
|
298
|
+
if subtree.origin is not None:
|
|
299
|
+
return subtree.origin
|
|
300
|
+
return _write_leaf(store, depth, subtree.entries)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _write_leaf(store: NodeStore, depth: int, entries: list[_Entry]) -> bytes:
|
|
304
|
+
body = encode_node(LeafNode(depth, tuple((key, value) for _, key, value in entries)))
|
|
305
|
+
digest = node_hash(body)
|
|
306
|
+
store.put(digest, body)
|
|
307
|
+
return digest
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _write_branch(store: NodeStore, depth: int, children: dict[int, bytes], count: int) -> bytes:
|
|
311
|
+
body = encode_node(BranchNode(depth, tuple(sorted(children.items())), count))
|
|
312
|
+
digest = node_hash(body)
|
|
313
|
+
store.put(digest, body)
|
|
314
|
+
return digest
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _by_byte[T: tuple[bytes, str, bytes | None]](items: list[T], depth: int) -> dict[int, list[T]]:
|
|
318
|
+
groups: dict[int, list[T]] = {}
|
|
319
|
+
for item in items:
|
|
320
|
+
groups.setdefault(item[0][depth], []).append(item)
|
|
321
|
+
return groups
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _child(node: BranchNode, index: int) -> bytes | None:
|
|
325
|
+
for child_index, child in node.children:
|
|
326
|
+
if child_index == index:
|
|
327
|
+
return child
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _check_partition_node(node: object, depth: int) -> None:
|
|
332
|
+
if not isinstance(node, LeafNode | BranchNode) or node.depth != depth:
|
|
333
|
+
raise NodeFormatError(f"expected a partition node at depth {depth}")
|