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,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."""
|