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,540 @@
1
+ """Derived values in the materialized head (design 5.8, 6.6 step 6, AC-07).
2
+
3
+ `expression` and `aggregate` Derivations compute an Attribute of a Record from its own values
4
+ and from the Records one hop away. Their results live in the `derived` column of the
5
+ materialized row, keyed by Attribute id: never in the tree, so they take no part in hashing or
6
+ merge and are recomputed from merged inputs.
7
+
8
+ `DerivedValues` evaluates the Derivations of a schema in dependency order (a Derivation that
9
+ reads a derived Attribute runs after the one computing it):
10
+
11
+ - `recompute_all` computes every Record of every Kind with a Derivation (rebuilds, schema
12
+ changes, verification);
13
+ - `recompute` computes, after a changeset was applied, the Records it can affect: Records
14
+ written, Records whose Links of a Relation the Derivation reads changed (for a path
15
+ Derivation, a Link of any of its steps), Records linked to written Records or to Records
16
+ whose derived values changed. One Derivation recomputes at most
17
+ `store.materialize.derivation_max_incremental_records` Records this way; beyond that, every
18
+ Record of its Kind.
19
+
20
+ Evaluation is bounded by `schema.expression_limits`; `now()` is the time of the Revision. A
21
+ value that cannot be computed (an evaluation error, a result that does not fit the Attribute)
22
+ is absent and counted in `failures`. With `write=False` nothing is written: results are kept in
23
+ memory (`expected`) for verification. `preview` evaluates one Derivation on a few Records and
24
+ returns the values and the errors without writing, whatever `write` is (candidate Derivations
25
+ that are not stored yet).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from collections import defaultdict
32
+ from collections.abc import AsyncIterator, Callable, Iterable, Mapping, Sequence
33
+ from dataclasses import dataclass, field
34
+ from datetime import datetime
35
+ from decimal import Decimal
36
+ from typing import Any
37
+
38
+ from pydantic import JsonValue
39
+
40
+ from lorebase_core.expr import (
41
+ CompiledDerivation,
42
+ EvaluationContext,
43
+ ExprError,
44
+ ExprErrorCode,
45
+ ExprLimits,
46
+ LinkedRecord,
47
+ LinksRequest,
48
+ MemoryResolver,
49
+ RecordNotLoaded,
50
+ compile_derivation,
51
+ derivation_inputs,
52
+ evaluate_derivation,
53
+ links_read,
54
+ matches_filter,
55
+ )
56
+ from lorebase_core.expr.values import encode_result, to_runtime
57
+ from lorebase_core.model import (
58
+ AggregateDerivation,
59
+ AggregateFunction,
60
+ Attribute,
61
+ Derivation,
62
+ ExpressionDerivation,
63
+ Kind,
64
+ LinkDirection,
65
+ PathRelation,
66
+ Record,
67
+ Relation,
68
+ RelationBacking,
69
+ Schema,
70
+ )
71
+ from lorebase_core.types import AttributeType
72
+ from lorebase_store.bulk import chunks
73
+ from lorebase_store.materialize.links import (
74
+ links_of,
75
+ path_affected,
76
+ path_links_of,
77
+ reverse,
78
+ reverse_steps,
79
+ )
80
+ from lorebase_store.repositories.materialized import MaterializedRepository, RecordRow
81
+
82
+ logger = logging.getLogger("lorebase.store.derived")
83
+
84
+ RecordKey = tuple[str, str]
85
+
86
+
87
+ @dataclass(frozen=True, eq=False)
88
+ class Hop:
89
+ """A Relation traversed from the Derivation's Kind (`relation.endpoint(direction)`);
90
+ hashed by identity (one object per Derivation step). For a path Derivation, `relation`
91
+ is its virtual Relation and `path` the path."""
92
+
93
+ relation: Relation
94
+ direction: LinkDirection
95
+ path: PathRelation | None = None
96
+
97
+ @property
98
+ def near_kind(self) -> str:
99
+ return self.relation.endpoint(self.direction)
100
+
101
+ @property
102
+ def far_kind(self) -> str:
103
+ return self.relation.far_endpoint(self.direction)
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class DerivationStep:
108
+ """One Derivation ready to evaluate: its target, compiled expressions and the Relations
109
+ whose Links and far Records it reads."""
110
+
111
+ derivation: Derivation
112
+ kind: Kind
113
+ attribute: Attribute
114
+ compiled: CompiledDerivation
115
+ hops: tuple[Hop, ...]
116
+ inputs: frozenset[str]
117
+
118
+
119
+ def plan_derivations(schema: Schema, limits: ExprLimits) -> list[DerivationStep]:
120
+ """The `expression` and `aggregate` Derivations of `schema` in dependency order.
121
+
122
+ A Derivation that does not compile (only possible for schemas committed with other rules)
123
+ is left out and logged.
124
+ """
125
+ steps: dict[str, DerivationStep] = {}
126
+ for derivation in sorted(schema.derivations.values(), key=lambda d: d.id):
127
+ definition = derivation.definition
128
+ if not isinstance(definition, ExpressionDerivation | AggregateDerivation):
129
+ continue
130
+ kind = schema.kinds.get(definition.kind)
131
+ attribute = None if kind is None else kind.attributes.get(definition.attribute)
132
+ if kind is None or attribute is None:
133
+ continue
134
+ try:
135
+ compiled = compile_derivation(schema, derivation, limits)
136
+ except ExprError as error:
137
+ logger.warning(
138
+ "derivation skipped",
139
+ extra={"derivation": derivation.id, "error": error.describe()},
140
+ )
141
+ continue
142
+ hops: list[Hop] = []
143
+ paths = schema.path_relations()
144
+ if isinstance(definition, AggregateDerivation):
145
+ relation = schema.link_relation(definition.relation)
146
+ if relation is None:
147
+ continue
148
+ hops.append(Hop(relation, definition.direction, paths.get(relation.id)))
149
+ elif compiled.expression is not None:
150
+ hops.extend(
151
+ Hop(f.relation, f.direction, paths.get(f.relation.id))
152
+ for f in links_read(compiled.expression)
153
+ )
154
+ steps[attribute.id] = DerivationStep(
155
+ derivation,
156
+ kind,
157
+ attribute,
158
+ compiled,
159
+ tuple(hops),
160
+ derivation_inputs(schema, derivation, limits),
161
+ )
162
+ ordered: list[DerivationStep] = []
163
+ done: set[str] = set()
164
+ visiting: set[str] = set()
165
+
166
+ def visit(attribute_id: str) -> None:
167
+ if attribute_id in done or attribute_id in visiting:
168
+ return # a cycle is reported by schema validation; keep a stable order
169
+ visiting.add(attribute_id)
170
+ step = steps[attribute_id]
171
+ for dependency in sorted(step.inputs):
172
+ if dependency in steps:
173
+ visit(dependency)
174
+ visiting.discard(attribute_id)
175
+ done.add(attribute_id)
176
+ ordered.append(step)
177
+
178
+ for attribute_id in sorted(steps):
179
+ visit(attribute_id)
180
+ return ordered
181
+
182
+
183
+ @dataclass
184
+ class AppliedChanges:
185
+ """What a changeset did to the materialized head, as derived values need it.
186
+
187
+ `written` and `removed` are Record keys by Kind id; `links` the `(from, to)` keys of
188
+ changed Links by Relation id; `previous` the values of written or removed Records before
189
+ the change (`None` for new Records).
190
+ """
191
+
192
+ written: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
193
+ removed: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
194
+ links: dict[str, set[tuple[str, str]]] = field(default_factory=lambda: defaultdict(set))
195
+ previous: dict[RecordKey, Mapping[str, Any] | None] = field(default_factory=dict)
196
+
197
+
198
+ @dataclass(frozen=True, slots=True)
199
+ class PreviewedValue:
200
+ """The value a Derivation computes for one Record: `value` is `None` when there is none,
201
+ `error` says why the evaluation failed."""
202
+
203
+ key: str
204
+ title: str | None
205
+ value: JsonValue
206
+ error: ExprError | None = None
207
+
208
+
209
+ @dataclass
210
+ class _Loaded:
211
+ """Inputs of one batch: Records of the Derivation's Kind and, per hop, the Links and far
212
+ Records."""
213
+
214
+ rows: dict[str, RecordRow]
215
+ links: dict[Hop, dict[str, list[LinkedRecord]]]
216
+
217
+
218
+ class DerivedValues:
219
+ """Evaluates the Derivations of one schema on one branch (see the module docstring)."""
220
+
221
+ def __init__(
222
+ self,
223
+ repository: MaterializedRepository,
224
+ schema: Schema,
225
+ *,
226
+ limits: ExprLimits,
227
+ now: datetime,
228
+ batch: int,
229
+ max_incremental: int,
230
+ fanout: int,
231
+ write: bool = True,
232
+ ) -> None:
233
+ self.repository = repository
234
+ self.schema = schema
235
+ self.limits = limits
236
+ self.now = now
237
+ self.batch = batch
238
+ self.fanout = fanout
239
+ self.max_incremental = max_incremental
240
+ self.write = write
241
+ self.steps = plan_derivations(schema, limits)
242
+ self.expected: dict[RecordKey, dict[str, JsonValue]] = {}
243
+ self.failures = 0
244
+
245
+ @property
246
+ def kinds(self) -> set[str]:
247
+ """Kinds with at least one Derivation."""
248
+ return {step.kind.id for step in self.steps}
249
+
250
+ @property
251
+ def reads_paths(self) -> bool:
252
+ """Whether a Derivation reads a path Derivation (its values follow the fan-out)."""
253
+ return any(hop.path is not None for step in self.steps for hop in step.hops)
254
+
255
+ def searchable(self, kind_id: str) -> bool:
256
+ """Whether a derived Attribute of the Kind is in its search vector."""
257
+ return any(s.kind.id == kind_id and s.attribute.searchable for s in self.steps)
258
+
259
+ # Entry points -----------------------------------------------------------------------------
260
+
261
+ async def recompute_all(self) -> dict[str, set[str]]:
262
+ """Recompute every derived value; returns the keys whose values changed, by Kind."""
263
+ changed: dict[str, set[str]] = defaultdict(set)
264
+ for step in self.steps:
265
+ changed[step.kind.id] |= await self._run(step, None)
266
+ return changed
267
+
268
+ async def prune(self) -> None:
269
+ """Drop stored derived values of Attributes that are no longer derived."""
270
+ keep: dict[str, list[str]] = defaultdict(list)
271
+ for step in self.steps:
272
+ keep[step.kind.id].append(step.attribute.id)
273
+ for kind_id, attributes in sorted(keep.items()):
274
+ await self.repository.prune_derived(kind_id, attributes)
275
+ await self.repository.clear_derived(sorted(keep))
276
+
277
+ async def recompute_step(self, step: DerivationStep, keys: Iterable[str]) -> set[str]:
278
+ """Recompute one Derivation for the Records `keys` of its Kind; returns the keys
279
+ whose value changed. Running every step in order over every key of its Kind is
280
+ `recompute_all`, in pieces a background job can commit one by one."""
281
+ return await self._run(step, keys)
282
+
283
+ async def recompute(self, applied: AppliedChanges) -> dict[str, set[str]]:
284
+ """Recompute the derived values a changeset can affect; returns changed keys by Kind."""
285
+ changed: dict[str, set[str]] = defaultdict(set)
286
+ for step in self.steps:
287
+ kind_id = step.kind.id
288
+ keys = set(applied.written.get(kind_id, ())) | changed[kind_id]
289
+ for hop in step.hops:
290
+ keys |= await self._affected_through(hop, applied, changed)
291
+ keys -= applied.removed.get(kind_id, set())
292
+ if not keys:
293
+ continue
294
+ target = None if len(keys) > self.max_incremental else keys
295
+ changed[kind_id] |= await self._run(step, target)
296
+ return changed
297
+
298
+ async def preview(
299
+ self, attribute_id: str, *, keys: Sequence[str] | None, limit: int
300
+ ) -> list[PreviewedValue]:
301
+ """The values the Derivation of `attribute_id` computes for `keys` (the first `limit`
302
+ Records of its Kind by key when `None`), from the stored and derived values of the
303
+ branch. Nothing is written; Records that do not exist are left out."""
304
+ step = next((s for s in self.steps if s.attribute.id == attribute_id), None)
305
+ if step is None:
306
+ raise ExprError(ExprErrorCode.EVALUATION, "the Derivation cannot be evaluated")
307
+ if keys is None:
308
+ rows = await self.repository.record_page(step.kind.id, after=None, limit=limit)
309
+ batch = [row.key for row in rows]
310
+ else:
311
+ batch = list(dict.fromkeys(keys))[:limit]
312
+ loaded = await self._load(step, batch)
313
+ previewed: list[PreviewedValue] = []
314
+ for key in sorted(loaded.rows):
315
+ row = loaded.rows[key]
316
+ try:
317
+ previewed.append(
318
+ PreviewedValue(key, row.title, await self._compute(step, row, loaded))
319
+ )
320
+ except ExprError as error:
321
+ previewed.append(PreviewedValue(key, row.title, None, error))
322
+ return previewed
323
+
324
+ # Affected Records ---------------------------------------------------------------------------
325
+
326
+ async def _affected_through(
327
+ self, hop: Hop, applied: AppliedChanges, changed: Mapping[str, set[str]]
328
+ ) -> set[str]:
329
+ """Keys of the hop's near Kind whose Links or far Records changed. For a path, the
330
+ Records whose pairs a change of any step can change (`path_affected`)."""
331
+ relation = hop.relation
332
+ forward = hop.direction is LinkDirection.FORWARD
333
+ if hop.path is not None:
334
+ steps = hop.path.steps if forward else reverse_steps(hop.path.steps)
335
+ keys = await path_affected(
336
+ self.repository, steps, applied, fanout=self.fanout, inverse=not forward
337
+ )
338
+ else:
339
+ keys = {f if forward else t for f, t in applied.links.get(relation.id, ())}
340
+ far = hop.far_kind
341
+ far_keys = (
342
+ set(applied.written.get(far, ()))
343
+ | set(applied.removed.get(far, ()))
344
+ | set(changed.get(far, ()))
345
+ )
346
+ if relation.backing is RelationBacking.FOREIGN_KEY:
347
+ keys |= self._foreign_key_targets(hop, applied)
348
+ if far_keys:
349
+ back = Hop(relation, reverse(hop.direction), hop.path)
350
+ for near_keys in (await self._links(back, sorted(far_keys))).values():
351
+ keys.update(far_key for far_key, _ in near_keys)
352
+ return keys
353
+
354
+ def _foreign_key_targets(self, hop: Hop, applied: AppliedChanges) -> set[str]:
355
+ """Targets, before and after the change, of written or removed `from` Records of a
356
+ foreign-key Relation read from its `to` end."""
357
+ relation = hop.relation
358
+ attribute = relation.foreign_key_attribute
359
+ if hop.direction is LinkDirection.FORWARD or attribute is None:
360
+ return set() # the near Records hold the foreign key: they were written
361
+ targets: set[str] = set()
362
+ source = relation.from_kind
363
+ for key in set(applied.written.get(source, ())) | set(applied.removed.get(source, ())):
364
+ before = applied.previous.get((source, key))
365
+ if before is not None and isinstance(before.get(attribute), str):
366
+ targets.add(before[attribute])
367
+ return targets
368
+
369
+ # Loading ----------------------------------------------------------------------------------
370
+
371
+ async def _links(self, hop: Hop, keys: Sequence[str]) -> dict[str, list[tuple[str, Any]]]:
372
+ """`near key -> [(far key, link values)]` of the hop for `keys`."""
373
+ if hop.path is not None:
374
+ return await path_links_of(self.repository, hop.path, hop.direction, keys, self.fanout)
375
+ return await links_of(self.repository, hop.relation, hop.direction, keys)
376
+
377
+ def _inputs(self, kind_id: str, row: RecordRow) -> dict[str, Any]:
378
+ """Stored and derived values of a Record; derived ones from `expected` when not
379
+ writing (verification compares stored values with recomputed ones)."""
380
+ if self.write:
381
+ return row.inputs
382
+ return {**row.values, **self.expected.get((kind_id, row.key), {})}
383
+
384
+ async def _linked(self, hop: Hop, keys: Sequence[str]) -> dict[str, list[LinkedRecord]]:
385
+ links = await self._links(hop, keys)
386
+ far_keys = sorted({far for items in links.values() for far, _ in items})
387
+ rows = await self.repository.records(hop.far_kind, far_keys) if far_keys else {}
388
+ return {
389
+ near: [
390
+ LinkedRecord(far, self._inputs(hop.far_kind, rows[far]), values)
391
+ for far, values in items
392
+ if far in rows
393
+ ]
394
+ for near, items in links.items()
395
+ }
396
+
397
+ async def _load(self, step: DerivationStep, keys: Sequence[str]) -> _Loaded:
398
+ rows = await self.repository.records(step.kind.id, keys)
399
+ present = sorted(rows)
400
+ links = {hop: await self._linked(hop, present) for hop in step.hops}
401
+ return _Loaded(rows, links)
402
+
403
+ # Evaluation -------------------------------------------------------------------------------
404
+
405
+ async def _run(self, step: DerivationStep, keys: Iterable[str] | None) -> set[str]:
406
+ """Recompute one Derivation for `keys` (every Record of its Kind when `None`)."""
407
+ changed: set[str] = set()
408
+ async for batch in self._batches(step.kind.id, keys):
409
+ loaded = await self._load(step, batch)
410
+ updates: list[tuple[str, JsonValue]] = []
411
+ for key in batch:
412
+ row = loaded.rows.get(key)
413
+ if row is None:
414
+ continue
415
+ value = await self._value(step, row, loaded)
416
+ current = self._inputs(step.kind.id, row).get(step.attribute.id)
417
+ if not self.write:
418
+ record = self.expected.setdefault((step.kind.id, key), {})
419
+ if value is not None:
420
+ record[step.attribute.id] = value
421
+ if value != current:
422
+ updates.append((key, value))
423
+ changed.add(key)
424
+ if self.write and updates:
425
+ await self.repository.set_derived(step.kind.id, step.attribute.id, updates)
426
+ return changed
427
+
428
+ async def _batches(self, kind_id: str, keys: Iterable[str] | None) -> AsyncIterator[list[str]]:
429
+ if keys is not None:
430
+ for chunk in chunks(sorted(keys), self.batch):
431
+ yield list(chunk)
432
+ return
433
+ async for rows in self.repository.stream_records(kind_id, batch=self.batch):
434
+ yield [row.key for row in rows]
435
+
436
+ async def _value(self, step: DerivationStep, row: RecordRow, loaded: _Loaded) -> JsonValue:
437
+ try:
438
+ return await self._compute(step, row, loaded)
439
+ except ExprError as error:
440
+ self.failures += 1
441
+ logger.debug(
442
+ "derived value not computed",
443
+ extra={"derivation": step.derivation.id, "key": row.key, "error": str(error)},
444
+ )
445
+ return None
446
+
447
+ async def _compute(self, step: DerivationStep, row: RecordRow, loaded: _Loaded) -> JsonValue:
448
+ """The value of one Record; raises `ExprError` when it cannot be computed."""
449
+ resolver = MemoryResolver()
450
+ for hop, linked in loaded.links.items():
451
+ resolver.links[(hop.relation.id, hop.direction, row.key)] = linked.get(row.key, [])
452
+ context = EvaluationContext(now=self.now, resolver=resolver, limits=self.limits)
453
+ record = Record(kind=step.kind.id, key=row.key, values=self._inputs(step.kind.id, row))
454
+ compiled = step.compiled
455
+ definition = step.derivation.definition
456
+ if isinstance(definition, ExpressionDerivation):
457
+ return await self._loading(
458
+ lambda: evaluate_derivation(compiled, record, context), resolver
459
+ )
460
+ assert isinstance(definition, AggregateDerivation)
461
+ hop = step.hops[0]
462
+ far = self.schema.kinds[hop.far_kind]
463
+ members = []
464
+ for item in loaded.links[hop].get(row.key, []):
465
+ candidate = Record(kind=far.id, key=item.key, values=dict(item.values))
466
+
467
+ def passes(record: Record = candidate) -> bool:
468
+ return matches_filter(compiled, record, context)
469
+
470
+ if await self._loading(passes, resolver):
471
+ members.append(item)
472
+ return aggregate(step.attribute, definition, far, members)
473
+
474
+ async def _loading[T](self, compute: Callable[[], T], resolver: MemoryResolver) -> T:
475
+ """Run an evaluation, loading the Link lists it asks for (filters reading Links of
476
+ aggregated Records), then run it again."""
477
+ for _ in range(self.limits.max_record_lookups + 1):
478
+ try:
479
+ return compute()
480
+ except RecordNotLoaded as missing:
481
+ request = missing.request
482
+ if not isinstance(request, LinksRequest):
483
+ raise ExprError(
484
+ ExprErrorCode.EVALUATION, "Records cannot be read by a Derivation"
485
+ ) from None
486
+ relation = self.schema.link_relation(request.relation)
487
+ if relation is None:
488
+ raise ExprError(
489
+ ExprErrorCode.EVALUATION, f"unknown Relation {request.relation}"
490
+ ) from None
491
+ path = self.schema.path_relations().get(relation.id)
492
+ hop = Hop(relation, request.direction, path)
493
+ linked = await self._linked(hop, [request.key])
494
+ resolver.links[(request.relation, request.direction, request.key)] = linked.get(
495
+ request.key, []
496
+ )
497
+ return compute()
498
+
499
+
500
+ def aggregate(
501
+ target: Attribute,
502
+ definition: AggregateDerivation,
503
+ far: Kind,
504
+ members: Sequence[LinkedRecord],
505
+ ) -> JsonValue:
506
+ """The canonical value of an aggregate over the far Records that passed the filter.
507
+
508
+ `count` counts them; `sum` adds the source values (0 when none); `min`, `max` and `avg`
509
+ have no value without members. Decimal results are rounded to the target's scale.
510
+ """
511
+ if definition.function is AggregateFunction.COUNT:
512
+ return encode_result(target.type, len(members))
513
+ assert definition.source_attribute is not None
514
+ source = far.attributes[definition.source_attribute]
515
+ values: list[Any] = [
516
+ value
517
+ for member in members
518
+ if (value := to_runtime(source.type, member.values.get(source.id))) is not None
519
+ ]
520
+ result: object
521
+ if definition.function is AggregateFunction.SUM:
522
+ start: Any = Decimal(0) if source.type.name is AttributeType.DECIMAL else 0
523
+ result = sum(values, start)
524
+ elif not values:
525
+ return None
526
+ elif definition.function is AggregateFunction.MIN:
527
+ result = min(values)
528
+ elif definition.function is AggregateFunction.MAX:
529
+ result = max(values)
530
+ else:
531
+ total = sum(Decimal(str(v)) for v in values)
532
+ result = total / len(values)
533
+ if target.type.name is AttributeType.DECIMAL and isinstance(result, int | float | Decimal):
534
+ assert target.type.scale is not None
535
+ result = Decimal(str(result)).quantize(Decimal(1).scaleb(-target.type.scale))
536
+ elif target.type.name is AttributeType.INTEGER and isinstance(result, Decimal):
537
+ if result != result.to_integral_value():
538
+ raise ExprError(ExprErrorCode.VALUE_INVALID, f"{result} is not an integer")
539
+ result = int(result)
540
+ return encode_result(target.type, result)