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,476 @@
1
+ """Three-way merge engine (design 6.8 to 6.10): orchestration and resolution application.
2
+
3
+ ``merge`` finds the merge base (a virtual one for criss-cross histories, AC-13), merges the
4
+ schema, then the data under the merged schema, then checks consistency. The outcome is a
5
+ ``MergedTree`` ready for the commit pipeline, or a ``MergeAttempt`` listing Conflicts.
6
+ ``apply_resolutions`` re-runs the merge with the resolver's decisions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from collections.abc import Callable, Iterable, Mapping
13
+ from dataclasses import dataclass, field, replace
14
+ from enum import StrEnum
15
+
16
+ from lorebase_core.merge.conflicts import (
17
+ AutoResolution,
18
+ Conflict,
19
+ ConflictKind,
20
+ Resolution,
21
+ ResolutionChoice,
22
+ ResolutionError,
23
+ Side,
24
+ UnresolvedConflictsError,
25
+ )
26
+ from lorebase_core.merge.consistency import check_consistency
27
+ from lorebase_core.merge.data_merge import DataMerge, SideChanges, merge_data, side_changes
28
+ from lorebase_core.merge.index import TreeIndexView
29
+ from lorebase_core.merge.objects import (
30
+ ObjectStore,
31
+ SchemaLoader,
32
+ data_object_type,
33
+ tree_schema_loader,
34
+ )
35
+ from lorebase_core.merge.policies import ChangeStamp, MergePolicy
36
+ from lorebase_core.merge.resolve import emit_groups
37
+ from lorebase_core.merge.schema_merge import SchemaMerge, known_static_issues, merge_schema
38
+ from lorebase_core.merge.state import SIDES, Context, Directives, check_choice
39
+ from lorebase_core.revision import (
40
+ ChangesetEntry,
41
+ MergeStatus,
42
+ RevisionGraph,
43
+ RevisionStats,
44
+ changeset_between,
45
+ changeset_stats,
46
+ merge_base,
47
+ )
48
+ from lorebase_core.tree import (
49
+ PARTITION_SCHEMA,
50
+ NodeStore,
51
+ TreeChange,
52
+ TreeParams,
53
+ tree_apply,
54
+ tree_diff,
55
+ tree_entries,
56
+ )
57
+ from lorebase_core.validate import IndexView, SchemaRules
58
+
59
+ __all__ = [
60
+ "IndexViewFactory",
61
+ "LineageLoader",
62
+ "MergeAttempt",
63
+ "MergeInput",
64
+ "MergeOutcome",
65
+ "MergeResult",
66
+ "MergedTree",
67
+ "ProvenanceLoader",
68
+ "apply_resolutions",
69
+ "merge",
70
+ ]
71
+
72
+ IndexViewFactory = Callable[[bytes], IndexView]
73
+ """Returns the ``IndexView`` of the target head, given the target head Revision id."""
74
+
75
+ ProvenanceLoader = Callable[[bytes, str, str], Mapping[str, str]]
76
+ """``(revision id, partition, key) -> {attribute id: provenance id}`` of an element."""
77
+
78
+ LineageLoader = Callable[[bytes, str, str], Mapping[str, ChangeStamp]]
79
+ """``(revision id, partition, key) -> {attribute id: change stamp}``: the provenance stamp of
80
+ the last change of each Attribute of an element at a Revision. Read by the ``authority``
81
+ and ``newest_wins`` policies for ``ours`` and ``theirs``; an element without Lineage maps to
82
+ an empty mapping."""
83
+
84
+ _VIRTUAL_LABEL = b"lb1\x00virtual-base\x00"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class MergeInput:
89
+ """Everything a merge reads. ``theirs`` (source) is merged into ``ours`` (target).
90
+
91
+ ``base`` overrides the merge base search. Without ``schema_loader`` schemas are read from
92
+ the ``schema`` partition; without ``index_view`` the target head is read from its tree.
93
+ ``resolutions`` are matched to Conflicts by fingerprint. ``schema_rules`` are the options
94
+ of the static checks of the merged schema (the defaults when unset). ``lineage`` gives the
95
+ ``authority`` and ``newest_wins`` policies the provenance entries of both sides; without
96
+ it those policies never decide.
97
+ """
98
+
99
+ store: NodeStore
100
+ objects: ObjectStore
101
+ graph: RevisionGraph
102
+ revision_root: Callable[[bytes], bytes]
103
+ ours: bytes
104
+ theirs: bytes
105
+ base: bytes | None = None
106
+ schema_loader: SchemaLoader | None = None
107
+ index_view: IndexViewFactory | None = None
108
+ provenance: ProvenanceLoader | None = None
109
+ lineage: LineageLoader | None = None
110
+ policy: MergePolicy = field(default_factory=MergePolicy)
111
+ tree_params: TreeParams = field(default_factory=TreeParams)
112
+ resolutions: tuple[Resolution, ...] = ()
113
+ schema_rules: SchemaRules | None = None
114
+
115
+
116
+ class MergeResult(StrEnum):
117
+ UP_TO_DATE = "up_to_date"
118
+ """Theirs is already contained in ours: the target does not change."""
119
+ FAST_FORWARD = "fast_forward"
120
+ """The target moves to theirs."""
121
+ MERGED = "merged"
122
+ """A merge Revision with parents ``(ours, theirs)`` is to be written."""
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class MergedTree:
127
+ """A clean merge: the new tree and its changeset against ours (the first parent)."""
128
+
129
+ result: MergeResult
130
+ root: bytes
131
+ ours: bytes
132
+ theirs: bytes
133
+ base: bytes | None
134
+ virtual_base: bool
135
+ parents: tuple[bytes, ...]
136
+ changes: tuple[ChangesetEntry, ...]
137
+ stats: RevisionStats
138
+ auto_resolutions: tuple[AutoResolution, ...] = ()
139
+ resolutions: tuple[Resolution, ...] = ()
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class MergeAttempt:
144
+ """A merge with Conflicts, kept until every Conflict is resolved."""
145
+
146
+ base: bytes | None
147
+ ours: bytes
148
+ theirs: bytes
149
+ virtual_base: bool
150
+ conflicts: tuple[Conflict, ...]
151
+ auto_resolutions: tuple[AutoResolution, ...] = ()
152
+ resolutions: tuple[Resolution, ...] = ()
153
+ input: MergeInput | None = field(default=None, repr=False, compare=False)
154
+ #: Full member keys of grouped Conflicts, by fingerprint.
155
+ members: Mapping[str, tuple[str, ...]] = field(default_factory=dict, repr=False)
156
+
157
+
158
+ MergeOutcome = MergedTree | MergeAttempt
159
+
160
+
161
+ def merge(merge_input: MergeInput) -> MergeOutcome:
162
+ """Merge ``theirs`` into ``ours`` (design 6.8 steps 0 to 5)."""
163
+ return _Engine(merge_input).run()
164
+
165
+
166
+ def apply_resolutions(attempt: MergeAttempt, resolutions: Iterable[Resolution]) -> MergeOutcome:
167
+ """Apply resolutions to an attempt and merge again.
168
+
169
+ Every Conflict of the attempt needs a resolution with an accepted choice; grouped
170
+ Conflicts resolved with per-member values need a value for every member. The merge is
171
+ recomputed, so resolutions can surface new Conflicts.
172
+ """
173
+ if attempt.input is None:
174
+ raise ResolutionError("the merge attempt does not carry its merge input")
175
+ conflicts = {c.fingerprint: c for c in attempt.conflicts}
176
+ given: dict[str, Resolution] = {}
177
+ for resolution in resolutions:
178
+ conflict = conflicts.get(resolution.fingerprint)
179
+ if conflict is None:
180
+ raise ResolutionError(f"no conflict with fingerprint {resolution.fingerprint}")
181
+ check_choice(conflict, resolution)
182
+ _check_members(attempt, conflict, resolution)
183
+ given[resolution.fingerprint] = resolution
184
+ unresolved = conflicts.keys() - given.keys()
185
+ if unresolved:
186
+ raise UnresolvedConflictsError(unresolved)
187
+ combined = {r.fingerprint: r for r in attempt.resolutions}
188
+ combined.update(given)
189
+ return merge(replace(attempt.input, resolutions=tuple(combined.values())))
190
+
191
+
192
+ def _check_members(attempt: MergeAttempt, conflict: Conflict, resolution: Resolution) -> None:
193
+ if resolution.choice is not ResolutionChoice.CUSTOM or resolution.values is None:
194
+ return
195
+ if conflict.kind in (ConflictKind.UNIQUENESS, ConflictKind.REQUIRED_RELATION):
196
+ # A new value for some of the Records, or Links for some of them, can be enough.
197
+ return
198
+ members = attempt.members.get(conflict.fingerprint, ())
199
+ missing = [m for m in members if m not in resolution.values]
200
+ if missing and resolution.value is None:
201
+ raise ResolutionError(
202
+ f"custom values of {conflict.fingerprint} miss {len(missing)} member(s), "
203
+ f"for example {missing[0]!r}"
204
+ )
205
+
206
+
207
+ @dataclass
208
+ class _Run:
209
+ ctx: Context
210
+ schema: SchemaMerge
211
+ data: DataMerge
212
+
213
+
214
+ class _Engine:
215
+ def __init__(self, merge_input: MergeInput) -> None:
216
+ self.input = merge_input
217
+ self.store = merge_input.store
218
+ self.loader = merge_input.schema_loader or tree_schema_loader(
219
+ merge_input.store, merge_input.objects
220
+ )
221
+ self.virtual_roots: dict[bytes, bytes] = {}
222
+ self.lineage_cache: dict[tuple[Side, str, str], Mapping[str, ChangeStamp]] = {}
223
+
224
+ def root_of(self, revision: bytes | None) -> bytes | None:
225
+ if revision is None:
226
+ return None
227
+ found = self.virtual_roots.get(revision)
228
+ return found if found is not None else self.input.revision_root(revision)
229
+
230
+ # Orchestration -------------------------------------------------------------------
231
+
232
+ def run(self) -> MergeOutcome:
233
+ merge_input = self.input
234
+ ours, theirs = merge_input.ours, merge_input.theirs
235
+ if merge_input.base is not None:
236
+ base: bytes | None = merge_input.base
237
+ status = (
238
+ MergeStatus.UP_TO_DATE
239
+ if base == theirs
240
+ else MergeStatus.FAST_FORWARD
241
+ if base == ours
242
+ else MergeStatus.MERGE
243
+ )
244
+ virtual = False
245
+ else:
246
+ found = merge_base(merge_input.graph, ours, theirs, self.build_virtual)
247
+ base, status, virtual = found.base, found.status, found.virtual
248
+ ours_root = self.root_of(ours)
249
+ theirs_root = self.root_of(theirs)
250
+ assert ours_root is not None
251
+ assert theirs_root is not None
252
+ if status is MergeStatus.UP_TO_DATE:
253
+ return self.unchanged(ours_root, base)
254
+ if status is MergeStatus.FAST_FORWARD and merge_input.policy.fast_forward:
255
+ return self.fast_forward(ours_root, theirs_root, base)
256
+ return self.three_way(base, virtual)
257
+
258
+ def unchanged(self, root: bytes, base: bytes | None) -> MergedTree:
259
+ merge_input = self.input
260
+ return MergedTree(
261
+ result=MergeResult.UP_TO_DATE,
262
+ root=root,
263
+ ours=merge_input.ours,
264
+ theirs=merge_input.theirs,
265
+ base=base,
266
+ virtual_base=False,
267
+ parents=(),
268
+ changes=(),
269
+ stats=changeset_stats(()),
270
+ )
271
+
272
+ def fast_forward(self, ours_root: bytes, theirs_root: bytes, base: bytes | None) -> MergedTree:
273
+ merge_input = self.input
274
+ changes = tuple(
275
+ replace(entry, provenance=self.provenance(merge_input.theirs, entry))
276
+ for entry in changeset_between(self.store, ours_root, theirs_root)
277
+ )
278
+ return MergedTree(
279
+ result=MergeResult.FAST_FORWARD,
280
+ root=theirs_root,
281
+ ours=merge_input.ours,
282
+ theirs=merge_input.theirs,
283
+ base=base,
284
+ virtual_base=False,
285
+ parents=(),
286
+ changes=changes,
287
+ stats=changeset_stats(changes),
288
+ )
289
+
290
+ def three_way(self, base: bytes | None, virtual: bool) -> MergeOutcome:
291
+ merge_input = self.input
292
+ roots = {
293
+ Side.BASE: self.root_of(base),
294
+ Side.OURS: self.root_of(merge_input.ours),
295
+ Side.THEIRS: self.root_of(merge_input.theirs),
296
+ }
297
+ resolutions = {r.fingerprint: r for r in merge_input.resolutions}
298
+ factory = merge_input.index_view
299
+ index: IndexView = (
300
+ factory(merge_input.ours)
301
+ if factory is not None
302
+ else TreeIndexView(self.store, merge_input.objects, roots[Side.OURS])
303
+ )
304
+ directives = Directives()
305
+ known = None
306
+ # Each round applies the directives of newly matched resolutions; a resolution
307
+ # yields its directives once, so the rounds are bounded by their number.
308
+ for _ in range(len(resolutions) + 1):
309
+ current = self.execute(roots, resolutions, directives, index=index, known=known)
310
+ known = current.ctx.known_issues
311
+ if not directives.absorb(current.ctx.out):
312
+ break
313
+ ctx = current.ctx
314
+ auto = tuple(ctx.auto)
315
+ if ctx.conflicts:
316
+ return MergeAttempt(
317
+ base=base,
318
+ ours=merge_input.ours,
319
+ theirs=merge_input.theirs,
320
+ virtual_base=virtual,
321
+ conflicts=tuple(ctx.conflicts),
322
+ auto_resolutions=auto,
323
+ resolutions=merge_input.resolutions,
324
+ input=merge_input,
325
+ members=dict(ctx.members),
326
+ )
327
+ root, entries = self.write(current)
328
+ return MergedTree(
329
+ result=MergeResult.MERGED,
330
+ root=root,
331
+ ours=merge_input.ours,
332
+ theirs=merge_input.theirs,
333
+ base=base,
334
+ virtual_base=virtual,
335
+ parents=(merge_input.ours, merge_input.theirs),
336
+ changes=entries,
337
+ stats=changeset_stats(entries),
338
+ auto_resolutions=auto,
339
+ resolutions=merge_input.resolutions,
340
+ )
341
+
342
+ def execute(
343
+ self,
344
+ roots: dict[Side, bytes | None],
345
+ resolutions: Mapping[str, Resolution],
346
+ directives: Directives,
347
+ *,
348
+ index: IndexView | None,
349
+ known: set[tuple[object, tuple[object, ...]]] | None = None,
350
+ ) -> _Run:
351
+ """One run of steps 1 to 4 under the given directives."""
352
+ schemas = {
353
+ side: None if root is None else self.loader(root) for side, root in roots.items()
354
+ }
355
+ ctx = Context(
356
+ store=self.store,
357
+ objects=self.input.objects,
358
+ policy=self.input.policy,
359
+ params=self.input.tree_params,
360
+ schema_loader=self.loader,
361
+ roots=roots,
362
+ schemas=schemas,
363
+ resolutions=resolutions,
364
+ directives=directives.copy(),
365
+ virtual=index is None,
366
+ rules=self.input.schema_rules or SchemaRules(),
367
+ lineage=None if self.input.lineage is None else self.lineage_of,
368
+ )
369
+ ctx.schema_hashes = {side: _schema_hashes(self.store, root) for side, root in roots.items()}
370
+ if index is not None:
371
+ ctx.known_issues = (
372
+ known
373
+ if known is not None
374
+ else known_static_issues([schemas[Side.OURS], schemas[Side.THEIRS]], ctx.rules)
375
+ )
376
+ changes: dict[Side, SideChanges] = {
377
+ side: side_changes(tree_diff(self.store, roots[Side.BASE], roots[side]))
378
+ for side in SIDES
379
+ }
380
+ schema = merge_schema(ctx)
381
+ data = merge_data(ctx, schema, changes)
382
+ if index is not None:
383
+ check_consistency(ctx, schema.schema, data, index)
384
+ emit_groups(ctx, schema)
385
+ return _Run(ctx, schema, data)
386
+
387
+ def write(
388
+ self, run: _Run, *, with_provenance: bool = True
389
+ ) -> tuple[bytes, tuple[ChangesetEntry, ...]]:
390
+ """Write the merged tree; return its root and its changeset against ours."""
391
+ ctx, schema, data = run.ctx, run.schema, run.data
392
+ theirs = self.input.theirs if with_provenance else None
393
+ entries: list[ChangesetEntry] = []
394
+ ours_schema = ctx.schema_hashes[Side.OURS]
395
+ for key in sorted(ours_schema.keys() | schema.hashes.keys()):
396
+ old, new = ours_schema.get(key), schema.hashes.get(key)
397
+ if old != new:
398
+ whole = schema.origins.get(key)
399
+ lineage = (
400
+ self.provenance_of(theirs, PARTITION_SCHEMA, key)
401
+ if whole is Side.THEIRS
402
+ else {}
403
+ )
404
+ entries.append(ChangesetEntry(PARTITION_SCHEMA, key, old, new, lineage))
405
+ tree_changes: list[TreeChange] = []
406
+ for location in sorted(data.states):
407
+ state = data.states[location]
408
+ new = None if state is None else ctx.put(data_object_type(location[0]), state)
409
+ old = data.ours.get(location)
410
+ if old == new:
411
+ continue
412
+ tree_changes.append(TreeChange(location[0], location[1], new))
413
+ origin = data.origins.get(location)
414
+ provenance: Mapping[str, str] = {}
415
+ if origin is not None and (origin.whole is Side.THEIRS or origin.attributes):
416
+ full = self.provenance_of(theirs, *location)
417
+ if origin.whole is Side.THEIRS:
418
+ provenance = full
419
+ else:
420
+ provenance = {
421
+ a: p for a, p in full.items() if origin.attributes.get(a) is Side.THEIRS
422
+ }
423
+ entries.append(ChangesetEntry(location[0], location[1], old, new, provenance))
424
+ root = tree_apply(self.store, schema.root, tree_changes, self.input.tree_params)
425
+ entries.sort(key=lambda e: (e.partition.encode("utf-8"), e.key))
426
+ return root, tuple(entries)
427
+
428
+ # Provenance -----------------------------------------------------------------------
429
+
430
+ def provenance_of(self, revision: bytes | None, partition: str, key: str) -> Mapping[str, str]:
431
+ loader = self.input.provenance
432
+ if loader is None or revision is None:
433
+ return {}
434
+ return dict(loader(revision, partition, key))
435
+
436
+ def provenance(self, revision: bytes, entry: ChangesetEntry) -> Mapping[str, str]:
437
+ return self.provenance_of(revision, entry.partition, entry.key)
438
+
439
+ def lineage_of(self, side: Side, partition: str, key: str) -> Mapping[str, ChangeStamp]:
440
+ """Lineage entries of an element on ``ours`` or ``theirs``, loaded once per merge."""
441
+ loader = self.input.lineage
442
+ if loader is None or side is Side.BASE:
443
+ return {}
444
+ found = self.lineage_cache.get((side, partition, key))
445
+ if found is None:
446
+ revision = self.input.ours if side is Side.OURS else self.input.theirs
447
+ found = self.lineage_cache[(side, partition, key)] = dict(
448
+ loader(revision, partition, key)
449
+ )
450
+ return found
451
+
452
+ # Virtual merge base (AC-13) -----------------------------------------------------------
453
+
454
+ def build_virtual(self, base: bytes | None, ours: bytes, theirs: bytes) -> bytes:
455
+ """Merge two common ancestors into a virtual base Revision (never committed).
456
+
457
+ Conflicting changes keep the value of their own merge base, so that both sides of
458
+ the outer merge see them as changes.
459
+ """
460
+ roots = {
461
+ Side.BASE: self.root_of(base),
462
+ Side.OURS: self.root_of(ours),
463
+ Side.THEIRS: self.root_of(theirs),
464
+ }
465
+ run = self.execute(roots, {}, Directives(), index=None)
466
+ root, _ = self.write(run, with_provenance=False)
467
+ digest = hashlib.sha256(_VIRTUAL_LABEL)
468
+ for part in (base or b"", ours, theirs, root):
469
+ digest.update(part)
470
+ virtual = digest.digest()
471
+ self.virtual_roots[virtual] = root
472
+ return virtual
473
+
474
+
475
+ def _schema_hashes(store: NodeStore, root: bytes | None) -> dict[str, bytes]:
476
+ return dict(tree_entries(store, root, PARTITION_SCHEMA))
@@ -0,0 +1,100 @@
1
+ """An ``IndexView`` read from a Revision tree, for targets without a materialized head.
2
+
3
+ Each partition is loaded on first use and indexed in memory, so lookups cost one scan of the
4
+ partitions they touch. A materialized head (design 6.11) answers the same questions with
5
+ indexed reads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from collections.abc import Iterable
12
+
13
+ from pydantic import JsonValue
14
+
15
+ from lorebase_core.canonical import canonical_bytes
16
+ from lorebase_core.merge.objects import ObjectStore, load_object
17
+ from lorebase_core.tree import NodeStore, kind_partition, relation_partition, tree_entries
18
+ from lorebase_core.types import CanonicalValue
19
+
20
+ __all__ = ["TreeIndexView"]
21
+
22
+
23
+ class _Links:
24
+ def __init__(self) -> None:
25
+ self.pairs: set[tuple[str, str]] = set()
26
+ self.by_from: dict[str, set[str]] = defaultdict(set)
27
+ self.by_to: dict[str, set[str]] = defaultdict(set)
28
+
29
+
30
+ class TreeIndexView:
31
+ """:class:`~lorebase_core.validate.IndexView` over the tree at ``root``."""
32
+
33
+ def __init__(self, store: NodeStore, objects: ObjectStore, root: bytes | None) -> None:
34
+ self._store = store
35
+ self._objects = objects
36
+ self._root = root
37
+ self._records: dict[str, dict[str, dict[str, JsonValue]]] = {}
38
+ self._links: dict[str, _Links] = {}
39
+
40
+ def _kind(self, kind_id: str) -> dict[str, dict[str, JsonValue]]:
41
+ records = self._records.get(kind_id)
42
+ if records is None:
43
+ records = {}
44
+ for key, digest in tree_entries(self._store, self._root, kind_partition(kind_id)):
45
+ values = load_object(self._objects, digest).get("values")
46
+ records[key] = dict(values) if isinstance(values, dict) else {}
47
+ self._records[kind_id] = records
48
+ return records
49
+
50
+ def _relation(self, relation_id: str) -> _Links:
51
+ links = self._links.get(relation_id)
52
+ if links is None:
53
+ links = _Links()
54
+ for _, digest in tree_entries(self._store, self._root, relation_partition(relation_id)):
55
+ payload = load_object(self._objects, digest)
56
+ start, end = str(payload["from"]), str(payload["to"])
57
+ links.pairs.add((start, end))
58
+ links.by_from[start].add(end)
59
+ links.by_to[end].add(start)
60
+ self._links[relation_id] = links
61
+ return links
62
+
63
+ def record_exists(self, kind_id: str, key: str) -> bool:
64
+ return key in self._kind(kind_id)
65
+
66
+ def find_unique(self, kind_id: str, attribute_id: str, value: CanonicalValue) -> str | None:
67
+ wanted = canonical_bytes(value)
68
+ for key in sorted(self._kind(kind_id)):
69
+ found = self._kind(kind_id)[key].get(attribute_id)
70
+ if found is not None and canonical_bytes(found) == wanted:
71
+ return key
72
+ return None
73
+
74
+ def keys_referencing(self, kind_id: str, attribute_id: str, key: str) -> Iterable[str]:
75
+ for record_key, values in sorted(self._kind(kind_id).items()):
76
+ value = values.get(attribute_id)
77
+ if value == key or (isinstance(value, list) and key in value):
78
+ yield record_key
79
+
80
+ def link_exists(self, relation_id: str, from_key: str, to_key: str) -> bool:
81
+ return (from_key, to_key) in self._relation(relation_id).pairs
82
+
83
+ def count_links(
84
+ self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
85
+ ) -> int:
86
+ return len(list(self.links_of(relation_id, from_key=from_key, to_key=to_key)))
87
+
88
+ def links_of(
89
+ self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
90
+ ) -> Iterable[tuple[str, str]]:
91
+ links = self._relation(relation_id)
92
+ if from_key is not None:
93
+ pairs = {(from_key, end) for end in links.by_from.get(from_key, ())}
94
+ elif to_key is not None:
95
+ pairs = {(start, to_key) for start in links.by_to.get(to_key, ())}
96
+ else:
97
+ pairs = set(links.pairs)
98
+ if from_key is not None and to_key is not None:
99
+ pairs = {p for p in pairs if p[1] == to_key}
100
+ return sorted(pairs)