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,102 @@
1
+ """`chunks_vec` maintenance and the batched embedding that fills it.
2
+
3
+ **A vector is addressed by `chunk_id`, and there is no `rowid` here to fall back on.** The table is
4
+ declared with an explicit `chunk_id INTEGER PRIMARY KEY`, and a `vec0` table declared that way
5
+ exposes no `rowid` column at all — measured: selecting one raises `no such column`. That is the
6
+ opposite of the memory store's vector table, which has no key column and is addressed by `rowid`, so
7
+ the resemblance between the two is a trap rather than a shortcut.
8
+
9
+ **Nothing cascades.** `vec0` takes no foreign key, so a vector outlives the chunk row it belongs to
10
+ unless something deletes it explicitly — and an unidentified vector is a silent false positive in
11
+ retrieval, matching a query and resolving to nothing. The per-file transaction in `writes.py` is the
12
+ only thing that keeps the two in step.
13
+ """
14
+
15
+ import asyncio
16
+ from collections.abc import Sequence
17
+
18
+ import aiosqlite
19
+
20
+ from zikaron.core.errors import ErrorCode, IndexStage, ZikaronError
21
+ from zikaron.core.indexing.encoder import Encoder
22
+ from zikaron.core.indexing.vectors import normalize, serialize
23
+ from zikaron.core.knowledge.chunking import FileChunkPlan
24
+
25
+
26
+ def _reject_embed() -> ZikaronError:
27
+ return ZikaronError(ErrorCode.INDEX_FAILED, stage=IndexStage.EMBED)
28
+
29
+
30
+ async def embed_chunks(
31
+ plan: FileChunkPlan, *, encoder: Encoder, embed_dim: int, batch_size: int
32
+ ) -> tuple[bytes, ...]:
33
+ """Embed every chunk of one file and return the vectors ready for `chunks_vec`.
34
+
35
+ Runs **outside** any transaction, deliberately: inference is the expensive step of a build by an
36
+ order of magnitude, and holding SQLite's single write lock across it would make every concurrent
37
+ reader's `busy_timeout` a function of model speed. Nothing is staged at this point, so a failure
38
+ here loses nothing.
39
+
40
+ **Batched, and the batch size may not change the answer.** Splitting the file's chunks into
41
+ passes of `batch_size` is a throughput decision; a chunk's vector must be the same whether it
42
+ was embedded alone or beside thirty-one others, which holds when pooling is mask-aware and
43
+ fails silently when it is not — the failure being an embedding that depends on what else
44
+ happened to be in the batch.
45
+
46
+ Each pass goes through a worker thread because inference is blocking and CPU-bound, and this is
47
+ a coroutine: running it inline would hold the event loop for the whole pass.
48
+
49
+ Args:
50
+ plan: the chunks to embed, in `part_index` order, carrying the prefix their sequences were
51
+ budgeted against — which is why this takes no path of its own to prepend.
52
+ encoder: the artifact, whose identity the caller has already checked against the corpus.
53
+ embed_dim: the width this knowledge base's `chunks_vec` column was created at.
54
+ batch_size: `knowledge_embed_batch`.
55
+
56
+ Returns:
57
+ One serialized unit-length vector per chunk, in `part_index` order.
58
+
59
+ Raises:
60
+ ZikaronError: `INDEX_FAILED` at stage `embed` if the embedder raises, returns a different
61
+ number of vectors than it was given texts, or returns one of the wrong width — each of
62
+ which would otherwise be stored as a vector that does not describe its chunk.
63
+ """
64
+ texts = plan.embedded_texts()
65
+ produced: list[tuple[float, ...]] = []
66
+ for start in range(0, len(texts), batch_size):
67
+ batch = texts[start : start + batch_size]
68
+ produced.extend(await _embed_one_pass(batch, encoder=encoder))
69
+ return tuple(serialize(normalize(vector, embed_dim=embed_dim)) for vector in produced)
70
+
71
+
72
+ async def _embed_one_pass(
73
+ texts: Sequence[str], *, encoder: Encoder
74
+ ) -> tuple[tuple[float, ...], ...]:
75
+ """One forward pass, with the embedder's own failures named as this layer's."""
76
+ try:
77
+ produced = await asyncio.to_thread(encoder.embed, texts)
78
+ except ZikaronError:
79
+ raise
80
+ except Exception as error:
81
+ raise _reject_embed() from error
82
+ if len(produced) != len(texts):
83
+ raise _reject_embed()
84
+ return produced
85
+
86
+
87
+ async def insert(db: aiosqlite.Connection, *, chunk_id: int, vector: bytes) -> None:
88
+ """Store one chunk's vector under the chunk's own id."""
89
+ await db.execute(
90
+ "INSERT INTO chunks_vec (chunk_id, embedding) VALUES (?, ?)", (chunk_id, vector)
91
+ )
92
+
93
+
94
+ async def delete(db: aiosqlite.Connection, *, chunk_ids: Sequence[int]) -> None:
95
+ """Remove the vectors for `chunk_ids`, which may be empty.
96
+
97
+ One statement per id rather than an `IN` list: `vec0` is a virtual table, and keeping its
98
+ deletes to the primary-key form the extension documents is worth more than saving round trips on
99
+ a set bounded by one file's chunk count.
100
+ """
101
+ for chunk_id in chunk_ids:
102
+ await db.execute("DELETE FROM chunks_vec WHERE chunk_id = ?", (chunk_id,))
@@ -0,0 +1,264 @@
1
+ """The filesystem walk: which files a corpus root offers, before git is asked anything about them.
2
+
3
+ **The walk is the sole authority on which files exist.** Filtering is deny-by-default at the
4
+ directory level and runs in a fixed order, because the order decides which reason a file's absence
5
+ is reported under and a report nobody can reproduce is worse than no report:
6
+
7
+ 1. **Directory pruning**, at the directory node *before descending*. A named build or tool
8
+ directory, or any directory whose name begins with a dot. The name `.git` is pruned whether it
9
+ is a directory or a **file** — a linked worktree's and a submodule's are files holding a
10
+ pointer, and directory pruning never sees those.
11
+ 2. **Symlinks are not followed**, directory or file. A symlink is one refusal, whatever it points
12
+ at: refusing to follow it is precisely declining to find out.
13
+ 3. **`.gitignore`**, and 4. **globs**, and 5. **the size cap** — the first of which needs an answer
14
+ from git, so all three are applied in one pass after the walk rather than inside it.
15
+ 6. **A name-ending deny-list**, the walk-time half of text detection.
16
+
17
+ Steps 1, 2 and the `stat` for step 5 happen during the walk; steps 3 to 6 are applied by `admitted`
18
+ to what the walk found.
19
+
20
+ **What this module never does is read a file.** The whole-file UTF-8 decode that settles text
21
+ detection happens on the first read of a candidate's bytes in a scan, which is either the walk
22
+ phase's hashing read or the index phase's — running it here would read every byte of every
23
+ candidate on every scan and collapse the change-detection fast path into the read-everything path
24
+ it exists to beat.
25
+ """
26
+
27
+ import fnmatch
28
+ import os
29
+ from collections.abc import Iterator, Sequence
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Final
33
+
34
+ from zikaron.core.knowledge import text
35
+ from zikaron.core.knowledge.counters import ScanCounters, SkipReason
36
+
37
+ #: Directories never descended into. Several of these also begin with a dot and would be pruned by
38
+ #: that rule alone; they are named anyway, because the list says what it is *for* — version-control
39
+ #: metadata, dependency trees, build output and tool caches — and would survive the dot rule being
40
+ #: narrowed.
41
+ PRUNED_DIRECTORY_NAMES: Final = frozenset(
42
+ {
43
+ ".git",
44
+ ".hg",
45
+ ".svn",
46
+ "node_modules",
47
+ ".venv",
48
+ "venv",
49
+ "__pycache__",
50
+ "target",
51
+ "dist",
52
+ "build",
53
+ ".next",
54
+ ".tox",
55
+ ".mypy_cache",
56
+ ".pytest_cache",
57
+ }
58
+ )
59
+
60
+ #: The one name pruned as a file as well as a directory. Every other dot-file is admitted.
61
+ _GIT_DIRECTORY_NAME: Final = ".git"
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class WalkRules:
66
+ """What one corpus's own configuration says about which files it wants.
67
+
68
+ Held together rather than passed as three arguments because they are read together, in a fixed
69
+ order, and a caller that supplied two of the three would silently index a different corpus.
70
+ """
71
+
72
+ include_globs: tuple[str, ...] = ()
73
+ exclude_globs: tuple[str, ...] = ()
74
+ max_file_bytes: int = 0
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class Candidate:
79
+ """One file the walk found, with what it cost nothing to learn while finding it.
80
+
81
+ `path` is relative to the corpus root in POSIX form, and is the join key against everything
82
+ else in the system: the `files` table, git's answers, and the globs. It is matched byte-exactly
83
+ everywhere, so where a filesystem's casing differs from git's the two simply fail to meet and
84
+ the file falls to the slower path — never to the wrong answer.
85
+ """
86
+
87
+ path: str
88
+ absolute: Path
89
+ size: int
90
+
91
+
92
+ def is_pruned_directory(name: str) -> bool:
93
+ """Whether a directory of this name is one the walk refuses to descend into."""
94
+ return name in PRUNED_DIRECTORY_NAMES or name.startswith(".")
95
+
96
+
97
+ def _relative(prefix: str, name: str) -> str:
98
+ return f"{prefix}/{name}" if prefix else name
99
+
100
+
101
+ def _is_directory(entry: os.DirEntry[str]) -> bool:
102
+ """Whether this entry is a directory in its own right, never through a symlink.
103
+
104
+ A failed `stat` answers *no*, which routes the entry to the file path where it is refused and
105
+ counted — the one place with a number to report it under.
106
+ """
107
+ try:
108
+ return entry.is_dir(follow_symlinks=False)
109
+ except OSError:
110
+ return False
111
+
112
+
113
+ def _is_symlink(entry: os.DirEntry[str]) -> bool:
114
+ """Whether this entry is a symlink. A failed `stat` answers *no*, and the entry is then
115
+ refused as unreadable a few lines later — which is the more accurate of the two words for an
116
+ entry whose type could not be determined."""
117
+ try:
118
+ return entry.is_symlink()
119
+ except OSError:
120
+ return False
121
+
122
+
123
+ def _child_candidate(entry: os.DirEntry[str], relative_path: str) -> Candidate | None:
124
+ """One directory entry as a candidate, or `None` if it is not a readable regular file."""
125
+ try:
126
+ if not entry.is_file(follow_symlinks=False):
127
+ return None
128
+ size = entry.stat(follow_symlinks=False).st_size
129
+ except OSError:
130
+ return None
131
+ return Candidate(path=relative_path, absolute=Path(entry.path), size=size)
132
+
133
+
134
+ def entries(root: Path, counters: ScanCounters) -> Iterator[Candidate]:
135
+ """Every file under `root` that the directory-level rules admit, depth-first and name-ordered.
136
+
137
+ Ordered because the order is observable: it fixes which files a partially-completed scan has
138
+ committed, and an unordered walk makes two runs over one tree incomparable.
139
+
140
+ Counts as it goes, and the two it can only count here are the two nothing downstream will ever
141
+ see: a symlink, which is refused whatever it points at, and an entry that could not be taken as
142
+ a readable regular file — a device or a pipe, a file whose `stat` failed, or a **directory that
143
+ could not be listed**. That last one is counted among the skipped files deliberately: the walk
144
+ cannot know how many files it did not see, so it records the one refusal it can rather than
145
+ letting the subtree vanish silently. Everything counted here is also counted as seen, so no
146
+ skip is ever attributed to an entry the walk did not reach.
147
+
148
+ Args:
149
+ root: the corpus root, already resolved.
150
+ counters: updated in place as the walk proceeds. Mutated rather than returned because the
151
+ caller flushes it to the database periodically, which is the only evidence a scan is
152
+ progressing before it has worked out what changed.
153
+ """
154
+ stack: list[tuple[Path, str]] = [(root, "")]
155
+ while stack:
156
+ directory, prefix = stack.pop()
157
+ try:
158
+ with os.scandir(directory) as scanning:
159
+ found = sorted(scanning, key=lambda entry: entry.name)
160
+ except OSError:
161
+ counters.files_seen += 1
162
+ counters.skip(SkipReason.UNREADABLE)
163
+ continue
164
+ descend: list[tuple[Path, str]] = []
165
+ for entry in found:
166
+ yield from _visit(entry, prefix, counters, descend)
167
+ # Reversed, because the stack is popped from the end and the names were sorted ascending.
168
+ stack.extend(reversed(descend))
169
+
170
+
171
+ def _visit(
172
+ entry: os.DirEntry[str],
173
+ prefix: str,
174
+ counters: ScanCounters,
175
+ descend: list[tuple[Path, str]],
176
+ ) -> Iterator[Candidate]:
177
+ """Classify one directory entry, counting it and queueing it for descent as appropriate."""
178
+ if entry.name == _GIT_DIRECTORY_NAME:
179
+ # Pruned whether it is a directory or the pointer file a linked worktree and a submodule
180
+ # carry. Only the directory shape is counted: the counter it feeds is reported as a number
181
+ # of directories, and a repository's own control file is not a corpus file whose absence
182
+ # anyone would ask about.
183
+ if _is_directory(entry):
184
+ counters.pruned_directories += 1
185
+ return
186
+ if _is_directory(entry):
187
+ if is_pruned_directory(entry.name):
188
+ counters.pruned_directories += 1
189
+ else:
190
+ descend.append((Path(entry.path), _relative(prefix, entry.name)))
191
+ return
192
+ # Everything that is not a directory in its own right is evaluated as a file, symlinks to
193
+ # directories included: not following one means never learning what it points at.
194
+ counters.files_seen += 1
195
+ if _is_symlink(entry):
196
+ counters.skip(SkipReason.SYMLINK)
197
+ return
198
+ candidate = _child_candidate(entry, _relative(prefix, entry.name))
199
+ if candidate is None:
200
+ counters.skip(SkipReason.UNREADABLE)
201
+ return
202
+ yield candidate
203
+
204
+
205
+ def _matches_any(path: str, patterns: Sequence[str]) -> bool:
206
+ """Whether the relative POSIX path matches any pattern, case-sensitively.
207
+
208
+ `fnmatch`'s `*` crosses `/`, which is what makes `*.md` reach `docs/a.md` and `docs/*` mean
209
+ the whole tree under `docs` rather than its direct children. Case-sensitive because this path
210
+ is the same byte-exact string the index stores, so a pattern means one thing to the walk and
211
+ to anyone reading the index back.
212
+
213
+ The one surprise, since it is otherwise met as an empty corpus: **`**` carries no meaning of
214
+ its own here**. It is two stars, so `docs/**/*.md` asks for a file two directories down and
215
+ gets exactly that, where a reader used to git's globbing expects the whole tree. It fails
216
+ closed rather than quietly wide, and `docs/*` is the pattern that means what they wanted.
217
+ """
218
+ return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
219
+
220
+
221
+ def admitted(
222
+ found: Sequence[Candidate],
223
+ *,
224
+ ignored: frozenset[str],
225
+ rules: WalkRules,
226
+ counters: ScanCounters,
227
+ ) -> list[Candidate]:
228
+ """The candidates that survive `.gitignore`, the globs, the size cap and the deny-list.
229
+
230
+ Applied in that order, and the order is the reported answer rather than an optimisation: a
231
+ file that is both ignored and over the cap is reported as ignored, and two implementations
232
+ disagreeing about which would give two different accounts of one corpus.
233
+
234
+ Args:
235
+ found: what the walk yielded, in its order, which is preserved.
236
+ ignored: the paths `.gitignore` excludes. Empty where git was not consulted, which is the
237
+ same thing as applying no ignore rules.
238
+ rules: this corpus's globs and size cap.
239
+ counters: updated in place with one skip per refused file.
240
+ """
241
+ kept: list[Candidate] = []
242
+ for candidate in found:
243
+ reason = _refusal(candidate, ignored=ignored, rules=rules)
244
+ if reason is None:
245
+ kept.append(candidate)
246
+ else:
247
+ counters.skip(reason)
248
+ return kept
249
+
250
+
251
+ def _refusal(
252
+ candidate: Candidate, *, ignored: frozenset[str], rules: WalkRules
253
+ ) -> SkipReason | None:
254
+ if candidate.path in ignored:
255
+ return SkipReason.GITIGNORED
256
+ if _matches_any(candidate.path, rules.exclude_globs):
257
+ return SkipReason.EXCLUDED_BY_GLOB
258
+ if rules.include_globs and not _matches_any(candidate.path, rules.include_globs):
259
+ return SkipReason.EXCLUDED_BY_GLOB
260
+ if candidate.size > rules.max_file_bytes:
261
+ return SkipReason.OVER_SIZE_CAP
262
+ if text.extension_denied(candidate.absolute.name):
263
+ return SkipReason.DENIED_EXTENSION
264
+ return None
@@ -0,0 +1,127 @@
1
+ """One file's place in the index, changed or removed in a single transaction.
2
+
3
+ **Atomicity is per file, not per corpus.** A build is thousands of independent transactions, which
4
+ buys two things at once: a search running alongside it sees whole files or no files, never a
5
+ half-indexed one; and progress is durable, so an interrupted build resumes rather than restarting.
6
+
7
+ **Three tables have to move together and none of them will do it on its own.** `chunks_fts` is
8
+ external-content, so it observes no delete on `chunks`; `chunks_vec` is a virtual table that takes
9
+ no foreign key. Nothing here is a cascade, and the word does not apply: every deletion is issued by
10
+ hand, in an order chosen so that the values the lexical arm needs are still readable when it needs
11
+ them.
12
+
13
+ The caller owns the transaction. These functions stage work and never commit, so a file's chunks,
14
+ its `files` row, and whatever else that caller is doing in the same breath — clearing its pending
15
+ row, advancing its counters — commit together or not at all.
16
+ """
17
+
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass
20
+
21
+ import aiosqlite
22
+
23
+ from zikaron.core.errors import ErrorCode, IndexStage, ZikaronError
24
+ from zikaron.core.knowledge import files, lexical, vectors
25
+ from zikaron.core.knowledge.chunking import FileChunkPlan
26
+
27
+ _CHUNK_COLUMNS = "path, part_index, start_line, end_line, text"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class _StoredChunk:
32
+ """One chunk as it stands in the index, with exactly what removing it requires: its id, and the
33
+ two column values the lexical arm was given when it was indexed."""
34
+
35
+ chunk_id: int
36
+ document: lexical.Document
37
+
38
+
39
+ async def _stored_chunks(db: aiosqlite.Connection, path: str) -> tuple[_StoredChunk, ...]:
40
+ """Every chunk currently indexed for `path`, read before anything is deleted.
41
+
42
+ Read first because the lexical arm's delete needs the values it was given at index time, and
43
+ they exist only in the rows about to be removed.
44
+ """
45
+ rows = await db.execute_fetchall(
46
+ "SELECT id, text, path FROM chunks WHERE path = ? ORDER BY part_index", (path,)
47
+ )
48
+ return tuple(
49
+ _StoredChunk(
50
+ chunk_id=int(chunk_id),
51
+ # Both values come out of the row rather than from this function's own argument, even
52
+ # though the path is what was searched for: what the delete needs is what was *indexed*,
53
+ # and reading it back is what makes that true by construction instead of by reasoning.
54
+ document=lexical.Document(text=str(text), path=str(indexed_path)),
55
+ )
56
+ for chunk_id, text, indexed_path in rows
57
+ )
58
+
59
+
60
+ async def _clear(db: aiosqlite.Connection, path: str) -> None:
61
+ """Remove every trace of `path` from all three tables, in the one order that works.
62
+
63
+ Postings first, while the rows that say what was indexed are still there — the lexical delete
64
+ takes the values it was given at index time, and given anything else it removes nothing and says
65
+ nothing. Then vectors, then the chunk rows: vectors before their rows is the memory store's
66
+ order too, and for the same reason. A chunk row with no vector is detectable by a join and
67
+ repairable by reindexing, where a vector with no chunk row matches a query and resolves to
68
+ nothing.
69
+ """
70
+ stored = await _stored_chunks(db, path)
71
+ for chunk in stored:
72
+ await lexical.remove(db, chunk_id=chunk.chunk_id, document=chunk.document)
73
+ await vectors.delete(db, chunk_ids=[chunk.chunk_id for chunk in stored])
74
+ await db.execute("DELETE FROM chunks WHERE path = ?", (path,))
75
+
76
+
77
+ async def index_file(
78
+ db: aiosqlite.Connection,
79
+ *,
80
+ indexed: files.IndexedFile,
81
+ plan: FileChunkPlan,
82
+ embeddings: Sequence[bytes],
83
+ ) -> None:
84
+ """Replace everything the index holds for one file with this plan and these vectors.
85
+
86
+ Replace rather than update: change detection tells us the file's content moved, and there is no
87
+ correspondence between its old chunks and its new ones to preserve. A file the corpus has never
88
+ seen takes the same path, since clearing a path with nothing under it removes nothing.
89
+
90
+ Args:
91
+ indexed: the `files` row to write, whose `chunk_count` is this plan's chunk count — the two
92
+ are compared here rather than trusted, since a row claiming a count the chunks do not
93
+ match is the one inconsistency no later reader can detect.
94
+ plan: the chunks to store, in `part_index` order.
95
+ embeddings: one serialized vector per chunk, in the same order.
96
+
97
+ Raises:
98
+ ZikaronError: `INDEX_FAILED` at stage `index_write` if the vectors, the plan and the row's
99
+ count do not describe the same set of chunks, or if a chunk insert reports no id to pair
100
+ its vector with.
101
+ """
102
+ if len(embeddings) != plan.n_chunks or indexed.chunk_count != plan.n_chunks:
103
+ raise ZikaronError(ErrorCode.INDEX_FAILED, stage=IndexStage.INDEX_WRITE)
104
+ await _clear(db, indexed.path)
105
+ for chunk, vector in zip(plan.chunks, embeddings, strict=True):
106
+ cursor = await db.execute(
107
+ f"INSERT INTO chunks ({_CHUNK_COLUMNS}) VALUES (?, ?, ?, ?, ?)", # noqa: S608 — a source-level column list; every value is bound.
108
+ (indexed.path, chunk.part_index, chunk.start_line, chunk.end_line, chunk.text),
109
+ )
110
+ chunk_id = cursor.lastrowid
111
+ if chunk_id is None: # pragma: no cover — SQLite reports an id for every successful insert.
112
+ raise ZikaronError(ErrorCode.INDEX_FAILED, stage=IndexStage.INDEX_WRITE)
113
+ document = lexical.Document(text=chunk.text, path=indexed.path)
114
+ await lexical.insert(db, chunk_id=chunk_id, document=document)
115
+ await vectors.insert(db, chunk_id=chunk_id, vector=vector)
116
+ await files.record(db, indexed)
117
+
118
+
119
+ async def forget_file(db: aiosqlite.Connection, *, path: str) -> None:
120
+ """Remove one file from the index entirely: its chunks, both arms, and its `files` row.
121
+
122
+ One function rather than two calls at each site, because "a chunk's path always names a row in
123
+ `files`" is maintained by nobody else: a caller that removed the row and left the chunks would
124
+ leave fragments pointing at a file the index no longer claims to hold, and nothing would raise.
125
+ """
126
+ await _clear(db, path)
127
+ await files.forget(db, path)
@@ -0,0 +1 @@
1
+ """Memory rows: versioning, soft retirement, supersession edges, and read receipts."""