lorebase-store 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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,321 @@
1
+ """Verification of a materialized head against its tree (design 6.11, invariant I4).
2
+
3
+ Recomputes what the tree at the branch head says the materialized rows must be and compares:
4
+ the schema partition hash, the set of `(key, object_hash)` of every Kind and Relation
5
+ partition, and the per-partition counts. With `deep=True`, Record and Link values and Record
6
+ titles are also recomputed from the stored objects. With `derived=True`, derived values are
7
+ recomputed from the materialized Records and Links (`materialize.derived`, nothing written) and
8
+ compared with the stored ones, and so are the Links of materialized path Derivations
9
+ (`materialize.paths`), which the tree comparison leaves out.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+ from enum import StrEnum
17
+ from typing import Any
18
+
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+ from lorebase_core.model import Schema
22
+ from lorebase_core.tree import PARTITION_SCHEMA, kind_partition, link_key, relation_partition
23
+ from lorebase_store.commit.schema import load_compiled
24
+ from lorebase_store.materialize.derived import DerivedValues
25
+ from lorebase_store.materialize.paths import PathLinks
26
+ from lorebase_store.materialize.rebuild import load_schema
27
+ from lorebase_store.materialize.rows import (
28
+ PartitionType,
29
+ parse_partition,
30
+ record_title,
31
+ )
32
+ from lorebase_store.materialize.sensitivity import branch_tagged_schema
33
+ from lorebase_store.nodestore import NodeRepository
34
+ from lorebase_store.objectstore import PgObjectStore
35
+ from lorebase_store.repositories.materialized import MaterializedRepository
36
+ from lorebase_store.repositories.refs import RefRepository
37
+ from lorebase_store.repositories.revisions import RevisionRepository
38
+ from lorebase_store.repositories.scope import scope_to_domain
39
+ from lorebase_store.runtime import StoreRuntime
40
+
41
+
42
+ class DivergenceReason(StrEnum):
43
+ SCHEMA_MISSING = "schema_missing"
44
+ SCHEMA_MISMATCH = "schema_mismatch"
45
+ MISSING_ROW = "missing_row"
46
+ EXTRA_ROW = "extra_row"
47
+ HASH_MISMATCH = "hash_mismatch"
48
+ VALUES_MISMATCH = "values_mismatch"
49
+ COUNT_MISMATCH = "count_mismatch"
50
+ DERIVED_MISMATCH = "derived_mismatch"
51
+ SENSITIVITY_STALE = "sensitivity_stale"
52
+ """A title materialized before the default branch tagged the title Attribute: stale,
53
+ not corrupt; the next change of the Kind on the branch, or a rebuild, recomputes it."""
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Divergence:
58
+ """One difference between the tree-derived view and the materialized rows.
59
+
60
+ `expected` comes from the tree, `actual` from the materialized rows.
61
+ """
62
+
63
+ reason: DivergenceReason
64
+ partition: str
65
+ key: str | None = None
66
+ expected: Any = None
67
+ actual: Any = None
68
+
69
+
70
+ @dataclass
71
+ class VerifyReport:
72
+ domain_id: str
73
+ branch: str
74
+ head: bytes
75
+ partitions: int = 0
76
+ rows: int = 0
77
+ divergences: list[Divergence] = field(default_factory=list)
78
+ truncated: bool = False
79
+ _limit: int = 1000
80
+
81
+ @property
82
+ def ok(self) -> bool:
83
+ return not self.divergences
84
+
85
+ def add(self, divergence: Divergence) -> None:
86
+ if len(self.divergences) >= self._limit:
87
+ self.truncated = True
88
+ return
89
+ self.divergences.append(divergence)
90
+
91
+
92
+ async def verify(
93
+ session: AsyncSession,
94
+ domain_id: str,
95
+ branch: str,
96
+ *,
97
+ runtime: StoreRuntime,
98
+ deep: bool = False,
99
+ derived: bool = False,
100
+ ) -> VerifyReport:
101
+ """Compare the materialized head of `branch` with the tree at its head (and, with
102
+ `derived`, its derived values with recomputed ones)."""
103
+ settings = runtime.settings
104
+ batch = settings.materialize.read_batch_rows
105
+ await scope_to_domain(session, domain_id)
106
+ ref = await RefRepository(session, domain_id).require(branch)
107
+ head = await RevisionRepository(session, domain_id).require(ref.head)
108
+ report = VerifyReport(
109
+ domain_id, branch, head.id, _limit=settings.materialize.verify_max_divergences
110
+ )
111
+ repository = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
112
+ nodes = NodeRepository(
113
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
114
+ )
115
+ objects = PgObjectStore(session, domain_id, writes=settings.writes, batch=batch)
116
+ tree = await nodes.load_tree(head.root)
117
+
118
+ schema, schema_hash = await load_schema(session, domain_id, tree, runtime=runtime)
119
+ tagged = await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
120
+ stored_schema = await repository.schema()
121
+ if stored_schema is None:
122
+ report.add(Divergence(DivergenceReason.SCHEMA_MISSING, PARTITION_SCHEMA))
123
+ elif stored_schema.schema_hash != schema_hash or (
124
+ deep and load_compiled(stored_schema.compiled) != schema
125
+ ):
126
+ report.add(
127
+ Divergence(
128
+ DivergenceReason.SCHEMA_MISMATCH,
129
+ PARTITION_SCHEMA,
130
+ expected=schema_hash.hex(),
131
+ actual=stored_schema.schema_hash.hex(),
132
+ )
133
+ )
134
+
135
+ counts = {kind_partition(k): n for k, n in (await repository.record_counts()).items()}
136
+ counts.update({relation_partition(r): n for r, n in (await repository.link_counts()).items()})
137
+ partitions = {p: ref.count for p, ref in tree.partitions.items() if p != PARTITION_SCHEMA}
138
+ for partition in sorted(partitions.keys() | counts.keys()):
139
+ report.partitions += 1
140
+ expected_count = partitions.get(partition, 0)
141
+ actual_count = counts.get(partition, 0)
142
+ if expected_count != actual_count:
143
+ report.add(
144
+ Divergence(
145
+ DivergenceReason.COUNT_MISMATCH,
146
+ partition,
147
+ expected=expected_count,
148
+ actual=actual_count,
149
+ )
150
+ )
151
+ expected: dict[str, bytes] = {}
152
+ if partition in tree.partitions:
153
+ async for entries in nodes.scan(tree.partitions[partition].root):
154
+ expected.update(entries)
155
+ actual = await _materialized(repository, partition, batch=batch)
156
+ report.rows += len(actual)
157
+ _compare_hashes(report, partition, expected, {k: v[0] for k, v in actual.items()})
158
+ if deep:
159
+ await _compare_values(report, partition, expected, actual, objects, tagged, schema)
160
+ if derived:
161
+ await _compare_derived(report, repository, schema, runtime, head.created_at)
162
+ await _compare_paths(report, repository, schema, runtime)
163
+ return report
164
+
165
+
166
+ async def _compare_derived(
167
+ report: VerifyReport,
168
+ repository: MaterializedRepository,
169
+ schema: Schema,
170
+ runtime: StoreRuntime,
171
+ at: Any,
172
+ ) -> None:
173
+ """Recompute every derived value without writing and compare with the stored ones."""
174
+ settings = runtime.settings.materialize
175
+ recomputed = DerivedValues(
176
+ repository,
177
+ schema,
178
+ limits=runtime.schema.expression_limits,
179
+ now=at,
180
+ batch=settings.read_batch_rows,
181
+ max_incremental=settings.derivation_max_incremental_records,
182
+ fanout=runtime.settings.traverse.path_fanout,
183
+ write=False,
184
+ )
185
+ await recomputed.recompute_all()
186
+ for kind_id in sorted(await repository.record_counts()):
187
+ async for rows in repository.stream_records(kind_id, batch=settings.read_batch_rows):
188
+ for row in rows:
189
+ want = recomputed.expected.get((kind_id, row.key), {})
190
+ if row.derived != want:
191
+ report.add(
192
+ Divergence(
193
+ DivergenceReason.DERIVED_MISMATCH,
194
+ kind_partition(kind_id),
195
+ row.key,
196
+ expected=want,
197
+ actual=row.derived,
198
+ )
199
+ )
200
+
201
+
202
+ async def _compare_paths(
203
+ report: VerifyReport, repository: MaterializedRepository, schema: Schema, runtime: StoreRuntime
204
+ ) -> None:
205
+ """Recompute the Links of every materialized path Derivation without writing and compare
206
+ them with the stored ones (and report Links of paths no longer materialized)."""
207
+ settings = runtime.settings
208
+ paths = PathLinks(
209
+ repository,
210
+ schema,
211
+ fanout=settings.traverse.path_fanout,
212
+ batch=settings.materialize.read_batch_rows,
213
+ max_incremental=settings.materialize.derivation_max_incremental_records,
214
+ write=False,
215
+ )
216
+ known = {path.id for path in paths.paths}
217
+ for path_id, count in sorted((await repository.link_counts(derived=True)).items()):
218
+ if path_id not in known:
219
+ report.add(
220
+ Divergence(
221
+ DivergenceReason.DERIVED_MISMATCH,
222
+ relation_partition(path_id),
223
+ expected=0,
224
+ actual=count,
225
+ )
226
+ )
227
+ for path in paths.paths:
228
+ await paths.compute_all(path)
229
+ want = paths.expected.get(path.id, set())
230
+ got = await paths.stored(path)
231
+ for from_key, to_key in sorted(want ^ got):
232
+ report.add(
233
+ Divergence(
234
+ DivergenceReason.DERIVED_MISMATCH,
235
+ relation_partition(path.id),
236
+ link_key(from_key, to_key),
237
+ expected=(from_key, to_key) in want,
238
+ actual=(from_key, to_key) in got,
239
+ )
240
+ )
241
+
242
+
243
+ async def _materialized(
244
+ repository: MaterializedRepository, partition: str, *, batch: int
245
+ ) -> dict[str, tuple[bytes, dict[str, Any] | None, str | None]]:
246
+ """`key -> (object_hash, values, title)` of a partition's materialized rows."""
247
+ parsed = parse_partition(partition)
248
+ rows: dict[str, tuple[bytes, dict[str, Any] | None, str | None]] = {}
249
+ if parsed.type is PartitionType.KIND:
250
+ async for records in repository.stream_records(parsed.element_id, batch=batch):
251
+ rows.update((r.key, (r.object_hash, r.values, r.title)) for r in records)
252
+ else:
253
+ async for links in repository.stream_links(parsed.element_id, batch=batch):
254
+ rows.update(
255
+ (link_key(r.from_key, r.to_key), (r.object_hash, r.values, None)) for r in links
256
+ )
257
+ return rows
258
+
259
+
260
+ def _compare_hashes(
261
+ report: VerifyReport, partition: str, expected: Mapping[str, bytes], actual: Mapping[str, bytes]
262
+ ) -> None:
263
+ for key in sorted(expected.keys() | actual.keys()):
264
+ want, got = expected.get(key), actual.get(key)
265
+ if want == got:
266
+ continue
267
+ if got is None:
268
+ reason = DivergenceReason.MISSING_ROW
269
+ elif want is None:
270
+ reason = DivergenceReason.EXTRA_ROW
271
+ else:
272
+ reason = DivergenceReason.HASH_MISMATCH
273
+ report.add(
274
+ Divergence(
275
+ reason,
276
+ partition,
277
+ key,
278
+ expected=None if want is None else want.hex(),
279
+ actual=None if got is None else got.hex(),
280
+ )
281
+ )
282
+
283
+
284
+ async def _compare_values(
285
+ report: VerifyReport,
286
+ partition: str,
287
+ expected: Mapping[str, bytes],
288
+ actual: Mapping[str, tuple[bytes, dict[str, Any] | None, str | None]],
289
+ objects: PgObjectStore,
290
+ schema: Schema,
291
+ own: Schema | None = None,
292
+ ) -> None:
293
+ """`schema` has the default branch's tags added; `own` is the branch's own schema, which
294
+ tells a stale title (`SENSITIVITY_STALE`) from a wrong one."""
295
+ common = sorted(expected.keys() & actual.keys())
296
+ bodies = await objects.get_many(expected[key] for key in common)
297
+ parsed = parse_partition(partition)
298
+ kind = schema.kinds.get(parsed.element_id) if parsed.type is PartitionType.KIND else None
299
+ for key in common:
300
+ body = bodies.get(expected[key])
301
+ want_values = {} if body is None else dict(body.body.get("values", {}))
302
+ _, got_values, got_title = actual[key]
303
+ want_title = record_title(kind, want_values) if kind is not None else None
304
+ if got_values != want_values or got_title != want_title:
305
+ own_kind = None if own is None else own.kinds.get(parsed.element_id)
306
+ stale = (
307
+ got_values == want_values
308
+ and own_kind is not None
309
+ and got_title == record_title(own_kind, want_values)
310
+ )
311
+ report.add(
312
+ Divergence(
313
+ DivergenceReason.SENSITIVITY_STALE
314
+ if stale
315
+ else DivergenceReason.VALUES_MISMATCH,
316
+ partition,
317
+ key,
318
+ expected={"values": want_values, "title": want_title},
319
+ actual={"values": got_values, "title": got_title},
320
+ )
321
+ )
@@ -0,0 +1,39 @@
1
+ """Merging Proposals over the store: merge attempts, Conflicts, resolutions, merge commits."""
2
+
3
+ from lorebase_store.merging.attempts import (
4
+ AttemptComputation,
5
+ ConflictState,
6
+ Decision,
7
+ check_resolution,
8
+ complete_provenance,
9
+ compute_attempt,
10
+ decisions_of,
11
+ taken_provenance,
12
+ )
13
+ from lorebase_store.merging.lineage import BranchLineage
14
+ from lorebase_store.merging.service import (
15
+ MERGE_ORIGIN,
16
+ MERGE_REASON,
17
+ AttemptResult,
18
+ MergeRequest,
19
+ MergeService,
20
+ ProposalBranchMissingError,
21
+ )
22
+
23
+ __all__ = [
24
+ "MERGE_ORIGIN",
25
+ "MERGE_REASON",
26
+ "AttemptComputation",
27
+ "AttemptResult",
28
+ "BranchLineage",
29
+ "ConflictState",
30
+ "Decision",
31
+ "MergeRequest",
32
+ "MergeService",
33
+ "ProposalBranchMissingError",
34
+ "check_resolution",
35
+ "complete_provenance",
36
+ "compute_attempt",
37
+ "decisions_of",
38
+ "taken_provenance",
39
+ ]
@@ -0,0 +1,334 @@
1
+ """Merge attempts computed from stored resolutions (design 6.10), independent of storage.
2
+
3
+ `compute_attempt` runs the core merge with the resolutions of a Proposal. Resolutions are
4
+ stored by fingerprint and may outnumber the Conflicts of the current run: a Conflict can
5
+ appear only once another one is resolved (a custom value that breaks uniqueness), and a
6
+ resolution can lose its Conflict when a head moves. The merge is therefore re-run with the
7
+ resolutions that matched so far until no new one matches; every Conflict seen on the way is
8
+ reported as open (in the final run) or resolved (its resolution was applied), and resolutions
9
+ that never matched are returned as unmatched.
10
+
11
+ `complete_provenance` attributes every change of a merge Revision: values taken from the
12
+ source keep their provenance, values decided by a resolution get the provenance entry of that
13
+ resolution (`merge_resolution`, design 6.10) and the rest the merge's own entry. Only the
14
+ Attributes a change sets are attributed (`taken_provenance`): an element taken whole from the
15
+ source brings the source's whole Lineage, and its unchanged values keep the Revision that last
16
+ changed them on the target.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterable, Mapping, Sequence
22
+ from dataclasses import dataclass, replace
23
+ from typing import Any
24
+
25
+ from lorebase_core.merge import (
26
+ CHOICES,
27
+ AutoResolution,
28
+ Conflict,
29
+ ConflictKind,
30
+ MergeAttempt,
31
+ MergedTree,
32
+ MergeInput,
33
+ MergeOutcome,
34
+ ObjectStore,
35
+ Resolution,
36
+ ResolutionChoice,
37
+ ResolutionError,
38
+ merge,
39
+ )
40
+ from lorebase_core.model import looks_like_reference
41
+ from lorebase_core.revision import ChangesetEntry
42
+ from lorebase_core.tree import PARTITION_SCHEMA
43
+ from lorebase_store.materialize.rows import ELEMENT_PROVENANCE_KEY, changed_attributes
44
+
45
+ __all__ = [
46
+ "AttemptComputation",
47
+ "ConflictState",
48
+ "Decision",
49
+ "check_resolution",
50
+ "complete_provenance",
51
+ "compute_attempt",
52
+ "decisions_of",
53
+ "taken_provenance",
54
+ ]
55
+
56
+ #: Grouped kinds whose custom resolution may give values for some members only.
57
+ _PARTIAL_CUSTOM = frozenset({ConflictKind.UNIQUENESS, ConflictKind.REQUIRED_RELATION})
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ConflictState:
62
+ """A Conflict of an attempt with its full member keys and its state.
63
+
64
+ `resolution` is the stored resolution that applies to it. An open Conflict with a
65
+ resolution was `rejected`: the merge reported it again after applying the resolution.
66
+ """
67
+
68
+ conflict: Conflict
69
+ members: tuple[str, ...]
70
+ open: bool
71
+ resolution: Resolution | None = None
72
+
73
+ @property
74
+ def rejected(self) -> bool:
75
+ return self.open and self.resolution is not None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class AttemptComputation:
80
+ """Outcome of a merge with stored resolutions applied."""
81
+
82
+ outcome: MergeOutcome
83
+ conflicts: tuple[ConflictState, ...]
84
+ applied: tuple[Resolution, ...]
85
+ unmatched: tuple[str, ...]
86
+
87
+ @property
88
+ def clean(self) -> bool:
89
+ return isinstance(self.outcome, MergedTree)
90
+
91
+ @property
92
+ def open_count(self) -> int:
93
+ return sum(1 for state in self.conflicts if state.open)
94
+
95
+ @property
96
+ def resolved_count(self) -> int:
97
+ return sum(1 for state in self.conflicts if not state.open)
98
+
99
+
100
+ def compute_attempt(
101
+ merge_input: MergeInput, resolutions: Iterable[Resolution]
102
+ ) -> AttemptComputation:
103
+ """Merge `merge_input` with the stored `resolutions` (see the module docstring).
104
+
105
+ Raises `ResolutionError` when a stored resolution does not fit its Conflict.
106
+ """
107
+ stored = {r.fingerprint: r for r in resolutions}
108
+ applied: dict[str, Resolution] = {}
109
+ seen: dict[str, tuple[Conflict, tuple[str, ...]]] = {}
110
+ outcome: MergeOutcome | None = None
111
+ for _ in range(len(stored) + 1):
112
+ outcome = merge(replace(merge_input, resolutions=tuple(applied.values())))
113
+ if isinstance(outcome, MergeAttempt):
114
+ for conflict in outcome.conflicts:
115
+ if conflict.fingerprint not in seen:
116
+ members = tuple(outcome.members.get(conflict.fingerprint, ()))
117
+ seen[conflict.fingerprint] = (conflict, members)
118
+ matched = {fp: r for fp, r in stored.items() if fp in seen and fp not in applied}
119
+ if not matched:
120
+ break
121
+ applied.update(matched)
122
+ assert outcome is not None
123
+ open_now = (
124
+ {c.fingerprint: c for c in outcome.conflicts} if isinstance(outcome, MergeAttempt) else {}
125
+ )
126
+ states: list[ConflictState] = []
127
+ for fingerprint, (conflict, members) in seen.items():
128
+ if fingerprint in open_now:
129
+ states.append(
130
+ ConflictState(
131
+ open_now[fingerprint], members, open=True, resolution=applied.get(fingerprint)
132
+ )
133
+ )
134
+ elif fingerprint in applied:
135
+ states.append(
136
+ ConflictState(conflict, members, open=False, resolution=applied[fingerprint])
137
+ )
138
+ return AttemptComputation(
139
+ outcome=outcome,
140
+ conflicts=tuple(states),
141
+ applied=tuple(applied.values()),
142
+ unmatched=tuple(fp for fp in stored if fp not in applied),
143
+ )
144
+
145
+
146
+ def check_resolution(conflict: Conflict, members: Sequence[str], resolution: Resolution) -> None:
147
+ """Raise `ResolutionError` unless `resolution` is acceptable for `conflict`.
148
+
149
+ The choice must be one of the Conflict kind's choices; a custom resolution of a grouped
150
+ Conflict given per member must cover every member unless it also has a default `value`
151
+ (uniqueness and required relation Conflicts accept values for some members only).
152
+ """
153
+ if resolution.fingerprint != conflict.fingerprint:
154
+ raise ResolutionError("the resolution names another Conflict")
155
+ if resolution.choice not in CHOICES[conflict.kind]:
156
+ allowed = ", ".join(c.value for c in CHOICES[conflict.kind])
157
+ raise ResolutionError(
158
+ f"{resolution.choice.value} does not resolve a {conflict.kind.value} Conflict "
159
+ f"(accepted: {allowed})"
160
+ )
161
+ if resolution.choice is ResolutionChoice.CUSTOM and (
162
+ _holds_reference((conflict.base, conflict.ours, conflict.theirs))
163
+ or _holds_reference((resolution.value, resolution.values))
164
+ ):
165
+ # A custom value would bypass the vault (design 13.5), and a reference would copy
166
+ # another Record's value: vault values are resolved by picking a side.
167
+ raise ResolutionError(
168
+ "a Conflict over vault values takes one of its sides, not a custom value"
169
+ )
170
+ if (
171
+ resolution.choice is ResolutionChoice.CUSTOM
172
+ and resolution.values is not None
173
+ and resolution.value is None
174
+ and conflict.kind not in _PARTIAL_CUSTOM
175
+ ):
176
+ missing = [m for m in members if m not in resolution.values]
177
+ if missing:
178
+ raise ResolutionError(
179
+ f"custom values miss {len(missing)} member(s), for example {missing[0]!r}"
180
+ )
181
+
182
+
183
+ def _holds_reference(value: Any) -> bool:
184
+ """Whether a vault reference appears anywhere in `value`."""
185
+ if looks_like_reference(value):
186
+ return True
187
+ if isinstance(value, Mapping):
188
+ return any(_holds_reference(item) for item in value.values())
189
+ if isinstance(value, list | tuple):
190
+ return any(_holds_reference(item) for item in value)
191
+ return False
192
+
193
+
194
+ # Provenance -------------------------------------------------------------------------------
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Decision:
199
+ """A resolution or automatic resolution, located for attribution."""
200
+
201
+ fingerprint: str
202
+ resolver: str
203
+ choice: ResolutionChoice
204
+ partition: str
205
+ key: str | None
206
+ attribute: str | None
207
+ members: tuple[str, ...] = ()
208
+
209
+
210
+ def decisions_of(
211
+ computation: AttemptComputation, auto: Sequence[AutoResolution] = ()
212
+ ) -> list[Decision]:
213
+ """Decisions of a clean merge: applied resolutions, then automatic resolutions."""
214
+ found: list[Decision] = []
215
+ for state in computation.conflicts:
216
+ if state.resolution is None or state.open:
217
+ continue
218
+ conflict = state.conflict
219
+ found.append(
220
+ Decision(
221
+ fingerprint=conflict.fingerprint,
222
+ resolver=state.resolution.resolver,
223
+ choice=state.resolution.choice,
224
+ partition=conflict.partition,
225
+ key=conflict.key,
226
+ attribute=conflict.attribute,
227
+ members=state.members or conflict.sample,
228
+ )
229
+ )
230
+ for item in auto:
231
+ found.append(
232
+ Decision(
233
+ fingerprint=item.fingerprint,
234
+ resolver=item.resolver,
235
+ choice=item.choice,
236
+ partition=item.partition,
237
+ key=item.key,
238
+ attribute=item.attribute,
239
+ members=item.sample,
240
+ )
241
+ )
242
+ return found
243
+
244
+
245
+ def _index(decisions: Sequence[Decision]) -> dict[tuple[str, str, str | None], Decision]:
246
+ """Decisions by `(partition, key, attribute)`; schema decisions by element only."""
247
+ index: dict[tuple[str, str, str | None], Decision] = {}
248
+ for decision in decisions:
249
+ attribute = None if decision.partition == PARTITION_SCHEMA else decision.attribute
250
+ keys = [decision.key] if decision.key is not None else []
251
+ keys.extend(decision.members)
252
+ for key in keys:
253
+ index.setdefault((decision.partition, key, attribute), decision)
254
+ return index
255
+
256
+
257
+ def _values(objects: ObjectStore, digest: bytes | None) -> Mapping[str, Any] | None:
258
+ if digest is None:
259
+ return None
260
+ body = objects.get(digest)
261
+ if not isinstance(body, dict):
262
+ return None
263
+ values = body.get("values")
264
+ return values if isinstance(values, dict) else {}
265
+
266
+
267
+ def _changed(entry: ChangesetEntry, objects: ObjectStore) -> list[str] | None:
268
+ """Attributes whose value the change sets; None for schema elements and deletions."""
269
+ if entry.partition == PARTITION_SCHEMA or entry.new_hash is None:
270
+ return None
271
+ return changed_attributes(
272
+ _values(objects, entry.old_hash), _values(objects, entry.new_hash) or {}
273
+ )
274
+
275
+
276
+ def _restricted(entry: ChangesetEntry, changed: list[str] | None) -> dict[str, str]:
277
+ if changed is None:
278
+ return dict(entry.provenance)
279
+ kept = {*changed, ELEMENT_PROVENANCE_KEY}
280
+ return {a: p for a, p in entry.provenance.items() if a in kept}
281
+
282
+
283
+ def taken_provenance(
284
+ entries: Sequence[ChangesetEntry], objects: ObjectStore
285
+ ) -> list[ChangesetEntry]:
286
+ """Entries whose provenance names only the Attributes they change (and the element key);
287
+ schema elements and deletions are kept as they are."""
288
+ return [
289
+ replace(entry, provenance=_restricted(entry, _changed(entry, objects))) for entry in entries
290
+ ]
291
+
292
+
293
+ def complete_provenance(
294
+ entries: Sequence[ChangesetEntry],
295
+ objects: ObjectStore,
296
+ decisions: Sequence[Decision],
297
+ decision_ids: Mapping[str, str],
298
+ fallback: str,
299
+ ) -> tuple[list[ChangesetEntry], set[str]]:
300
+ """Entries whose provenance covers every changed Attribute; returns the decisions used.
301
+
302
+ `decision_ids` maps decision fingerprints to provenance entry ids; `fallback` is the id
303
+ used for changes that neither the source nor a decision accounts for. Provenance of
304
+ Attributes a change leaves as they are is dropped (`taken_provenance`).
305
+ """
306
+ index = _index(decisions)
307
+ used: set[str] = set()
308
+
309
+ def decided(partition: str, key: str, attribute: str | None) -> str | None:
310
+ decision = index.get((partition, key, attribute))
311
+ if decision is None and attribute is not None:
312
+ decision = index.get((partition, key, None))
313
+ if decision is None or decision.fingerprint not in decision_ids:
314
+ return None
315
+ used.add(decision.fingerprint)
316
+ return decision_ids[decision.fingerprint]
317
+
318
+ completed: list[ChangesetEntry] = []
319
+ for entry in entries:
320
+ found = _changed(entry, objects)
321
+ provenance = _restricted(entry, found)
322
+ changed = found or []
323
+ for attribute in changed:
324
+ chosen = decided(entry.partition, entry.key, attribute)
325
+ if chosen is not None:
326
+ provenance[attribute] = chosen
327
+ elif attribute not in provenance:
328
+ provenance[attribute] = fallback
329
+ if not changed:
330
+ chosen = decided(entry.partition, entry.key, None)
331
+ if chosen is not None or not provenance:
332
+ provenance = {ELEMENT_PROVENANCE_KEY: chosen or fallback}
333
+ completed.append(replace(entry, provenance=provenance))
334
+ return completed, used