knolo 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.
- knolo/__init__.py +25 -0
- knolo/errors.py +10 -0
- knolo/models.py +51 -0
- knolo/py.typed +1 -0
- knolo/runtime.py +463 -0
- knolo/tokenize.py +21 -0
- knolo-0.1.0.dist-info/METADATA +121 -0
- knolo-0.1.0.dist-info/RECORD +10 -0
- knolo-0.1.0.dist-info/WHEEL +4 -0
- knolo-0.1.0.dist-info/top_level.txt +1 -0
knolo/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .errors import InvalidPackError, KnoloError
|
|
4
|
+
from .models import Hit, Pack, PackMeta, PackStats, QueryOptions
|
|
5
|
+
from .runtime import mount_pack, mount_pack_from_bytes, query
|
|
6
|
+
from .tokenize import normalize, tokenize
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"__version__",
|
|
12
|
+
"Hit",
|
|
13
|
+
"InvalidPackError",
|
|
14
|
+
"KnoloError",
|
|
15
|
+
"Pack",
|
|
16
|
+
"PackMeta",
|
|
17
|
+
"PackStats",
|
|
18
|
+
"QueryOptions",
|
|
19
|
+
"mount_pack",
|
|
20
|
+
"mount_pack_from_bytes",
|
|
21
|
+
"normalize",
|
|
22
|
+
"query",
|
|
23
|
+
"tokenize",
|
|
24
|
+
]
|
|
25
|
+
|
knolo/errors.py
ADDED
knolo/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
FilterInput = str | Sequence[str] | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class PackStats:
|
|
12
|
+
docs: int
|
|
13
|
+
blocks: int
|
|
14
|
+
terms: int
|
|
15
|
+
avg_block_len: float | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class PackMeta:
|
|
20
|
+
version: int
|
|
21
|
+
stats: PackStats
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Pack:
|
|
26
|
+
meta: PackMeta
|
|
27
|
+
lexicon: dict[str, int]
|
|
28
|
+
postings: tuple[int, ...]
|
|
29
|
+
blocks: tuple[str, ...]
|
|
30
|
+
headings: tuple[str | None, ...]
|
|
31
|
+
doc_ids: tuple[str | None, ...]
|
|
32
|
+
namespaces: tuple[str | None, ...]
|
|
33
|
+
block_token_lens: tuple[int, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class QueryOptions:
|
|
38
|
+
top_k: int = 10
|
|
39
|
+
min_score: float = 0.0
|
|
40
|
+
namespace: FilterInput = None
|
|
41
|
+
source: FilterInput = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class Hit:
|
|
46
|
+
block_id: int
|
|
47
|
+
score: float
|
|
48
|
+
text: str
|
|
49
|
+
source: str | None = None
|
|
50
|
+
namespace: str | None = None
|
|
51
|
+
|
knolo/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
knolo/runtime.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import InvalidPackError
|
|
12
|
+
from .models import FilterInput, Hit, Pack, PackMeta, PackStats, QueryOptions
|
|
13
|
+
from .tokenize import normalize, tokenize
|
|
14
|
+
|
|
15
|
+
_UINT32 = struct.Struct("<I")
|
|
16
|
+
_MISSING = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mount_pack(source: str | os.PathLike[str] | bytes | bytearray | memoryview) -> Pack:
|
|
20
|
+
"""Mount a pack from a local file path or a bytes-like object."""
|
|
21
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
22
|
+
return mount_pack_from_bytes(source)
|
|
23
|
+
|
|
24
|
+
path = Path(os.fspath(source))
|
|
25
|
+
return mount_pack_from_bytes(path.read_bytes())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mount_pack_from_bytes(data: bytes | bytearray | memoryview) -> Pack:
|
|
29
|
+
"""Mount a pack from a bytes-like object."""
|
|
30
|
+
try:
|
|
31
|
+
view = memoryview(data).cast("B")
|
|
32
|
+
except TypeError as exc: # pragma: no cover - defensive type guard
|
|
33
|
+
raise TypeError("mount_pack_from_bytes() expects a bytes-like object") from exc
|
|
34
|
+
|
|
35
|
+
offset = 0
|
|
36
|
+
meta_payload, offset = _read_json_section(view, offset, "meta")
|
|
37
|
+
meta = _parse_meta(meta_payload)
|
|
38
|
+
|
|
39
|
+
lexicon_payload, offset = _read_json_section(view, offset, "lexicon")
|
|
40
|
+
lexicon = _parse_lexicon(lexicon_payload)
|
|
41
|
+
|
|
42
|
+
post_count, offset = _read_u32(view, offset)
|
|
43
|
+
postings = tuple(_read_u32_array(view, offset, post_count))
|
|
44
|
+
offset += post_count * 4
|
|
45
|
+
|
|
46
|
+
blocks_payload, offset = _read_json_section(view, offset, "blocks")
|
|
47
|
+
blocks, headings, doc_ids, namespaces, block_token_lens = _parse_blocks(blocks_payload)
|
|
48
|
+
|
|
49
|
+
return Pack(
|
|
50
|
+
meta=meta,
|
|
51
|
+
lexicon=lexicon,
|
|
52
|
+
postings=postings,
|
|
53
|
+
blocks=blocks,
|
|
54
|
+
headings=headings,
|
|
55
|
+
doc_ids=doc_ids,
|
|
56
|
+
namespaces=namespaces,
|
|
57
|
+
block_token_lens=block_token_lens,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def query(
|
|
62
|
+
pack: Pack,
|
|
63
|
+
q: str,
|
|
64
|
+
options: QueryOptions | None = None,
|
|
65
|
+
*,
|
|
66
|
+
top_k: int | object = _MISSING,
|
|
67
|
+
min_score: float | object = _MISSING,
|
|
68
|
+
namespace: FilterInput | object = _MISSING,
|
|
69
|
+
source: FilterInput | object = _MISSING,
|
|
70
|
+
) -> list[Hit]:
|
|
71
|
+
"""Run deterministic lexical retrieval over a mounted pack."""
|
|
72
|
+
resolved = _merge_query_options(
|
|
73
|
+
options,
|
|
74
|
+
top_k=top_k,
|
|
75
|
+
min_score=min_score,
|
|
76
|
+
namespace=namespace,
|
|
77
|
+
source=source,
|
|
78
|
+
)
|
|
79
|
+
_validate_query_options(resolved)
|
|
80
|
+
|
|
81
|
+
if not q.strip():
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
query_terms = tokenize(q)
|
|
85
|
+
if not query_terms:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
term_ids = {pack.lexicon[term] for term in query_terms if term in pack.lexicon}
|
|
89
|
+
if not term_ids:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
candidates, dfs = _scan_postings(pack, term_ids)
|
|
93
|
+
if not candidates:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
namespace_filters = _normalize_filter_values(resolved.namespace)
|
|
97
|
+
source_filters = _normalize_filter_values(resolved.source)
|
|
98
|
+
if namespace_filters:
|
|
99
|
+
candidates = {
|
|
100
|
+
block_id: tf_map
|
|
101
|
+
for block_id, tf_map in candidates.items()
|
|
102
|
+
if _matches_filter(pack.namespaces, block_id, namespace_filters)
|
|
103
|
+
}
|
|
104
|
+
if not candidates:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
if source_filters:
|
|
108
|
+
candidates = {
|
|
109
|
+
block_id: tf_map
|
|
110
|
+
for block_id, tf_map in candidates.items()
|
|
111
|
+
if _matches_filter(pack.doc_ids, block_id, source_filters)
|
|
112
|
+
}
|
|
113
|
+
if not candidates:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
doc_count = max(pack.meta.stats.blocks, len(pack.blocks), 1)
|
|
117
|
+
avg_len = _resolve_avg_block_len(pack)
|
|
118
|
+
|
|
119
|
+
hits: list[Hit] = []
|
|
120
|
+
for block_id, tf_map in candidates.items():
|
|
121
|
+
block_len = _resolve_block_len(pack, block_id)
|
|
122
|
+
score = 0.0
|
|
123
|
+
for term_id, tf in tf_map.items():
|
|
124
|
+
df = dfs.get(term_id, 0)
|
|
125
|
+
idf = math.log(1.0 + (doc_count - df + 0.5) / (df + 0.5))
|
|
126
|
+
k1 = 1.5
|
|
127
|
+
b = 0.75
|
|
128
|
+
numerator = tf * (k1 + 1.0)
|
|
129
|
+
denominator = tf + k1 * (1.0 - b + b * (block_len / avg_len))
|
|
130
|
+
score += idf * (numerator / denominator)
|
|
131
|
+
|
|
132
|
+
if score < resolved.min_score:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
hits.append(
|
|
136
|
+
Hit(
|
|
137
|
+
block_id=block_id,
|
|
138
|
+
score=score,
|
|
139
|
+
text=pack.blocks[block_id] if block_id < len(pack.blocks) else "",
|
|
140
|
+
source=pack.doc_ids[block_id] if block_id < len(pack.doc_ids) else None,
|
|
141
|
+
namespace=pack.namespaces[block_id] if block_id < len(pack.namespaces) else None,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
hits.sort(key=lambda hit: (-hit.score, hit.block_id))
|
|
146
|
+
return hits[: resolved.top_k]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _merge_query_options(
|
|
150
|
+
options: QueryOptions | None,
|
|
151
|
+
*,
|
|
152
|
+
top_k: int | object,
|
|
153
|
+
min_score: float | object,
|
|
154
|
+
namespace: FilterInput | object,
|
|
155
|
+
source: FilterInput | object,
|
|
156
|
+
) -> QueryOptions:
|
|
157
|
+
resolved = replace(options) if options is not None else QueryOptions()
|
|
158
|
+
|
|
159
|
+
if top_k is not _MISSING:
|
|
160
|
+
resolved.top_k = top_k # type: ignore[assignment]
|
|
161
|
+
if min_score is not _MISSING:
|
|
162
|
+
resolved.min_score = min_score # type: ignore[assignment]
|
|
163
|
+
if namespace is not _MISSING:
|
|
164
|
+
resolved.namespace = namespace # type: ignore[assignment]
|
|
165
|
+
if source is not _MISSING:
|
|
166
|
+
resolved.source = source # type: ignore[assignment]
|
|
167
|
+
return resolved
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validate_query_options(options: QueryOptions) -> None:
|
|
171
|
+
if not _is_positive_int(options.top_k):
|
|
172
|
+
raise ValueError("query(...): top_k must be a positive integer")
|
|
173
|
+
if not _is_non_negative_finite_number(options.min_score):
|
|
174
|
+
raise ValueError("query(...): min_score must be a finite number >= 0")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _scan_postings(pack: Pack, term_ids: set[int]) -> tuple[dict[int, dict[int, int]], dict[int, int]]:
|
|
178
|
+
candidates: dict[int, dict[int, int]] = {}
|
|
179
|
+
dfs: dict[int, int] = {}
|
|
180
|
+
uses_offset_block_ids = pack.meta.version >= 3
|
|
181
|
+
postings = pack.postings
|
|
182
|
+
cursor = 0
|
|
183
|
+
|
|
184
|
+
while cursor < len(postings):
|
|
185
|
+
term_id = postings[cursor]
|
|
186
|
+
cursor += 1
|
|
187
|
+
if term_id == 0:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
relevant = term_id in term_ids
|
|
191
|
+
term_df = 0
|
|
192
|
+
|
|
193
|
+
while True:
|
|
194
|
+
if cursor >= len(postings):
|
|
195
|
+
raise InvalidPackError("unexpected end of postings stream")
|
|
196
|
+
|
|
197
|
+
encoded_block_id = postings[cursor]
|
|
198
|
+
cursor += 1
|
|
199
|
+
if encoded_block_id == 0:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
block_id = encoded_block_id - 1 if uses_offset_block_ids else encoded_block_id
|
|
203
|
+
tf = 0
|
|
204
|
+
|
|
205
|
+
while True:
|
|
206
|
+
if cursor >= len(postings):
|
|
207
|
+
raise InvalidPackError("unexpected end of postings stream")
|
|
208
|
+
|
|
209
|
+
position = postings[cursor]
|
|
210
|
+
cursor += 1
|
|
211
|
+
if position == 0:
|
|
212
|
+
break
|
|
213
|
+
tf += 1
|
|
214
|
+
|
|
215
|
+
term_df += 1
|
|
216
|
+
if relevant and 0 <= block_id < len(pack.blocks):
|
|
217
|
+
tf_map = candidates.setdefault(block_id, {})
|
|
218
|
+
tf_map[term_id] = tf_map.get(term_id, 0) + tf
|
|
219
|
+
|
|
220
|
+
if relevant:
|
|
221
|
+
dfs[term_id] = term_df
|
|
222
|
+
|
|
223
|
+
return candidates, dfs
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_block_len(pack: Pack, block_id: int) -> int:
|
|
227
|
+
if 0 <= block_id < len(pack.block_token_lens):
|
|
228
|
+
length = pack.block_token_lens[block_id]
|
|
229
|
+
if _is_int(length) and length >= 0:
|
|
230
|
+
return length
|
|
231
|
+
if 0 <= block_id < len(pack.blocks):
|
|
232
|
+
return len(tokenize(pack.blocks[block_id]))
|
|
233
|
+
return 1
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _resolve_avg_block_len(pack: Pack) -> float:
|
|
237
|
+
avg = pack.meta.stats.avg_block_len
|
|
238
|
+
if isinstance(avg, (int, float)) and math.isfinite(avg) and avg > 0:
|
|
239
|
+
return float(avg)
|
|
240
|
+
|
|
241
|
+
lengths = [
|
|
242
|
+
_resolve_block_len(pack, index)
|
|
243
|
+
for index in range(len(pack.blocks))
|
|
244
|
+
]
|
|
245
|
+
if not lengths:
|
|
246
|
+
return 1.0
|
|
247
|
+
return max(sum(lengths) / len(lengths), 1.0)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _normalize_filter_values(value: FilterInput) -> set[str]:
|
|
251
|
+
if value is None:
|
|
252
|
+
return set()
|
|
253
|
+
if isinstance(value, str):
|
|
254
|
+
values = [value]
|
|
255
|
+
else:
|
|
256
|
+
try:
|
|
257
|
+
values = list(value)
|
|
258
|
+
except TypeError as exc:
|
|
259
|
+
raise ValueError("query(...): namespace/source filters must be strings or iterables of strings") from exc
|
|
260
|
+
normalized: set[str] = set()
|
|
261
|
+
for item in values:
|
|
262
|
+
if not isinstance(item, str):
|
|
263
|
+
raise ValueError("query(...): namespace/source filters must be strings or iterables of strings")
|
|
264
|
+
item_norm = normalize(item)
|
|
265
|
+
if item_norm:
|
|
266
|
+
normalized.add(item_norm)
|
|
267
|
+
return normalized
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _matches_filter(values: tuple[str | None, ...], block_id: int, filter_values: set[str]) -> bool:
|
|
271
|
+
if not filter_values:
|
|
272
|
+
return True
|
|
273
|
+
if block_id >= len(values):
|
|
274
|
+
return False
|
|
275
|
+
value = values[block_id]
|
|
276
|
+
return isinstance(value, str) and normalize(value) in filter_values
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_meta(payload: Any) -> PackMeta:
|
|
280
|
+
if not isinstance(payload, dict):
|
|
281
|
+
raise InvalidPackError("meta must be a JSON object")
|
|
282
|
+
|
|
283
|
+
version = _require_int(payload.get("version"), "meta.version", minimum=1)
|
|
284
|
+
stats_payload = payload.get("stats")
|
|
285
|
+
if not isinstance(stats_payload, dict):
|
|
286
|
+
raise InvalidPackError("meta.stats must be a JSON object")
|
|
287
|
+
|
|
288
|
+
docs = _require_int(stats_payload.get("docs"), "meta.stats.docs", minimum=0)
|
|
289
|
+
blocks = _require_int(stats_payload.get("blocks"), "meta.stats.blocks", minimum=0)
|
|
290
|
+
terms = _require_int(stats_payload.get("terms"), "meta.stats.terms", minimum=0)
|
|
291
|
+
avg_block_len = stats_payload.get("avgBlockLen", stats_payload.get("avg_block_len"))
|
|
292
|
+
if avg_block_len is not None:
|
|
293
|
+
avg_block_len = _require_float(avg_block_len, "meta.stats.avgBlockLen", minimum=0.0)
|
|
294
|
+
|
|
295
|
+
return PackMeta(
|
|
296
|
+
version=version,
|
|
297
|
+
stats=PackStats(
|
|
298
|
+
docs=docs,
|
|
299
|
+
blocks=blocks,
|
|
300
|
+
terms=terms,
|
|
301
|
+
avg_block_len=avg_block_len,
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _parse_lexicon(payload: Any) -> dict[str, int]:
|
|
307
|
+
lexicon: dict[str, int] = {}
|
|
308
|
+
if isinstance(payload, dict):
|
|
309
|
+
items = payload.items()
|
|
310
|
+
for term, term_id in items:
|
|
311
|
+
if not isinstance(term, str):
|
|
312
|
+
raise InvalidPackError("lexicon keys must be strings")
|
|
313
|
+
lexicon[term] = _require_int(term_id, f"lexicon[{term!r}]", minimum=1)
|
|
314
|
+
return lexicon
|
|
315
|
+
|
|
316
|
+
if not isinstance(payload, list):
|
|
317
|
+
raise InvalidPackError("lexicon must be a JSON array or object")
|
|
318
|
+
|
|
319
|
+
for entry in payload:
|
|
320
|
+
if not isinstance(entry, list) or len(entry) != 2:
|
|
321
|
+
raise InvalidPackError("lexicon entries must be [term, id] pairs")
|
|
322
|
+
term, term_id = entry
|
|
323
|
+
if not isinstance(term, str):
|
|
324
|
+
raise InvalidPackError("lexicon terms must be strings")
|
|
325
|
+
lexicon[term] = _require_int(term_id, f"lexicon[{term!r}]", minimum=1)
|
|
326
|
+
|
|
327
|
+
return lexicon
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _parse_blocks(payload: Any) -> tuple[tuple[str, ...], tuple[str | None, ...], tuple[str | None, ...], tuple[str | None, ...], tuple[int, ...]]:
|
|
331
|
+
if not isinstance(payload, list):
|
|
332
|
+
raise InvalidPackError("blocks must be a JSON array")
|
|
333
|
+
|
|
334
|
+
blocks: list[str] = []
|
|
335
|
+
headings: list[str | None] = []
|
|
336
|
+
doc_ids: list[str | None] = []
|
|
337
|
+
namespaces: list[str | None] = []
|
|
338
|
+
lengths: list[int] = []
|
|
339
|
+
|
|
340
|
+
for item in payload:
|
|
341
|
+
if isinstance(item, str):
|
|
342
|
+
text = item
|
|
343
|
+
heading = None
|
|
344
|
+
doc_id = None
|
|
345
|
+
namespace = None
|
|
346
|
+
length = None
|
|
347
|
+
elif isinstance(item, dict):
|
|
348
|
+
text_value = item.get("text", "")
|
|
349
|
+
text = text_value if isinstance(text_value, str) else ""
|
|
350
|
+
heading = _optional_str(item.get("heading"))
|
|
351
|
+
doc_id = _optional_str(item.get("docId"))
|
|
352
|
+
namespace = _optional_str(item.get("namespace"))
|
|
353
|
+
length = _optional_int(item.get("len"), minimum=0)
|
|
354
|
+
else:
|
|
355
|
+
text = "" if item is None else str(item)
|
|
356
|
+
heading = None
|
|
357
|
+
doc_id = None
|
|
358
|
+
namespace = None
|
|
359
|
+
length = None
|
|
360
|
+
|
|
361
|
+
if length is None:
|
|
362
|
+
length = len(tokenize(text))
|
|
363
|
+
|
|
364
|
+
blocks.append(text)
|
|
365
|
+
headings.append(heading)
|
|
366
|
+
doc_ids.append(doc_id)
|
|
367
|
+
namespaces.append(namespace)
|
|
368
|
+
lengths.append(length)
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
tuple(blocks),
|
|
372
|
+
tuple(headings),
|
|
373
|
+
tuple(doc_ids),
|
|
374
|
+
tuple(namespaces),
|
|
375
|
+
tuple(lengths),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _read_json_section(view: memoryview, offset: int, name: str) -> tuple[Any, int]:
|
|
380
|
+
length, offset = _read_u32(view, offset)
|
|
381
|
+
if offset + length > len(view):
|
|
382
|
+
raise InvalidPackError(f"{name} section is truncated")
|
|
383
|
+
|
|
384
|
+
raw = bytes(view[offset : offset + length])
|
|
385
|
+
offset += length
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
text = raw.decode("utf-8")
|
|
389
|
+
except UnicodeDecodeError as exc:
|
|
390
|
+
raise InvalidPackError(f"{name} section is not valid UTF-8") from exc
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
payload = json.loads(text)
|
|
394
|
+
except json.JSONDecodeError as exc:
|
|
395
|
+
raise InvalidPackError(f"{name} section is not valid JSON") from exc
|
|
396
|
+
|
|
397
|
+
return payload, offset
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _read_u32(view: memoryview, offset: int) -> tuple[int, int]:
|
|
401
|
+
if offset + 4 > len(view):
|
|
402
|
+
raise InvalidPackError("unexpected end of buffer")
|
|
403
|
+
try:
|
|
404
|
+
(value,) = _UINT32.unpack_from(view, offset)
|
|
405
|
+
except struct.error as exc: # pragma: no cover - defensive
|
|
406
|
+
raise InvalidPackError("unexpected end of buffer") from exc
|
|
407
|
+
return value, offset + 4
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _read_u32_array(view: memoryview, offset: int, length: int) -> list[int]:
|
|
411
|
+
if length > (len(view) - offset) // 4:
|
|
412
|
+
raise InvalidPackError("unexpected end of buffer")
|
|
413
|
+
values: list[int] = []
|
|
414
|
+
for _ in range(length):
|
|
415
|
+
value, offset = _read_u32(view, offset)
|
|
416
|
+
values.append(value)
|
|
417
|
+
return values
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _optional_str(value: Any) -> str | None:
|
|
421
|
+
return value if isinstance(value, str) else None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _optional_int(value: Any, *, minimum: int | None = None) -> int | None:
|
|
425
|
+
if not _is_int(value):
|
|
426
|
+
return None
|
|
427
|
+
if minimum is not None and value < minimum:
|
|
428
|
+
return None
|
|
429
|
+
return value
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _require_int(value: Any, field_name: str, *, minimum: int | None = None) -> int:
|
|
433
|
+
if not _is_int(value):
|
|
434
|
+
raise InvalidPackError(f"{field_name} must be an integer")
|
|
435
|
+
if minimum is not None and value < minimum:
|
|
436
|
+
raise InvalidPackError(f"{field_name} must be >= {minimum}")
|
|
437
|
+
return value
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _require_float(value: Any, field_name: str, *, minimum: float | None = None) -> float:
|
|
441
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
442
|
+
raise InvalidPackError(f"{field_name} must be a number")
|
|
443
|
+
out = float(value)
|
|
444
|
+
if not math.isfinite(out):
|
|
445
|
+
raise InvalidPackError(f"{field_name} must be finite")
|
|
446
|
+
if minimum is not None and out < minimum:
|
|
447
|
+
raise InvalidPackError(f"{field_name} must be >= {minimum}")
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _is_int(value: Any) -> bool:
|
|
452
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _is_positive_int(value: Any) -> bool:
|
|
456
|
+
return _is_int(value) and value > 0
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _is_non_negative_finite_number(value: Any) -> bool:
|
|
460
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
461
|
+
return False
|
|
462
|
+
out = float(value)
|
|
463
|
+
return math.isfinite(out) and out >= 0
|
knolo/tokenize.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def normalize(text: str) -> str:
|
|
5
|
+
"""Lowercase and trim text without the richer TypeScript normalization."""
|
|
6
|
+
return text.lower().strip()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tokenize(text: str) -> list[str]:
|
|
10
|
+
"""Split text on non-alphanumeric characters and lowercase each token."""
|
|
11
|
+
tokens: list[str] = []
|
|
12
|
+
current: list[str] = []
|
|
13
|
+
for ch in text:
|
|
14
|
+
if ch.isalnum():
|
|
15
|
+
current.append(ch.lower())
|
|
16
|
+
elif current:
|
|
17
|
+
tokens.append("".join(current))
|
|
18
|
+
current.clear()
|
|
19
|
+
if current:
|
|
20
|
+
tokens.append("".join(current))
|
|
21
|
+
return tokens
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: knolo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python runtime for mounting and querying .knolo packs.
|
|
5
|
+
Author: Knolo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# `knolo`
|
|
15
|
+
|
|
16
|
+
`knolo` is the pure-Python runtime for mounting existing `.knolo` packs and running deterministic lexical queries locally.
|
|
17
|
+
|
|
18
|
+
It is intentionally release-scoped for Phase 2:
|
|
19
|
+
|
|
20
|
+
- local-first retrieval
|
|
21
|
+
- deterministic lexical retrieval
|
|
22
|
+
- no vector database
|
|
23
|
+
- no embeddings on the default query path
|
|
24
|
+
- no Python pack builder
|
|
25
|
+
- no LangChain or LlamaIndex integration
|
|
26
|
+
- no Node.js runtime dependency for mount/query
|
|
27
|
+
|
|
28
|
+
Packs are still built with `@knolo/core` in TypeScript, then mounted and queried from Python.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
From this package directory:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install -e ".[dev]"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For a normal install, omit the extra:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Query
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from knolo import mount_pack, query
|
|
48
|
+
|
|
49
|
+
pack = mount_pack("tests/fixtures/simple.knolo")
|
|
50
|
+
hits = query(pack, "alpha beta", top_k=5)
|
|
51
|
+
|
|
52
|
+
for hit in hits:
|
|
53
|
+
print(hit.block_id, hit.score, hit.text)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You can also mount bytes directly:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from knolo import mount_pack_from_bytes
|
|
61
|
+
|
|
62
|
+
pack = mount_pack_from_bytes(Path("tests/fixtures/simple.knolo").read_bytes())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Release Readiness
|
|
66
|
+
|
|
67
|
+
The package publishes from GitHub release events via Trusted Publishing. No secret-based PyPI credentials are required in CI.
|
|
68
|
+
|
|
69
|
+
Before a release, run:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m pytest
|
|
73
|
+
python -m build
|
|
74
|
+
python -m twine check dist/*
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A manual upload fallback is still available when needed:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m twine upload dist/*
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
See [`RELEASE.md`](./RELEASE.md) for the release checklist.
|
|
84
|
+
|
|
85
|
+
## Fixture Regeneration
|
|
86
|
+
|
|
87
|
+
The committed fixture at `tests/fixtures/simple.knolo` is what tests use, so the test suite does not need Node.js at runtime.
|
|
88
|
+
|
|
89
|
+
To regenerate the fixture from the checked-in corpus, run the root helper script from the repo root:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
node scripts/regenerate-python-fixture.mjs
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The script reads `tests/fixtures/corpus/intro.md`, `runtime.md`, and `other.md`, then rewrites the committed binary fixture. Pass `--check` to verify that the committed bytes match the corpus without rewriting.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
The public package exports:
|
|
100
|
+
|
|
101
|
+
- `mount_pack(source)`
|
|
102
|
+
- `mount_pack_from_bytes(data)`
|
|
103
|
+
- `query(pack, q, ...)`
|
|
104
|
+
- `KnoloError`
|
|
105
|
+
- `InvalidPackError`
|
|
106
|
+
- `PackStats`
|
|
107
|
+
- `PackMeta`
|
|
108
|
+
- `Pack`
|
|
109
|
+
- `QueryOptions`
|
|
110
|
+
- `Hit`
|
|
111
|
+
- `tokenize()`
|
|
112
|
+
- `normalize()`
|
|
113
|
+
- `__version__`
|
|
114
|
+
|
|
115
|
+
## Current Scope
|
|
116
|
+
|
|
117
|
+
- No Python pack builder yet
|
|
118
|
+
- No semantic reranking
|
|
119
|
+
- No embeddings or vector database integration on the default path
|
|
120
|
+
- No Node.js runtime dependency at query time
|
|
121
|
+
- No LangChain or LlamaIndex adapters in this package
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
knolo/__init__.py,sha256=vuBqajpciI1IHp65QKOEbiVVp29D9W-_lTgootGGurU,523
|
|
2
|
+
knolo/errors.py,sha256=F2BkLt3c7xnpxr8H4mNHeN6QdSxsB-sF4WEaCM3psTI,221
|
|
3
|
+
knolo/models.py,sha256=4K7jSmQw3I0jWlKF9aO0gjnTiB-9A2KKMPOi5_Y6dws,953
|
|
4
|
+
knolo/runtime.py,sha256=VoT4JWjuZLvNgpyS0Tsf_gjWFhRlfiGAEZzIyW1jhgo,15110
|
|
5
|
+
knolo/tokenize.py,sha256=NpqIx5MC7aAg9t_ccBiBq6C5c1i34J2guoMKBOjvFjU,606
|
|
6
|
+
knolo/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
knolo-0.1.0.dist-info/METADATA,sha256=H1DJ8CROPC-2GYvdl7a85OOdVXtQV6E155iLeQd4LmE,2883
|
|
8
|
+
knolo-0.1.0.dist-info/WHEEL,sha256=cG5K-jkxLtOSEGCtZjW4PtGR6gKzAaHRKk-24jdkyHc,90
|
|
9
|
+
knolo-0.1.0.dist-info/top_level.txt,sha256=1KFS2itvGBltIxAwwVzRefEYqrisEz9RxWOlSfFaKmw,6
|
|
10
|
+
knolo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
knolo
|