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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|