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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. 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}")