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,200 @@
|
|
|
1
|
+
"""Signals 1 and 5 — writes per session and the zero-write rate; the write size distribution.
|
|
2
|
+
|
|
3
|
+
**Signal 1.** `schema.md` §"D30's six signals, as queries", row 1: per `session_id`,
|
|
4
|
+
`count(kind='remember')` and `count(kind='amend')` reported separately and summed, over a
|
|
5
|
+
denominator of `count(DISTINCT session_id)` restricted to **linked** sessions (`signals.sessions`) —
|
|
6
|
+
every session the service saw through both clients, whether or not it wrote. A linked session with a
|
|
7
|
+
`surface_call` and no `remember`/`amend` is a zero-write session; an unlinked one is excluded rather
|
|
8
|
+
than counted as zero-write, because an unlinked *writing* session would otherwise be miscounted as
|
|
9
|
+
one — the exact failure `write-policy.md` §3 names as the one to engineer against, since it biases
|
|
10
|
+
the rate in the direction that would confirm the under-writing prior this signal exists to test.
|
|
11
|
+
|
|
12
|
+
Reports **both halves of D11's loop from the write side**: `remember` volume alone answers "is the
|
|
13
|
+
agent recording at all", and the retire signal (`signals.retirement`) answers the repair half this
|
|
14
|
+
one does not — a session with plenty of `remember` and no `amend` is not yet evidence that repair
|
|
15
|
+
never fires, since a surfaced memory repaired by outright `retire` is legitimate under D11 and D16
|
|
16
|
+
and belongs to a different signal.
|
|
17
|
+
|
|
18
|
+
**Signal 5**, kept in this module rather than a separate one: both signals aggregate the same
|
|
19
|
+
authored-write events (`remember`, `amend`, and the authoring rows of `merge`/`promote`), and a
|
|
20
|
+
write's size is one further fact about the same write population signal 1 already counts — not a
|
|
21
|
+
fact about a different event population.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
import aiosqlite
|
|
27
|
+
|
|
28
|
+
from zikaron.core.events import EventKind
|
|
29
|
+
from zikaron.core.signals.sessions import LinkCoverage, link_coverage, linked_session_ids
|
|
30
|
+
|
|
31
|
+
_PER_SESSION_COUNTS = """
|
|
32
|
+
SELECT session_id,
|
|
33
|
+
SUM(CASE WHEN kind = ? THEN 1 ELSE 0 END) AS n_remember,
|
|
34
|
+
SUM(CASE WHEN kind = ? THEN 1 ELSE 0 END) AS n_amend
|
|
35
|
+
FROM event
|
|
36
|
+
WHERE session_id IN ({placeholders})
|
|
37
|
+
AND kind IN (?, ?)
|
|
38
|
+
GROUP BY session_id
|
|
39
|
+
"""
|
|
40
|
+
#: The two `AS` labels above are documentation, not a wire the row is read through:
|
|
41
|
+
#: `writes_per_session` below unpacks each row positionally (`session_id, n_remember, n_amend`),
|
|
42
|
+
#: so which count is `n_remember` is decided by which `?` value binds to the first `CASE WHEN` at
|
|
43
|
+
#: parameter-construction time, never by the label text. Stated here because that makes the label
|
|
44
|
+
#: itself unable to cause or catch a defect, which a mutation on it would otherwise look like.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class SessionWrites:
|
|
49
|
+
"""One linked session's write counts. A session absent from the per-session query made
|
|
50
|
+
neither call, so both counts are `0` for it — the zero-write case this signal exists to find."""
|
|
51
|
+
|
|
52
|
+
session_id: str
|
|
53
|
+
n_remember: int
|
|
54
|
+
n_amend: int
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def n_writes(self) -> int:
|
|
58
|
+
"""`remember` and `amend` summed, per the design's "reported separately and summed"."""
|
|
59
|
+
return self.n_remember + self.n_amend
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def wrote_nothing(self) -> bool:
|
|
63
|
+
return self.n_writes == 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class WritesPerSession:
|
|
68
|
+
"""The full signal: every linked session's write counts, plus the coverage the cross-client
|
|
69
|
+
join depends on.
|
|
70
|
+
|
|
71
|
+
`n_linked_sessions` is the rate's own denominator, restated on the type rather than left to be
|
|
72
|
+
recomputed from `len(sessions)` — `sessions` already omits nothing, so the two do agree, but a
|
|
73
|
+
caller reading only this field should not have to know that to trust it names the denominator.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
sessions: tuple[SessionWrites, ...]
|
|
77
|
+
link: LinkCoverage
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def n_linked_sessions(self) -> int:
|
|
81
|
+
return len(self.sessions)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def n_zero_write_sessions(self) -> int:
|
|
85
|
+
return sum(1 for session in self.sessions if session.wrote_nothing)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def zero_write_rate(self) -> float | None:
|
|
89
|
+
"""`n_zero_write_sessions / n_linked_sessions`, or `None` over an empty denominator.
|
|
90
|
+
|
|
91
|
+
Bounded in `[0, 1]` by construction: the numerator counts a subset of the exact rows the
|
|
92
|
+
denominator counts (a session cannot be zero-write and also outside `sessions`, since every
|
|
93
|
+
linked session appears in `sessions` whether or not it wrote), so it can never exceed the
|
|
94
|
+
denominator it is drawn from.
|
|
95
|
+
"""
|
|
96
|
+
if self.n_linked_sessions == 0:
|
|
97
|
+
return None
|
|
98
|
+
return self.n_zero_write_sessions / self.n_linked_sessions
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def total_remember(self) -> int:
|
|
102
|
+
return sum(session.n_remember for session in self.sessions)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def total_amend(self) -> int:
|
|
106
|
+
return sum(session.n_amend for session in self.sessions)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def writes_per_session(db: aiosqlite.Connection) -> WritesPerSession:
|
|
110
|
+
"""Every linked session's `remember`/`amend` counts, with link coverage.
|
|
111
|
+
|
|
112
|
+
A linked session that made no `remember` and no `amend` call is absent from the SQL result
|
|
113
|
+
(`GROUP BY` produces no row for a group with nothing to group), so it is added back explicitly
|
|
114
|
+
with both counts `0` — the join must not silently drop the case this whole signal is for.
|
|
115
|
+
"""
|
|
116
|
+
linked = await linked_session_ids(db)
|
|
117
|
+
counted: dict[str, SessionWrites] = {}
|
|
118
|
+
if linked:
|
|
119
|
+
placeholders = ", ".join("?" for _ in linked)
|
|
120
|
+
sql = _PER_SESSION_COUNTS.format(placeholders=placeholders)
|
|
121
|
+
params = (
|
|
122
|
+
EventKind.REMEMBER.value,
|
|
123
|
+
EventKind.AMEND.value,
|
|
124
|
+
*linked,
|
|
125
|
+
EventKind.REMEMBER.value,
|
|
126
|
+
EventKind.AMEND.value,
|
|
127
|
+
)
|
|
128
|
+
rows = await db.execute_fetchall(sql, params)
|
|
129
|
+
for session_id, n_remember, n_amend in rows:
|
|
130
|
+
counted[str(session_id)] = SessionWrites(
|
|
131
|
+
session_id=str(session_id), n_remember=int(n_remember), n_amend=int(n_amend)
|
|
132
|
+
)
|
|
133
|
+
sessions = tuple(
|
|
134
|
+
counted.get(session_id, SessionWrites(session_id=session_id, n_remember=0, n_amend=0))
|
|
135
|
+
for session_id in sorted(linked)
|
|
136
|
+
)
|
|
137
|
+
return WritesPerSession(sessions=sessions, link=await link_coverage(db))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Signal 5 — write size distribution
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
#
|
|
144
|
+
# Kept in this module rather than a separate one: both signals aggregate the same authored-write
|
|
145
|
+
# events (`remember`, `amend`, and the authoring rows of `merge`/`promote`), and a write's size is
|
|
146
|
+
# one further fact about the same write count 1 already found — not a fact about a different event
|
|
147
|
+
# population.
|
|
148
|
+
|
|
149
|
+
_AUTHORED_SIZES = """
|
|
150
|
+
SELECT id, json_extract(detail, '$.token_count') AS token_count
|
|
151
|
+
FROM event
|
|
152
|
+
WHERE kind IN (?, ?)
|
|
153
|
+
UNION ALL
|
|
154
|
+
SELECT id, json_extract(detail, '$.token_count') AS token_count
|
|
155
|
+
FROM event
|
|
156
|
+
WHERE kind = ? AND json_extract(detail, '$.role') = 'target'
|
|
157
|
+
UNION ALL
|
|
158
|
+
SELECT id, json_extract(detail, '$.token_count') AS token_count
|
|
159
|
+
FROM event
|
|
160
|
+
WHERE kind = ? AND json_extract(detail, '$.role') IN ('created', 'flipped')
|
|
161
|
+
ORDER BY id
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class WriteSizeDistribution:
|
|
167
|
+
"""`token_count` from every write that authored prose, in `event.id` order.
|
|
168
|
+
|
|
169
|
+
A distribution, not a rate (`schema.md`: "n/a (a distribution, not a rate)"), so this carries
|
|
170
|
+
the raw values rather than a summary statistic — histogramming, percentiles or a mean are a
|
|
171
|
+
caller's choice, and baking one in would answer a question about the shape of the data with a
|
|
172
|
+
single number chosen before the shape was known. Ordered by the underlying `event.id` (the
|
|
173
|
+
query's own `ORDER BY`, applied over the whole three-branch `UNION`) so two reads of one store
|
|
174
|
+
return byte-identical results, matching every other signal's own determinism guarantee rather
|
|
175
|
+
than leaving `UNION ALL`'s branch order to be assumed.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
values: tuple[int, ...]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def write_size_distribution(db: aiosqlite.Connection) -> WriteSizeDistribution:
|
|
182
|
+
"""`token_count` from every write that authored prose: `remember`, `amend`, a merge's `target`
|
|
183
|
+
row, and a promotion's `created`/`flipped` row.
|
|
184
|
+
|
|
185
|
+
An absorbed `merge` row and every non-`created`/`flipped` `promote` row authored no prose, so
|
|
186
|
+
the design states their `token_count` as null (`schema.md`'s nullability table) rather than
|
|
187
|
+
absent — filtering by `role` here is what a query over `token_count IS NOT NULL` would do
|
|
188
|
+
implicitly, made explicit because the role is the actual reason those rows are excluded, not an
|
|
189
|
+
incidental correlate of it.
|
|
190
|
+
"""
|
|
191
|
+
rows = await db.execute_fetchall(
|
|
192
|
+
_AUTHORED_SIZES,
|
|
193
|
+
(
|
|
194
|
+
EventKind.REMEMBER.value,
|
|
195
|
+
EventKind.AMEND.value,
|
|
196
|
+
EventKind.MERGE.value,
|
|
197
|
+
EventKind.PROMOTE.value,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
return WriteSizeDistribution(values=tuple(int(token_count) for _id, token_count in rows))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Schema creation, open/close, migration posture, and file permissions."""
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""How Zikaron opens a SQLite database, for every database it owns.
|
|
2
|
+
|
|
3
|
+
Two live today — the memory store and one file per knowledge base — and they differ in their
|
|
4
|
+
schemas, not in how a connection is established: both need `sqlite-vec` loaded, both need their own
|
|
5
|
+
pragmas applied, and both need a failure between connecting and finishing that setup to close the
|
|
6
|
+
connection rather than abandon it. Keeping one implementation is what stops the second database
|
|
7
|
+
growing a subtly different opener; the alternative was already measured to be expensive in this
|
|
8
|
+
project, where an unclosed `aiosqlite` connection keeps its process alive with nothing printed.
|
|
9
|
+
|
|
10
|
+
**Two things vary between callers, and both are parameters rather than assumptions.**
|
|
11
|
+
|
|
12
|
+
- **How a failed *connect* is named** — the same split `transactions.FailureMap` makes, for the same
|
|
13
|
+
reason. A memory store that will not open is a configuration error naming the store directory; a
|
|
14
|
+
knowledge-base database that will not open is a reportable state of one corpus, and not an error
|
|
15
|
+
at all when the file is simply absent.
|
|
16
|
+
- **Which pragmas to apply.** The two schemas do not want the same set, and the difference is
|
|
17
|
+
load-bearing rather than cosmetic: the knowledge schema declares no foreign key anywhere, so
|
|
18
|
+
`PRAGMA foreign_keys = ON` there would advertise a cascade that does not exist — which is the
|
|
19
|
+
precise wrong mental model its design spends a paragraph refusing. The set is therefore supplied
|
|
20
|
+
by whoever owns the schema, and there is deliberately **no default**: a caller that did not say
|
|
21
|
+
would get whichever set this module happened to import, which is exactly how a connection came to
|
|
22
|
+
carry a pragma its own design forbids.
|
|
23
|
+
|
|
24
|
+
Everything *after* the connect — the extension, the pragmas — raises the driver's own error for
|
|
25
|
+
every caller, because a database that opened and then refused a pragma is the same fault whoever
|
|
26
|
+
asked for it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import threading
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Final
|
|
34
|
+
from urllib.parse import quote
|
|
35
|
+
|
|
36
|
+
import aiosqlite
|
|
37
|
+
import sqlite_vec
|
|
38
|
+
|
|
39
|
+
#: How a caller names a failed connect. Takes the driver's error and returns the exception to
|
|
40
|
+
#: raise in its place, with the original kept as the cause.
|
|
41
|
+
type ConnectFailure = Callable[[aiosqlite.Error], Exception]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: How long to wait for the worker thread of a *failed* connect to finish. It is draining a single
|
|
45
|
+
#: queued sentinel, which takes microseconds; this bound exists so a wedged thread cannot hang a
|
|
46
|
+
#: caller rather than as a duration anything is expected to spend.
|
|
47
|
+
_ABANDONED_WORKER_JOIN_SECONDS: Final = 5.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def join_abandoned_worker(connector: aiosqlite.Connection) -> None:
|
|
51
|
+
"""Wait for the worker thread of a connect that failed, so it cannot outlive this event loop.
|
|
52
|
+
|
|
53
|
+
**Why this is needed, reproduced before it was written.** `aiosqlite.connect()` starts a worker
|
|
54
|
+
thread, and a failed connect leaves it mid-shutdown: the library's own failure path calls
|
|
55
|
+
`stop()`, which *queues* a sentinel and returns without waiting. If the event loop closes before
|
|
56
|
+
the thread drains that sentinel — which is exactly what happens when a caller catches the error
|
|
57
|
+
and returns, the situation every caller of this function is in — the thread tries to deliver the
|
|
58
|
+
sentinel's result through `call_soon_threadsafe` on a dead loop, raises `RuntimeError: Event
|
|
59
|
+
loop is closed`, fails identically while trying to report that, and dies with the exception
|
|
60
|
+
unhandled. A test run surfaces it as `PytestUnhandledThreadExceptionWarning`.
|
|
61
|
+
|
|
62
|
+
**What that loses is the thread, and nothing else.** A connect that *failed* never produced a
|
|
63
|
+
`sqlite3.Connection` for anybody to drop — the driver raised instead of returning one, and the
|
|
64
|
+
stop the library queues behind it therefore finds no handle to close either. So this path
|
|
65
|
+
cannot leave a database open, and an `unclosed database` warning seen near it has a different
|
|
66
|
+
cause. *Superseded, in place: this docstring previously said the dropped connection "surfaces
|
|
67
|
+
separately as `ResourceWarning: unclosed database`", and the surrounding record called it one
|
|
68
|
+
bug with two symptoms. It is one symptom; the warnings counted alongside it came from raw
|
|
69
|
+
`sqlite3` connections in test helpers and were fixed separately.*
|
|
70
|
+
|
|
71
|
+
The number this is worth is in `test_store_connection.py`, which keeps the measurement as a
|
|
72
|
+
test because nothing else in the gate can tell the two states apart: coverage sees both
|
|
73
|
+
branches execute either way, and the matrix's warning filter errors only on deprecations.
|
|
74
|
+
|
|
75
|
+
The join runs off the event loop, so a thread that never exits costs this bound rather than
|
|
76
|
+
blocking the loop. `_thread` is private, and deliberately so — the library exposes no other way
|
|
77
|
+
to wait for a worker it has already told to stop, and `stop()`'s own return value is a future on
|
|
78
|
+
the loop that is about to close, which is the thing that cannot be relied on here.
|
|
79
|
+
|
|
80
|
+
**Absence is tolerated for a substitute only, and the type is what draws that line.** A test
|
|
81
|
+
that replaces `aiosqlite.connect` gets a plain coroutine with no worker behind it, and there is
|
|
82
|
+
genuinely nothing to wait for; the first version of this assumed the attribute and broke such a
|
|
83
|
+
test, which is how the tolerance was arrived at. A real `aiosqlite.Connection` that stopped
|
|
84
|
+
keeping its thread here is the opposite case — the wait would quietly become a no-op and the
|
|
85
|
+
defect would come back with nothing to announce it — so the check is on the type rather than on
|
|
86
|
+
mere presence, and `test_store_connection.py` pins the attribute against the installed library
|
|
87
|
+
so a version that moves it turns the matrix red instead of disabling this.
|
|
88
|
+
"""
|
|
89
|
+
worker = getattr(connector, "_thread", None)
|
|
90
|
+
if not isinstance(worker, threading.Thread):
|
|
91
|
+
return
|
|
92
|
+
await asyncio.to_thread(worker.join, _ABANDONED_WORKER_JOIN_SECONDS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _load_sqlite_vec(db: aiosqlite.Connection) -> None:
|
|
96
|
+
"""Load the `sqlite-vec` extension on this connection, through `aiosqlite`'s own method.
|
|
97
|
+
|
|
98
|
+
`aiosqlite.Connection.load_extension` is the sanctioned path: the wrapped `sqlite3`
|
|
99
|
+
connection lives on `aiosqlite`'s own dedicated worker thread, so reaching into it directly
|
|
100
|
+
to call `sqlite_vec.load()` raises from the caller's thread instead.
|
|
101
|
+
"""
|
|
102
|
+
await db.enable_load_extension(True)
|
|
103
|
+
try:
|
|
104
|
+
await db.load_extension(sqlite_vec.loadable_path())
|
|
105
|
+
finally:
|
|
106
|
+
await db.enable_load_extension(False)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def open_connection(
|
|
110
|
+
db_path: Path,
|
|
111
|
+
*,
|
|
112
|
+
pragmas: Sequence[str],
|
|
113
|
+
existing_only: bool = False,
|
|
114
|
+
connect_failure: ConnectFailure | None = None,
|
|
115
|
+
) -> tuple[aiosqlite.Connection, int]:
|
|
116
|
+
"""Open `db_path` through `aiosqlite`, with the extension loaded and `pragmas` applied.
|
|
117
|
+
|
|
118
|
+
Returns the connection **and** the inode `db_path` resolved to at the moment this call's own
|
|
119
|
+
`aiosqlite.connect()` returned — needed so a caller can hand it onward to whatever later needs
|
|
120
|
+
to detect this exact file being replaced out from under an already-open connection
|
|
121
|
+
(`zikaron.service.lifecycle`'s inode-drift self-stop).
|
|
122
|
+
|
|
123
|
+
Never bare `sqlite3`: a handler that called it directly could hold the single-threaded event
|
|
124
|
+
loop for as long as SQLite's own `busy_timeout` retries, which is a genuine, previously
|
|
125
|
+
reproduced self-inflicted deadlock rather than a style concern. `aiosqlite` closes that
|
|
126
|
+
structurally by running the connection on its own thread.
|
|
127
|
+
|
|
128
|
+
A failure loading the extension or applying a pragma closes the connection before
|
|
129
|
+
propagating: `aiosqlite.connect` succeeding is not this function succeeding, and a caller
|
|
130
|
+
that only wraps its *own* work in `try`/`except` would otherwise be handed nothing to close
|
|
131
|
+
when the failure happened here instead.
|
|
132
|
+
|
|
133
|
+
**A known, deliberately accepted gap, settled on human authority rather than closed by
|
|
134
|
+
further engineering — read this before "improving" the capture below.** Several attempts
|
|
135
|
+
were made to close a much narrower race than the one this baseline actually needs to defend
|
|
136
|
+
against: a replacement landing in the specific, sub-millisecond window inside `aiosqlite`'s
|
|
137
|
+
own cross-thread connect handoff, between SQLite binding to a file on its worker thread and
|
|
138
|
+
this coroutine resuming to read the path. Pinning a file descriptor and connecting through
|
|
139
|
+
its own `/proc/self/fd/<n>` path — reasoned to sidestep pathname resolution entirely — was
|
|
140
|
+
measured, empirically, to *not* actually do so: `PRAGMA database_list` shows SQLite
|
|
141
|
+
canonicalizes that magic-symlink path back to the ordinary pathname internally, and a file
|
|
142
|
+
replaced at the path while an existing connection is live can make even an *already
|
|
143
|
+
established* connection fail on its next statement — meaning the assumption the whole
|
|
144
|
+
mechanism rested on was false, not merely incompletely implemented. Closing this properly
|
|
145
|
+
would require controlling SQLite's own VFS-level file handle directly (a custom VFS or
|
|
146
|
+
file-control integration), which is a materially larger undertaking than the value warrants
|
|
147
|
+
for a race with this shape: it requires an adversarial replacement to land inside a
|
|
148
|
+
sub-millisecond window at process startup, not the ordinary case this mechanism exists for
|
|
149
|
+
(a store deleted and recreated while the service has been sitting open and idle, which the
|
|
150
|
+
lifecycle poll closes completely). **The operator's own explicit direction is to accept this
|
|
151
|
+
narrow gap rather than pursue that undertaking**, and to keep the capture simple: read
|
|
152
|
+
`db_path.stat()` once, immediately after `await aiosqlite.connect()` returns, with no
|
|
153
|
+
`await` between the connect and the read — not provably instantaneous with SQLite's own
|
|
154
|
+
internal bind, but the tightest capture available without the VFS-level work this decision
|
|
155
|
+
declines, and correct for every case except the one named above.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
db_path: the database file to open.
|
|
159
|
+
pragmas: the pragmas this database's own schema requires, applied in order. Required, not
|
|
160
|
+
defaulted: the two schemas want different sets, and a default is how one of them comes
|
|
161
|
+
to be opened under the other's.
|
|
162
|
+
existing_only: refuse to create `db_path` if it is absent, by opening it through a
|
|
163
|
+
`file:…?mode=rw` URI. The caller is left to say what absence means: for the memory
|
|
164
|
+
store it is a store that was never created, and for a knowledge base it is an empty
|
|
165
|
+
corpus, which is not an error at all.
|
|
166
|
+
connect_failure: what to raise when the connect itself fails. Omitted, the driver's own
|
|
167
|
+
error propagates. It is scoped to the connect deliberately — a caller naming a
|
|
168
|
+
missing store cannot also name a failed pragma, which describes a different fault.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
Exception: whatever `connect_failure` returns, if the connect failed and one was given.
|
|
172
|
+
aiosqlite.Error: the connect failed with no `connect_failure` given, or the extension
|
|
173
|
+
load or a pragma failed.
|
|
174
|
+
OSError: `db_path` could not be `stat`ed once the connection was established.
|
|
175
|
+
"""
|
|
176
|
+
if existing_only:
|
|
177
|
+
connector = aiosqlite.connect(f"file:{quote(str(db_path))}?mode=rw", uri=True)
|
|
178
|
+
else:
|
|
179
|
+
connector = aiosqlite.connect(db_path)
|
|
180
|
+
try:
|
|
181
|
+
db = await connector
|
|
182
|
+
except aiosqlite.Error as error:
|
|
183
|
+
await join_abandoned_worker(connector)
|
|
184
|
+
if connect_failure is None:
|
|
185
|
+
raise
|
|
186
|
+
raise connect_failure(error) from error
|
|
187
|
+
try:
|
|
188
|
+
# The very first statement inside this `try`, with no `await` before it — a failure here
|
|
189
|
+
# (a permission error, the path having become unstatable) must close the just-established
|
|
190
|
+
# `db` exactly like a pragma failure a few lines below does; reading it *outside* this
|
|
191
|
+
# block would abandon a real, worker-thread-backed connection with nothing left to close
|
|
192
|
+
# it, which is a genuine resource leak this project's own history has already paid for
|
|
193
|
+
# once: a suite whose tests all completed in 0.76 s then hung indefinitely with an empty
|
|
194
|
+
# output pipe, because a non-daemon worker thread is joined before any `atexit` handler.
|
|
195
|
+
opened_inode = db_path.stat().st_ino
|
|
196
|
+
await _load_sqlite_vec(db)
|
|
197
|
+
for pragma in pragmas:
|
|
198
|
+
await db.execute(pragma)
|
|
199
|
+
except BaseException:
|
|
200
|
+
await db.close()
|
|
201
|
+
raise
|
|
202
|
+
return db, opened_inode
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The schema, as data: every `CREATE` statement `schema.md` §Tables specifies.
|
|
2
|
+
|
|
3
|
+
One statement per table or index, in creation order, so a schema change touches this module and
|
|
4
|
+
nothing that executes it. Foreign keys point forward in this list — `memory_chunk` before
|
|
5
|
+
`memory`, for instance, would fail — so the order here **is** the order the store must execute
|
|
6
|
+
them in, and `applier` functions elsewhere must not parallelize or reorder it.
|
|
7
|
+
|
|
8
|
+
Every literal here is a transcription of `schema.md`'s own DDL block; a test reads that block at
|
|
9
|
+
test time and compares, so drift between the design and this module fails a test rather than
|
|
10
|
+
surfacing as a table that silently does not match its own specification.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
#: The three pragmas `schema.md`'s DDL block opens with, applied to every connection this store
|
|
16
|
+
#: opens — creation and every later open alike, since a connection that skipped them would not
|
|
17
|
+
#: see concurrent readers correctly or retry under contention as the design requires.
|
|
18
|
+
PRAGMAS: Final[tuple[str, ...]] = (
|
|
19
|
+
"PRAGMA journal_mode = WAL",
|
|
20
|
+
"PRAGMA busy_timeout = 5000",
|
|
21
|
+
"PRAGMA foreign_keys = ON",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_MEMORY: Final = """
|
|
25
|
+
CREATE TABLE memory (
|
|
26
|
+
rowid INTEGER PRIMARY KEY,
|
|
27
|
+
uuid TEXT NOT NULL UNIQUE,
|
|
28
|
+
tier TEXT NOT NULL DEFAULT 'journal'
|
|
29
|
+
CHECK (tier IN ('journal', 'long_term')),
|
|
30
|
+
gist TEXT NOT NULL,
|
|
31
|
+
content TEXT NOT NULL,
|
|
32
|
+
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
|
33
|
+
superseded_by TEXT REFERENCES memory(uuid) ON DELETE RESTRICT,
|
|
34
|
+
version INTEGER NOT NULL DEFAULT 1 CHECK (version >= 1),
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT NOT NULL,
|
|
37
|
+
session_id TEXT,
|
|
38
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
|
|
40
|
+
CHECK (length(trim(gist)) > 0),
|
|
41
|
+
CHECK (length(trim(content)) > 0),
|
|
42
|
+
CHECK (superseded_by IS NULL OR superseded_by <> uuid),
|
|
43
|
+
CHECK (superseded_by IS NULL OR active = 0)
|
|
44
|
+
)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_MEMORY_INDEXES: Final[tuple[str, ...]] = (
|
|
48
|
+
"CREATE INDEX idx_memory_unconsolidated ON memory(tier, active, created_at)",
|
|
49
|
+
"CREATE INDEX idx_memory_eligible ON memory(active, superseded_by)",
|
|
50
|
+
"CREATE INDEX idx_memory_superseded ON memory(superseded_by)",
|
|
51
|
+
"CREATE INDEX idx_memory_session ON memory(session_id)",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_MEMORY_FTS: Final = """
|
|
55
|
+
CREATE VIRTUAL TABLE memory_fts USING fts5 (
|
|
56
|
+
gist, content,
|
|
57
|
+
content = 'memory',
|
|
58
|
+
content_rowid= 'rowid',
|
|
59
|
+
tokenize = 'unicode61'
|
|
60
|
+
)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_MEMORY_CHUNK: Final = """
|
|
64
|
+
CREATE TABLE memory_chunk (
|
|
65
|
+
chunk_id INTEGER PRIMARY KEY,
|
|
66
|
+
memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE CASCADE,
|
|
67
|
+
part_index INTEGER NOT NULL,
|
|
68
|
+
token_count INTEGER NOT NULL,
|
|
69
|
+
truncated INTEGER NOT NULL DEFAULT 0 CHECK (truncated IN (0, 1)),
|
|
70
|
+
embed_model TEXT NOT NULL,
|
|
71
|
+
embed_dim INTEGER NOT NULL,
|
|
72
|
+
UNIQUE (memory_uuid, part_index)
|
|
73
|
+
)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_MEMORY_CHUNK_INDEX: Final = "CREATE INDEX idx_chunk_memory ON memory_chunk(memory_uuid)"
|
|
77
|
+
|
|
78
|
+
_EVENT: Final = """
|
|
79
|
+
CREATE TABLE event (
|
|
80
|
+
id INTEGER PRIMARY KEY,
|
|
81
|
+
at TEXT NOT NULL,
|
|
82
|
+
session_id TEXT,
|
|
83
|
+
client_kind TEXT NOT NULL CHECK (client_kind IN ('hook', 'mcp', 'consolidator')),
|
|
84
|
+
op_id TEXT NOT NULL,
|
|
85
|
+
kind TEXT NOT NULL CHECK (kind IN (
|
|
86
|
+
'surface_call', 'surface', 'search', 'fetch', 'remember', 'amend', 'retire',
|
|
87
|
+
'merge', 'promote', 'discard', 'dedup_offered', 'version_conflict',
|
|
88
|
+
'no_receipt', 'group_served', 'consolidate_run'
|
|
89
|
+
)),
|
|
90
|
+
memory_uuid TEXT,
|
|
91
|
+
detail TEXT
|
|
92
|
+
)
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_EVENT_INDEXES: Final[tuple[str, ...]] = (
|
|
96
|
+
"CREATE INDEX idx_event_kind_at ON event(kind, at)",
|
|
97
|
+
"CREATE INDEX idx_event_session ON event(session_id)",
|
|
98
|
+
"CREATE INDEX idx_event_session_kind ON event(session_id, client_kind)",
|
|
99
|
+
"CREATE INDEX idx_event_op ON event(op_id)",
|
|
100
|
+
"CREATE INDEX idx_event_uuid_at ON event(memory_uuid, at)",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_READ_RECEIPT: Final = """
|
|
104
|
+
CREATE TABLE read_receipt (
|
|
105
|
+
session_id TEXT NOT NULL,
|
|
106
|
+
client_kind TEXT NOT NULL,
|
|
107
|
+
memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE CASCADE,
|
|
108
|
+
version INTEGER NOT NULL,
|
|
109
|
+
at TEXT NOT NULL,
|
|
110
|
+
source TEXT NOT NULL CHECK (source IN ('fetch', 'group', 'conflict', 'own_write')),
|
|
111
|
+
PRIMARY KEY (session_id, client_kind, memory_uuid, version)
|
|
112
|
+
) WITHOUT ROWID
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
_CONSOLIDATION_RUN: Final = """
|
|
116
|
+
CREATE TABLE consolidation_run (
|
|
117
|
+
run_id TEXT PRIMARY KEY,
|
|
118
|
+
session_id TEXT NOT NULL,
|
|
119
|
+
pid INTEGER NOT NULL,
|
|
120
|
+
started_at TEXT NOT NULL,
|
|
121
|
+
expires_at TEXT NOT NULL,
|
|
122
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'complete', 'expired', 'abandoned',
|
|
123
|
+
'taken_over'))
|
|
124
|
+
)
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
_CONSOLIDATION_GROUP: Final = """
|
|
128
|
+
CREATE TABLE consolidation_group (
|
|
129
|
+
group_id TEXT PRIMARY KEY,
|
|
130
|
+
run_id TEXT NOT NULL REFERENCES consolidation_run(run_id) ON DELETE CASCADE,
|
|
131
|
+
anchor_uuid TEXT REFERENCES memory(uuid) ON DELETE RESTRICT,
|
|
132
|
+
order_key TEXT NOT NULL,
|
|
133
|
+
shard_index INTEGER NOT NULL DEFAULT 1,
|
|
134
|
+
shard_count INTEGER NOT NULL DEFAULT 1,
|
|
135
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'served', 'complete', 'deferred')),
|
|
136
|
+
serve_count INTEGER NOT NULL DEFAULT 0,
|
|
137
|
+
served_at TEXT,
|
|
138
|
+
CHECK (shard_count >= 1),
|
|
139
|
+
CHECK (shard_index BETWEEN 1 AND shard_count),
|
|
140
|
+
CHECK (serve_count >= 0)
|
|
141
|
+
)
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
_CONSOLIDATION_GROUP_INDEX: Final = (
|
|
145
|
+
"CREATE INDEX idx_group_run_order "
|
|
146
|
+
"ON consolidation_group(run_id, status, order_key, shard_index)"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
_CONSOLIDATION_GROUP_MEMBER: Final = """
|
|
150
|
+
CREATE TABLE consolidation_group_member (
|
|
151
|
+
group_id TEXT NOT NULL REFERENCES consolidation_group(group_id) ON DELETE CASCADE,
|
|
152
|
+
memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE RESTRICT,
|
|
153
|
+
version_seen INTEGER NOT NULL,
|
|
154
|
+
version_served INTEGER,
|
|
155
|
+
disposition TEXT CHECK (disposition IN
|
|
156
|
+
('merged', 'promoted', 'discarded', 'vacated')),
|
|
157
|
+
disposed_at TEXT,
|
|
158
|
+
PRIMARY KEY (group_id, memory_uuid)
|
|
159
|
+
) WITHOUT ROWID
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
_CONSOLIDATION_GROUP_MEMBER_INDEX: Final = (
|
|
163
|
+
"CREATE INDEX idx_group_member_open ON consolidation_group_member(group_id, disposition)"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
_CONSOLIDATION_GROUP_CANDIDATE: Final = """
|
|
167
|
+
CREATE TABLE consolidation_group_candidate (
|
|
168
|
+
group_id TEXT NOT NULL REFERENCES consolidation_group(group_id) ON DELETE CASCADE,
|
|
169
|
+
memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE RESTRICT,
|
|
170
|
+
role TEXT NOT NULL CHECK (role IN ('anchor', 'candidate')),
|
|
171
|
+
version_served INTEGER NOT NULL,
|
|
172
|
+
rank INTEGER NOT NULL,
|
|
173
|
+
PRIMARY KEY (group_id, memory_uuid)
|
|
174
|
+
) WITHOUT ROWID
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
_META: Final = "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
178
|
+
|
|
179
|
+
#: Every statement that creates something with **no** dependency on the validated `embed_dim`,
|
|
180
|
+
#: in the exact order `schema.md`'s DDL block presents them — which is also a valid dependency
|
|
181
|
+
#: order, since every `REFERENCES` in this list names a table created earlier in it.
|
|
182
|
+
FIXED_STATEMENTS: Final[tuple[str, ...]] = (
|
|
183
|
+
_MEMORY,
|
|
184
|
+
*_MEMORY_INDEXES,
|
|
185
|
+
_MEMORY_FTS,
|
|
186
|
+
_MEMORY_CHUNK,
|
|
187
|
+
_MEMORY_CHUNK_INDEX,
|
|
188
|
+
_EVENT,
|
|
189
|
+
*_EVENT_INDEXES,
|
|
190
|
+
_READ_RECEIPT,
|
|
191
|
+
_CONSOLIDATION_RUN,
|
|
192
|
+
_CONSOLIDATION_GROUP,
|
|
193
|
+
_CONSOLIDATION_GROUP_INDEX,
|
|
194
|
+
_CONSOLIDATION_GROUP_MEMBER,
|
|
195
|
+
_CONSOLIDATION_GROUP_MEMBER_INDEX,
|
|
196
|
+
_CONSOLIDATION_GROUP_CANDIDATE,
|
|
197
|
+
_META,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def memory_vec_statement(embed_dim: int) -> str:
|
|
202
|
+
"""The one DDL statement `vec0` fixes a width into, at the validated effective `embed_dim`.
|
|
203
|
+
|
|
204
|
+
`schema.md` §"Creating the dense index" requires this width to be validated — and the
|
|
205
|
+
embedder that will fill the column checked against it — before this statement ever runs,
|
|
206
|
+
which is why it is a function rather than a member of `FIXED_STATEMENTS`: nothing may call it
|
|
207
|
+
with an unvalidated value.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
embed_dim: the effective config's `embed_dim`, already checked to be `>= 1` and to equal
|
|
211
|
+
the width the configured embedder actually reports.
|
|
212
|
+
"""
|
|
213
|
+
if embed_dim < 1:
|
|
214
|
+
raise ValueError(f"embed_dim must be >= 1, got {embed_dim}")
|
|
215
|
+
return f"CREATE VIRTUAL TABLE memory_vec USING vec0 (embedding float[{embed_dim}])"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The one thing the store needs from an embedder: its width, and the model name it reports.
|
|
2
|
+
|
|
3
|
+
The store's dimension check must run before `memory_vec` is created, and creating a store embeds
|
|
4
|
+
nothing — so this is deliberately the *narrowest* thing an embedder can be asked for. `fastembed`
|
|
5
|
+
is a dependency of `zikaron-core` as a whole, not of this module: the indexing layer's `Encoder`
|
|
6
|
+
extends this protocol with the tokenizer and embedding calls a write path needs, and the store is
|
|
7
|
+
written against this narrow form so it never depends on them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class Embedder(Protocol):
|
|
16
|
+
"""What the store needs to check before it fixes a `vec0` column's width forever.
|
|
17
|
+
|
|
18
|
+
`vec0` fixes a column's width at `CREATE` time (`schema.md` §"Creating the dense index"), so
|
|
19
|
+
the store must know the width a configured model actually emits before any table exists. A
|
|
20
|
+
real implementation loads the model named by `model_name`; this protocol says nothing about
|
|
21
|
+
*how* `dim` is produced; only that it is available without embedding anything.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def model_name(self) -> str:
|
|
26
|
+
"""The model identifier this embedder reports, for comparison against `meta.embed_model`."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def dim(self) -> int:
|
|
31
|
+
"""The width of every vector this embedder would produce."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class FakeEmbedder:
|
|
37
|
+
"""An `Embedder` that reports a width without loading anything.
|
|
38
|
+
|
|
39
|
+
The store's own tests use this to exercise the width check deterministically and without
|
|
40
|
+
`fastembed`'s cold-start cost; nothing about the store's creation or open path can tell it
|
|
41
|
+
apart from a real one, since both are used purely through the protocol above.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_name: str
|
|
45
|
+
dim: int
|