zikaron 0.1.0__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 (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. zikaron-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,294 @@
1
+ """The two external read verbs: `search` (pull) and `surface` (push), each one transaction.
2
+
3
+ `retrieval.md`'s two paths differ in three ways and share everything else. `surface` spends D12's
4
+ five-gist budget on a user prompt and returns **ready-to-print text**, so the hook stays dumb;
5
+ `search` is a deliberate act by the agent, returns structured rows, and is the only path allowed to
6
+ widen eligibility to outright-retired rows. Neither reranks: D23's measured latency curve leaves
7
+ exactly one affordable point on the push path, and reranking five candidates is operationally
8
+ pointless.
9
+
10
+ **Each call is one transaction**, and the instrumentation is inside it: both arms, the `chunk_count`
11
+ the dense arm's coverage is compared against, the pool load, and the `event` rows. A read that
12
+ writes its own instrumentation is a WAL snapshot upgrade, so a write committed by another process
13
+ mid-call refuses the upgrade — which is `store_busy`, retryable, and exactly the stale-snapshot case
14
+ `architecture.md` §Errors defines that code to cover. Any *other* driver failure has no Zikaron code
15
+ and propagates: `index_failed` speaks of index maintenance and a rolled-back write, and a read
16
+ performs neither.
17
+
18
+ `search` mints **no receipt** and returns **no version**, which is the same fact twice. A row the
19
+ agent has only seen as a gist is a row it may not write, and holding a version would imply a licence
20
+ it has not earned — `architecture.md`: "`fetch` is the only way to license a write to a pre-existing
21
+ row, absent an own-write or conflict response for that exact row in this session."
22
+ """
23
+
24
+ import asyncio
25
+ from collections.abc import Sequence
26
+ from dataclasses import dataclass
27
+ from typing import Final
28
+
29
+ import aiosqlite
30
+
31
+ from zikaron.core.errors import ErrorCode, RowState, ZikaronError
32
+ from zikaron.core.events import (
33
+ ArmTermination,
34
+ QueryShape,
35
+ SearchDetail,
36
+ SurfaceCallDetail,
37
+ SurfaceDetail,
38
+ )
39
+ from zikaron.core.indexing.encoder import Encoder
40
+ from zikaron.core.records import memory
41
+ from zikaron.core.records.memory import CallParams, Tier
42
+ from zikaron.core.retrieval import block, query
43
+ from zikaron.core.retrieval.arms import ArmOutcome
44
+ from zikaron.core.retrieval.eligibility import Consumer, Scope
45
+ from zikaron.core.retrieval.query import ExternalQuery
46
+ from zikaron.core.retrieval.ranking import RankedMemory
47
+ from zikaron.core.retrieval.retrieve import RetrievalSettings, retrieve
48
+ from zikaron.core.store import transactions
49
+
50
+ #: `schema.md` §Bounds: the **output** budget, not retrieval depth. Per-arm depth is `fusion_depth`
51
+ #: and lives in configuration precisely so that asking for a different number of results cannot
52
+ #: change the ranking algorithm.
53
+ LIMIT_MIN: Final = 1
54
+ LIMIT_MAX: Final = 50
55
+ LIMIT_DEFAULT: Final = 5
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ReadCall:
60
+ """What both read verbs hold constant: who is calling, the parameters, and the artifact.
61
+
62
+ The same bundling `IndexedCall` uses and for the same reason — every entry point needs all
63
+ three, none changes partway through a call, and keeping them apart invites a call site that
64
+ pairs one store's encoder with another's context.
65
+ """
66
+
67
+ ctx: CallParams
68
+ settings: RetrievalSettings
69
+ encoder: Encoder
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class SearchHit:
74
+ """`zikaron_memory_search`'s per-row payload: exactly the seven fields the tool surface states.
75
+
76
+ `state` is here so triage can see a demoted row for what it is. `version` is not, and that is
77
+ the tool surface's own decision rather than an omission — see this module's docstring.
78
+ """
79
+
80
+ uuid: str
81
+ gist: str
82
+ tier: Tier
83
+ state: RowState
84
+ created_at: str
85
+ updated_at: str
86
+ superseded_by: str | None
87
+
88
+
89
+ def _hit(ranked: RankedMemory) -> SearchHit:
90
+ row = ranked.row
91
+ return SearchHit(
92
+ uuid=row.uuid,
93
+ gist=row.gist,
94
+ tier=row.tier,
95
+ state=row.state,
96
+ created_at=row.created_at,
97
+ updated_at=row.updated_at,
98
+ superseded_by=row.superseded_by,
99
+ )
100
+
101
+
102
+ def _reject_limit(limit: int) -> ZikaronError:
103
+ return ZikaronError(
104
+ ErrorCode.BOUNDS, field="limit", limit=f"{LIMIT_MIN}-{LIMIT_MAX}", actual=limit
105
+ )
106
+
107
+
108
+ def _checked_limit(limit: int) -> int:
109
+ """`limit` if it is within `schema.md` §Bounds, else `bounds` — rung 1 of the ladder.
110
+
111
+ Checked here as well as at whatever boundary deserializes the request, for the reason
112
+ `EffectiveConfig` validates itself: "the only caller checks first" is a fact about today's call
113
+ sites rather than a property of this function, and an unchecked `limit` silently changes how
114
+ much of the pool a caller sees.
115
+ """
116
+ if not LIMIT_MIN <= limit <= LIMIT_MAX:
117
+ raise _reject_limit(limit)
118
+ return limit
119
+
120
+
121
+ def _termination(dense: ArmOutcome, lexical: ArmOutcome) -> ArmTermination:
122
+ """Both arms' depth-and-reason pairs, as the one value the two read events share.
123
+
124
+ One function because the two kinds report the identical four fields in the identical way, and a
125
+ second transcription is how one kind's `dense_stop_reason` comes to mean something the other's
126
+ does not.
127
+ """
128
+ return ArmTermination(
129
+ dense_depth_reached=dense.depth_reached,
130
+ dense_stop_reason=dense.stop_reason,
131
+ lexical_depth_reached=lexical.depth_reached,
132
+ lexical_stop_reason=lexical.stop_reason,
133
+ )
134
+
135
+
136
+ def _query_shape(external: ExternalQuery) -> QueryShape:
137
+ """What the query cost and whether either arm had to give something up to run it."""
138
+ return QueryShape(
139
+ query_tokens=external.query_tokens,
140
+ query_truncated=external.query_truncated,
141
+ lexical_skipped=external.prepared.lexical.skipped,
142
+ )
143
+
144
+
145
+ async def _prepare(text: str, *, call: ReadCall) -> ExternalQuery:
146
+ """Build both arms of an external query, keeping the embedding off the event loop.
147
+
148
+ Outside any transaction, like the write path's own preflight and for the same reason: a cold
149
+ model load costs hundreds of milliseconds, and holding a transaction open across it would make
150
+ every concurrent writer's `busy_timeout` a function of model-load time.
151
+ """
152
+ return await asyncio.to_thread(
153
+ query.external_query,
154
+ text,
155
+ encoder=call.encoder,
156
+ prefix=call.settings.embed_prefix_query,
157
+ max_terms=call.settings.fts_query_max_terms,
158
+ )
159
+
160
+
161
+ def _failure_map(verb: str) -> transactions.FailureMap:
162
+ """Name contention `store_busy` and propagate everything else, per `architecture.md` §Errors.
163
+
164
+ A read has no `index_failed`: that code's contract names index maintenance and a rolled-back
165
+ write, so reusing it here would be a lie in both halves, and inventing a `read_failed` would add
166
+ a code no client can act on differently.
167
+ """
168
+ return lambda error: (
169
+ ZikaronError(ErrorCode.STORE_BUSY, verb=verb) if transactions.is_contention(error) else None
170
+ )
171
+
172
+
173
+ async def search(
174
+ db: aiosqlite.Connection,
175
+ *,
176
+ text: str,
177
+ call: ReadCall,
178
+ limit: int = LIMIT_DEFAULT,
179
+ include_retired: bool = False,
180
+ ) -> tuple[SearchHit, ...]:
181
+ """The pull path: fuse both arms over `text` and return the best `limit` memories.
182
+
183
+ `include_retired` widens eligibility to rows nobody replaced — the deliberate-archaeology case —
184
+ and they arrive demoted rather than ranked normally, because it widens *what may appear*, not
185
+ *what is currently true*. An empty store returns an empty tuple, which is an answer rather than
186
+ an error.
187
+
188
+ Emits exactly one `search` event, inside the same transaction as the read it describes.
189
+
190
+ Raises:
191
+ ZikaronError: `BOUNDS` if `limit` is outside 1-50; `STORE_BUSY` if the store was locked or
192
+ the read's snapshot went stale before its event could be written, which the caller may
193
+ retry; `BAD_SUPERSESSION` if the candidate pool's supersession edges are cyclic.
194
+ """
195
+ budget = _checked_limit(limit)
196
+ external = await _prepare(text, call=call)
197
+
198
+ async def work(connection: aiosqlite.Connection) -> tuple[SearchHit, ...]:
199
+ retrieved = await retrieve(
200
+ connection,
201
+ query=external.prepared,
202
+ scope=Scope(Consumer.SEARCH, include_retired=include_retired),
203
+ settings=call.settings,
204
+ )
205
+ returned = retrieved.pool[:budget]
206
+ await memory.log_event(
207
+ connection,
208
+ ctx=call.ctx,
209
+ detail=SearchDetail(
210
+ query_chars=len(text),
211
+ limit=budget,
212
+ fusion_depth=call.settings.fusion_depth,
213
+ arms=_termination(retrieved.dense, retrieved.lexical),
214
+ query=_query_shape(external),
215
+ include_retired=include_retired,
216
+ n_returned=len(returned),
217
+ uuids=tuple(ranked.row.uuid for ranked in returned),
218
+ ),
219
+ memory_uuid=None,
220
+ )
221
+ return tuple(_hit(ranked) for ranked in returned)
222
+
223
+ return await transactions.in_one_transaction(db, work, failure=_failure_map("search"))
224
+
225
+
226
+ async def _log_surface(
227
+ db: aiosqlite.Connection, *, rows: Sequence[RankedMemory], call: ReadCall
228
+ ) -> None:
229
+ """One `surface` row per returned memory, after the call's own `surface_call` row.
230
+
231
+ Zero rows when nothing was eligible, which is why `surface_call` exists at all: without a
232
+ per-call row a quiet push and a push that never happened would be the same absence of evidence.
233
+ """
234
+ for position, ranked in enumerate(rows, start=1):
235
+ await memory.log_event(
236
+ db,
237
+ ctx=call.ctx,
238
+ detail=SurfaceDetail(
239
+ rank=position,
240
+ fused_score=ranked.fused_score,
241
+ demoted=ranked.demoted,
242
+ demotion=ranked.row.demotion,
243
+ ),
244
+ memory_uuid=ranked.row.uuid,
245
+ )
246
+
247
+
248
+ async def surface(
249
+ db: aiosqlite.Connection, *, prompt: str, call: ReadCall, limit: int = LIMIT_DEFAULT
250
+ ) -> str:
251
+ """The push path: the block to inject for one user message, or the empty string.
252
+
253
+ Takes no `include_retired`: `schema.md` gives it to `search` alone, so push's predicate is the
254
+ base one — under which superseded rows are eligible and demoted, which is what D25's measurement
255
+ requires and what the block's label then explains.
256
+
257
+ Emits one `surface_call` event always, including when nothing was returned, followed by one
258
+ `surface` event per returned memory sharing its `op_id`.
259
+
260
+ Returns:
261
+ Ready-to-print text, or `""` when nothing was eligible — no header, no empty block.
262
+
263
+ Raises:
264
+ ZikaronError: as `search`, minus `include_retired`'s widening.
265
+ """
266
+ budget = _checked_limit(limit)
267
+ external = await _prepare(prompt, call=call)
268
+
269
+ async def work(connection: aiosqlite.Connection) -> str:
270
+ retrieved = await retrieve(
271
+ connection,
272
+ query=external.prepared,
273
+ scope=Scope(Consumer.SURFACE),
274
+ settings=call.settings,
275
+ )
276
+ returned = retrieved.pool[:budget]
277
+ await memory.log_event(
278
+ connection,
279
+ ctx=call.ctx,
280
+ detail=SurfaceCallDetail(
281
+ prompt_chars=len(prompt),
282
+ limit=budget,
283
+ fusion_depth=call.settings.fusion_depth,
284
+ arms=_termination(retrieved.dense, retrieved.lexical),
285
+ query=_query_shape(external),
286
+ n_returned=len(returned),
287
+ n_demoted=sum(1 for ranked in returned if ranked.demoted),
288
+ ),
289
+ memory_uuid=None,
290
+ )
291
+ await _log_surface(connection, rows=returned, call=call)
292
+ return block.render(returned)
293
+
294
+ return await transactions.in_one_transaction(db, work, failure=_failure_map("surface"))
@@ -0,0 +1,158 @@
1
+ """The one retrieval algorithm, run by all five consumers, composable into a caller's transaction.
2
+
3
+ `retrieval.md`: "Every read path — push, pull, write-time dedup, consolidation anchors, orphan edges
4
+ — uses the same **base** predicate and the same algorithm, and adds at most one filter." This module
5
+ is that algorithm: both arms, the pool load, and the ranking, in that order and nowhere else.
6
+
7
+ It **neither begins nor commits a transaction**, for the same reason `records` and `indexing` split
8
+ their verbs that way: D15's dedup search has to run inside the same transaction as the `remember` it
9
+ reports on, since it queries the vectors that write has just inserted. `reads.py` owns the
10
+ transaction for the two external verbs; the internal-query consumers own theirs.
11
+
12
+ What the caller must guarantee is the one thing this module cannot check for itself: that a read
13
+ transaction is already open. Both arms and the pool load then see one snapshot, which is what makes
14
+ the dense arm's coverage comparison a statement about a single store rather than about two.
15
+ """
16
+
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import Final, Self
20
+
21
+ import aiosqlite
22
+
23
+ from zikaron.core.config.resolution import EffectiveConfig
24
+ from zikaron.core.records.memory import Tier
25
+ from zikaron.core.retrieval import arms, ranking
26
+ from zikaron.core.retrieval.arms import ArmOutcome
27
+ from zikaron.core.retrieval.eligibility import Scope
28
+ from zikaron.core.retrieval.query import PreparedQuery
29
+ from zikaron.core.retrieval.ranking import Penalties, PoolRow, RankedMemory
30
+
31
+ #: `memory`'s columns as the read path needs them, in the fixed order `_to_pool_row` unpacks. One
32
+ #: constant rather than a literal per statement, so the row shape has one reader. `content` and
33
+ #: `version` are absent by design — see `PoolRow`.
34
+ _POOL_COLUMNS: Final = "uuid, tier, gist, active, superseded_by, created_at, updated_at"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class RetrievalSettings:
39
+ """Every configuration value the read algorithm consumes, resolved once.
40
+
41
+ Bundled because they travel together for the life of a store handle and because `event` rows
42
+ written by one call must all report the parameters that produced them — `architecture.md` reads
43
+ the config once at startup for exactly that reason, and a per-call re-read would let two events
44
+ of one run disagree about the ranking they describe.
45
+ """
46
+
47
+ fusion_depth: int
48
+ rrf_k: int
49
+ chunk_overfetch: int
50
+ penalties: Penalties
51
+ fts_query_max_terms: int
52
+ embed_prefix_query: str
53
+
54
+ @classmethod
55
+ def from_config(cls, config: EffectiveConfig) -> Self:
56
+ """Read the `[retrieval]` and `[embedding]` keys this algorithm is parameterized by."""
57
+ return cls(
58
+ fusion_depth=config.get_int("fusion_depth"),
59
+ rrf_k=config.get_int("rrf_k"),
60
+ chunk_overfetch=config.get_int("chunk_overfetch"),
61
+ penalties=Penalties(
62
+ superseded=config.get_float("supersession_penalty"),
63
+ retired=config.get_float("retired_penalty"),
64
+ ),
65
+ fts_query_max_terms=config.get_int("fts_query_max_terms"),
66
+ embed_prefix_query=config.get_str("embed_prefix_query"),
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class Retrieved:
72
+ """The full ordered candidate pool, plus what each arm reported about how far it got.
73
+
74
+ **Not cut to any output budget.** The cut is the caller's last step, after the supersession
75
+ repair, which is what lets the repair promote a replacement into the returned set rather than
76
+ only reshuffling what already made it.
77
+ """
78
+
79
+ pool: tuple[RankedMemory, ...]
80
+ dense: ArmOutcome
81
+ lexical: ArmOutcome
82
+
83
+
84
+ def _to_pool_row(row: Sequence[object]) -> PoolRow:
85
+ uuid, tier, gist, active, superseded_by, created_at, updated_at = row
86
+ return PoolRow(
87
+ uuid=str(uuid),
88
+ tier=Tier(str(tier)),
89
+ gist=str(gist),
90
+ active=bool(active),
91
+ superseded_by=None if superseded_by is None else str(superseded_by),
92
+ created_at=str(created_at),
93
+ updated_at=str(updated_at),
94
+ )
95
+
96
+
97
+ async def _load_pool_rows(db: aiosqlite.Connection, uuids: Sequence[str]) -> Mapping[str, PoolRow]:
98
+ """The row facts for every uuid either arm returned, in one statement.
99
+
100
+ A lookup by uuid rather than a predicate path, so it adds no filter and is not one of the five
101
+ consumers: eligibility was already decided inside each arm's own query, against the same
102
+ snapshot this read sees.
103
+ """
104
+ if not uuids:
105
+ return {}
106
+ placeholders = ", ".join("?" for _ in uuids)
107
+ sql = f"SELECT {_POOL_COLUMNS} FROM memory WHERE uuid IN ({placeholders})" # noqa: S608 — placeholders only; every uuid is a bound parameter.
108
+ rows = await db.execute_fetchall(sql, tuple(uuids))
109
+ loaded = [_to_pool_row(tuple(row)) for row in rows]
110
+ return {row.uuid: row for row in loaded}
111
+
112
+
113
+ async def retrieve(
114
+ db: aiosqlite.Connection,
115
+ *,
116
+ query: PreparedQuery,
117
+ scope: Scope,
118
+ settings: RetrievalSettings,
119
+ ) -> Retrieved:
120
+ """Run both arms over `scope`, fuse them, and return the whole ordered pool.
121
+
122
+ Assumes an open transaction. The lexical arm is skipped — and says so, with both of its
123
+ instrumentation fields null — when the query yielded no surviving term; the dense arm always
124
+ runs, so a prompt of pure punctuation still gets an answer.
125
+
126
+ Raises:
127
+ ZikaronError: `BAD_SUPERSESSION` with `reason='cycle'` if the pool's supersession edges
128
+ cannot be topologically ordered, which invariant 6 makes impossible in a well-formed
129
+ store.
130
+ """
131
+ dense = await arms.dense_arm(
132
+ db,
133
+ vector=query.vector,
134
+ scope=scope,
135
+ fusion_depth=settings.fusion_depth,
136
+ chunk_overfetch=settings.chunk_overfetch,
137
+ )
138
+ expression = query.lexical.expression
139
+ lexical = (
140
+ ArmOutcome.skipped(fusion_depth=settings.fusion_depth)
141
+ if expression is None
142
+ else await arms.lexical_arm(
143
+ db, expression=expression, scope=scope, fusion_depth=settings.fusion_depth
144
+ )
145
+ )
146
+ seen: dict[str, None] = {}
147
+ for outcome in (dense, lexical):
148
+ for row in outcome.rows:
149
+ seen.setdefault(row.uuid, None)
150
+ rows = await _load_pool_rows(db, tuple(seen))
151
+ pool = ranking.rank(
152
+ dense=dense,
153
+ lexical=lexical,
154
+ rows=rows,
155
+ rrf_k=settings.rrf_k,
156
+ penalties=settings.penalties,
157
+ )
158
+ return Retrieved(pool=pool, dense=dense, lexical=lexical)
@@ -0,0 +1,87 @@
1
+ """`s(X → Y)`: the one directed score all three cosine cutoffs are defined over.
2
+
3
+ `schema.md` §"Configuration keys" defines it once — **the best cosine between X's first chunk and
4
+ any chunk of Y** — and three keys threshold it: `dedup_threshold` on `s(new row → candidate)`,
5
+ `anchor_cutoff` on `s(entry → record)`, and `orphan_edge_cutoff` on the symmetrized
6
+ `min(s(A → B), s(B → A))`. One definition, so one implementation: a second one that rounded
7
+ differently would put a pair on opposite sides of a floor depending on which caller asked.
8
+
9
+ **It is a mathematical fact about two rows' stored vectors, not a by-product of a search.** The
10
+ fused pool decides *which* rows are candidates at all; this decides whether each clears its floor.
11
+ A row the dense arm's overfetch loop placed within `fusion_depth` already carries its own exact
12
+ minimum distance, because that loop finds the true nearest chunk among however many it probed and
13
+ stops only once it has proven a surplus or covered the index — so that value needs no refining. A
14
+ row the **lexical** arm alone surfaced carries none, and closing that gap is what the batched
15
+ statement below is for. Leaving it open was a real defect once: it silently excluded every
16
+ lexical-only candidate from a threshold test regardless of its true score.
17
+
18
+ **The arithmetic is the pinned extension's, never ours.** `vec_distance_L2` is the same native
19
+ function `vec0`'s KNN path evaluates, measured bit-identical on the same stored vectors. Decoding
20
+ float32 coordinates into Python's binary64 and re-summing them by hand has different rounding
21
+ characteristics, which is exactly how a pair sitting on a floor comes to clear it under one arm's
22
+ scoring and not the other's. Nor the ranked `MATCH`/KNN operator: `vec0`'s `k` is a **global** rank
23
+ cutoff, so aiming it at one named memory's chunks needs a `k` covering however much of the index
24
+ ranks ahead of them, and can return nothing belonging to the memory it was aimed at.
25
+
26
+ Every cosine here is exact **only for unit vectors**, which is why `query.QueryOrigin` exists: the
27
+ write path L2-normalizes the corpus and an internal query reuses a stored chunk vector, so both
28
+ sides are unit length. No external query is ever thresholded.
29
+ """
30
+
31
+ from collections.abc import Mapping, Sequence
32
+
33
+ import aiosqlite
34
+
35
+ from zikaron.core.retrieval.arms import cosine_from_distance
36
+ from zikaron.core.retrieval.query import PreparedQuery
37
+ from zikaron.core.retrieval.ranking import RankedMemory
38
+
39
+
40
+ async def exact_directed_cosines(
41
+ db: aiosqlite.Connection, memory_uuids: Sequence[str], *, query_vector: bytes
42
+ ) -> Mapping[str, float]:
43
+ """`s(query → Y)` for each of `memory_uuids`, over every one of that row's own stored chunks.
44
+
45
+ One statement, grouped by `memory_uuid`, over exactly the rows named — bounded by their own
46
+ chunk counts rather than by store size, and never one statement per row.
47
+
48
+ Assumes the caller's own open transaction, so the vectors read are the same snapshot the
49
+ caller's other reads saw.
50
+
51
+ Returns:
52
+ A cosine per uuid that has at least one stored vector. A uuid absent from the result had
53
+ none — unreachable for any active row, since invariant 12 pairs every one with a chunk, and
54
+ reported as absence rather than as an invented distance.
55
+ """
56
+ if not memory_uuids:
57
+ return {}
58
+ placeholders = ", ".join("?" for _ in memory_uuids)
59
+ sql = (
60
+ "SELECT c.memory_uuid, MIN(vec_distance_L2(v.embedding, ?)) " # noqa: S608 — placeholders only; every uuid is a bound parameter.
61
+ "FROM memory_chunk c "
62
+ "JOIN memory_vec v ON v.rowid = c.chunk_id "
63
+ f"WHERE c.memory_uuid IN ({placeholders}) "
64
+ "GROUP BY c.memory_uuid"
65
+ )
66
+ rows = await db.execute_fetchall(sql, (query_vector, *memory_uuids))
67
+ return {str(uuid): cosine_from_distance(float(distance)) for uuid, distance in rows}
68
+
69
+
70
+ async def directed_cosines(
71
+ db: aiosqlite.Connection, pool: Sequence[RankedMemory], *, query: PreparedQuery
72
+ ) -> Mapping[str, float]:
73
+ """`s(query → Y)` for every row of a fused pool, exact for all of them rather than for some.
74
+
75
+ Reuses the dense arm's own rolled-up minimum distance where it has one and batches a single
76
+ further statement for the rows only the lexical arm surfaced — one call for all of them, not
77
+ one each, because the cost of the missing case must not scale with how many rows are missing it.
78
+ """
79
+ cosines: dict[str, float] = {}
80
+ lexical_only: list[str] = []
81
+ for ranked in pool:
82
+ if ranked.best_distance is not None:
83
+ cosines[ranked.row.uuid] = cosine_from_distance(ranked.best_distance)
84
+ else:
85
+ lexical_only.append(ranked.row.uuid)
86
+ cosines.update(await exact_directed_cosines(db, lexical_only, query_vector=query.vector))
87
+ return cosines
@@ -0,0 +1,34 @@
1
+ """D30's six write-policy signals, each as executable SQL over the committed `event` log."""
2
+
3
+ from zikaron.core.signals.contention import ConflictRate, conflict_rate
4
+ from zikaron.core.signals.dedup import DedupOutcome, DedupResolution, dedup_resolution
5
+ from zikaron.core.signals.repair import RepairCounts, amend_after_surface
6
+ from zikaron.core.signals.retirement import RetireCount, retire_count
7
+ from zikaron.core.signals.sessions import LinkCoverage, link_coverage, linked_session_ids
8
+ from zikaron.core.signals.writes import (
9
+ SessionWrites,
10
+ WriteSizeDistribution,
11
+ WritesPerSession,
12
+ write_size_distribution,
13
+ writes_per_session,
14
+ )
15
+
16
+ __all__ = [
17
+ "ConflictRate",
18
+ "DedupOutcome",
19
+ "DedupResolution",
20
+ "LinkCoverage",
21
+ "RepairCounts",
22
+ "RetireCount",
23
+ "SessionWrites",
24
+ "WriteSizeDistribution",
25
+ "WritesPerSession",
26
+ "amend_after_surface",
27
+ "conflict_rate",
28
+ "dedup_resolution",
29
+ "link_coverage",
30
+ "linked_session_ids",
31
+ "retire_count",
32
+ "write_size_distribution",
33
+ "writes_per_session",
34
+ ]
@@ -0,0 +1,106 @@
1
+ """Signal 6 — version-conflict and no-receipt rate, over observable receipt-gated mutation calls.
2
+
3
+ `schema.md` §"D30's six signals, as queries", row 6: `count(DISTINCT op_id)` over
4
+ `kind='version_conflict'`, and **separately** over `kind='no_receipt'` — never summed, because a
5
+ conflict means "read something stale" and a missing receipt means "wrote without reading", and
6
+ collapsing the two into one number would erase the distinction the design states as the whole
7
+ reason for reporting them side by side. Both numerators dedup by `op_id` because a single rejected
8
+ call can emit several rows of one kind (`schema.md`: "one row per conflicting uuid" / "one row per
9
+ uuid lacking a receipt"), and a rate whose two sides use different units is not a rate at all — a
10
+ three-row conflicting `merge` naively counted as 3 rows over a 1-call denominator would report 300%.
11
+
12
+ The denominator is `count(DISTINCT op_id)` over three **disjoint** terms: committed calls of the
13
+ five receipt-gated verbs (`amend`, `retire`, `merge`, `promote`, `discard`), plus conflicted calls,
14
+ plus no-receipt calls. Disjoint because a call is rejected at the first rung holding any offender,
15
+ and the version rung precedes the receipt rung in both ladders (invariant 9), so no call emits both
16
+ kinds; and a rejected call commits no mutation, so no call is both committed and rejected.
17
+ `remember` is excluded from every term **by construction, not filtered out**: it names no
18
+ pre-existing row, so neither ladder rung applies to it and it cannot appear in either numerator —
19
+ admitting it into the denominator would divide contention by write volume, which signal 1 already
20
+ measures on purpose.
21
+ """
22
+
23
+ from dataclasses import dataclass
24
+
25
+ import aiosqlite
26
+
27
+ from zikaron.core.events import EventKind
28
+
29
+ #: The five verbs that name a pre-existing row at a version, and so are the only calls a version
30
+ #: conflict or a missing receipt can be rejected from. `schema.md`'s own list, transcribed once.
31
+ _RECEIPT_GATED_KINDS: tuple[EventKind, ...] = (
32
+ EventKind.AMEND,
33
+ EventKind.RETIRE,
34
+ EventKind.MERGE,
35
+ EventKind.PROMOTE,
36
+ EventKind.DISCARD,
37
+ )
38
+
39
+ _COUNT_DISTINCT_OP_IDS = "SELECT COUNT(DISTINCT op_id) FROM event WHERE kind IN ({placeholders})"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ConflictRate:
44
+ """Both numerators and the shared denominator, each already a call count via `DISTINCT op_id`.
45
+
46
+ `n_version_conflict` and `n_no_receipt` are reported as a pair, never summed — matching the
47
+ design's own instruction not to collapse them.
48
+ """
49
+
50
+ n_version_conflict: int
51
+ n_no_receipt: int
52
+ n_committed: int
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.n_version_conflict < 0 or self.n_no_receipt < 0 or self.n_committed < 0:
56
+ raise ValueError("call counts must be non-negative")
57
+
58
+ @property
59
+ def n_calls(self) -> int:
60
+ """The full denominator: committed plus both rejection populations, which are disjoint by
61
+ the ladder ordering the design states, so this sum double-counts nothing."""
62
+ return self.n_committed + self.n_version_conflict + self.n_no_receipt
63
+
64
+ @property
65
+ def version_conflict_rate(self) -> float | None:
66
+ """`n_version_conflict / n_calls`, or `None` over an empty denominator.
67
+
68
+ Bounded in `[0, 1]` by construction: `n_version_conflict` is one disjoint term the
69
+ denominator sums, so it can never exceed the sum it is one addend of.
70
+ """
71
+ if self.n_calls == 0:
72
+ return None
73
+ return self.n_version_conflict / self.n_calls
74
+
75
+ @property
76
+ def no_receipt_rate(self) -> float | None:
77
+ """`n_no_receipt / n_calls`, or `None` over an empty denominator. Bounded as above."""
78
+ if self.n_calls == 0:
79
+ return None
80
+ return self.n_no_receipt / self.n_calls
81
+
82
+
83
+ async def conflict_rate(db: aiosqlite.Connection) -> ConflictRate:
84
+ """The version-conflict and no-receipt rates over every observable receipt-gated call.
85
+
86
+ Three independent `COUNT(DISTINCT op_id)` queries rather than one query with three `CASE`
87
+ branches, because each term counts distinct `op_id`s **within its own `kind` filter** — a call
88
+ that emitted three `version_conflict` rows must count once in that term and the committed term
89
+ must not see it at all, which a single shared `GROUP BY op_id` would make harder to state
90
+ correctly than three separate, disjoint-by-ladder-ordering counts.
91
+ """
92
+ committed_placeholders = ", ".join("?" for _ in _RECEIPT_GATED_KINDS)
93
+ committed_sql = _COUNT_DISTINCT_OP_IDS.format(placeholders=committed_placeholders)
94
+ committed_rows = list(
95
+ await db.execute_fetchall(committed_sql, tuple(kind.value for kind in _RECEIPT_GATED_KINDS))
96
+ )
97
+ conflict_sql = _COUNT_DISTINCT_OP_IDS.format(placeholders="?")
98
+ conflict_rows = list(
99
+ await db.execute_fetchall(conflict_sql, (EventKind.VERSION_CONFLICT.value,))
100
+ )
101
+ no_receipt_rows = list(await db.execute_fetchall(conflict_sql, (EventKind.NO_RECEIPT.value,)))
102
+ return ConflictRate(
103
+ n_version_conflict=int(conflict_rows[0][0]),
104
+ n_no_receipt=int(no_receipt_rows[0][0]),
105
+ n_committed=int(committed_rows[0][0]),
106
+ )