knolo 0.1.0__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ include README.md
2
+ include src/knolo/py.typed
3
+
knolo-0.1.0/PKG-INFO ADDED
@@ -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
knolo-0.1.0/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # `knolo`
2
+
3
+ `knolo` is the pure-Python runtime for mounting existing `.knolo` packs and running deterministic lexical queries locally.
4
+
5
+ It is intentionally release-scoped for Phase 2:
6
+
7
+ - local-first retrieval
8
+ - deterministic lexical retrieval
9
+ - no vector database
10
+ - no embeddings on the default query path
11
+ - no Python pack builder
12
+ - no LangChain or LlamaIndex integration
13
+ - no Node.js runtime dependency for mount/query
14
+
15
+ Packs are still built with `@knolo/core` in TypeScript, then mounted and queried from Python.
16
+
17
+ ## Install
18
+
19
+ From this package directory:
20
+
21
+ ```bash
22
+ python -m pip install -e ".[dev]"
23
+ ```
24
+
25
+ For a normal install, omit the extra:
26
+
27
+ ```bash
28
+ python -m pip install .
29
+ ```
30
+
31
+ ## Query
32
+
33
+ ```python
34
+ from knolo import mount_pack, query
35
+
36
+ pack = mount_pack("tests/fixtures/simple.knolo")
37
+ hits = query(pack, "alpha beta", top_k=5)
38
+
39
+ for hit in hits:
40
+ print(hit.block_id, hit.score, hit.text)
41
+ ```
42
+
43
+ You can also mount bytes directly:
44
+
45
+ ```python
46
+ from pathlib import Path
47
+ from knolo import mount_pack_from_bytes
48
+
49
+ pack = mount_pack_from_bytes(Path("tests/fixtures/simple.knolo").read_bytes())
50
+ ```
51
+
52
+ ## Release Readiness
53
+
54
+ The package publishes from GitHub release events via Trusted Publishing. No secret-based PyPI credentials are required in CI.
55
+
56
+ Before a release, run:
57
+
58
+ ```bash
59
+ python -m pytest
60
+ python -m build
61
+ python -m twine check dist/*
62
+ ```
63
+
64
+ A manual upload fallback is still available when needed:
65
+
66
+ ```bash
67
+ python -m twine upload dist/*
68
+ ```
69
+
70
+ See [`RELEASE.md`](./RELEASE.md) for the release checklist.
71
+
72
+ ## Fixture Regeneration
73
+
74
+ The committed fixture at `tests/fixtures/simple.knolo` is what tests use, so the test suite does not need Node.js at runtime.
75
+
76
+ To regenerate the fixture from the checked-in corpus, run the root helper script from the repo root:
77
+
78
+ ```bash
79
+ node scripts/regenerate-python-fixture.mjs
80
+ ```
81
+
82
+ 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.
83
+
84
+ ## API
85
+
86
+ The public package exports:
87
+
88
+ - `mount_pack(source)`
89
+ - `mount_pack_from_bytes(data)`
90
+ - `query(pack, q, ...)`
91
+ - `KnoloError`
92
+ - `InvalidPackError`
93
+ - `PackStats`
94
+ - `PackMeta`
95
+ - `Pack`
96
+ - `QueryOptions`
97
+ - `Hit`
98
+ - `tokenize()`
99
+ - `normalize()`
100
+ - `__version__`
101
+
102
+ ## Current Scope
103
+
104
+ - No Python pack builder yet
105
+ - No semantic reranking
106
+ - No embeddings or vector database integration on the default path
107
+ - No Node.js runtime dependency at query time
108
+ - No LangChain or LlamaIndex adapters in this package
knolo-0.1.0/RELEASE.md ADDED
@@ -0,0 +1,13 @@
1
+ # Release Checklist
2
+
3
+ - [ ] Confirm the `knolo` distribution name is still available on PyPI, or choose a fallback package name before release if it is not.
4
+ - [ ] `cd packages/core-python && python -m pip install -e ".[dev]"`
5
+ - [ ] `cd packages/core-python && python -m pytest`
6
+ - [ ] `cd packages/core-python && python -m build`
7
+ - [ ] `cd packages/core-python && python -m twine check dist/*`
8
+ - [ ] Verify the wheel contents with `python -m zipfile -l dist/knolo-*.whl`.
9
+ - [ ] Verify the sdist contents with `tar -tzf dist/knolo-*.tar.gz`.
10
+ - [ ] Confirm the Python CI workflow passes on Python 3.10, 3.11, 3.12, and 3.13.
11
+ - [ ] Confirm the publish workflow only runs on GitHub release publication and uses Trusted Publishing with no hardcoded secrets.
12
+ - [ ] Smoke install the built wheel in a clean environment and run a basic `mount_pack` / `query` check.
13
+ - [ ] Yank a bad PyPI release instead of republishing the same tag if a release needs to be rolled back.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = []
3
+ build-backend = "setuptools.build_meta"
4
+ backend-path = ["."]
5
+
6
+ [project]
7
+ name = "knolo"
8
+ version = "0.1.0"
9
+ description = "Pure-Python runtime for mounting and querying .knolo packs."
10
+ readme = { file = "README.md", content-type = "text/markdown" }
11
+ requires-python = ">=3.10"
12
+ license = { text = "Apache-2.0" }
13
+ authors = [{ name = "Knolo" }]
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "build>=1.2",
19
+ "pytest>=8",
20
+ "twine>=5",
21
+ ]
22
+
23
+ [tool.setuptools]
24
+ package-dir = { "" = "src" }
25
+ include-package-data = true
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ knolo = ["py.typed"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ pythonpath = ["src"]
@@ -0,0 +1,2 @@
1
+ """Local build backend shim for the knolo Python package."""
2
+
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import csv
5
+ import hashlib
6
+ import io
7
+ import tarfile
8
+ import textwrap
9
+ import time
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Iterable
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ PACKAGE_NAME = "knolo"
16
+ VERSION = "0.1.0"
17
+ DIST_INFO = f"{PACKAGE_NAME}-{VERSION}.dist-info"
18
+ WHEEL_NAME = f"{PACKAGE_NAME}-{VERSION}-py3-none-any.whl"
19
+ SDIST_NAME = f"{PACKAGE_NAME}-{VERSION}.tar.gz"
20
+
21
+
22
+ def get_requires_for_build_wheel(config_settings=None):
23
+ return []
24
+
25
+
26
+ def get_requires_for_build_editable(config_settings=None):
27
+ return []
28
+
29
+
30
+ def get_requires_for_build_sdist(config_settings=None):
31
+ return []
32
+
33
+
34
+ def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
35
+ return _write_metadata_dir(Path(metadata_directory))
36
+
37
+
38
+ def prepare_metadata_for_build_editable(metadata_directory, config_settings=None):
39
+ return _write_metadata_dir(Path(metadata_directory))
40
+
41
+
42
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
43
+ return _build_wheel(Path(wheel_directory), editable=False)
44
+
45
+
46
+ def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
47
+ return _build_wheel(Path(wheel_directory), editable=True)
48
+
49
+
50
+ def build_sdist(sdist_directory, config_settings=None):
51
+ out_dir = Path(sdist_directory)
52
+ out_dir.mkdir(parents=True, exist_ok=True)
53
+ target = out_dir / SDIST_NAME
54
+ root_name = f"{PACKAGE_NAME}-{VERSION}"
55
+
56
+ with tarfile.open(target, "w:gz") as tar:
57
+ for path in _iter_sdist_paths():
58
+ arcname = Path(root_name) / path.relative_to(ROOT)
59
+ info = tar.gettarinfo(str(path), arcname=str(arcname))
60
+ if path.is_file():
61
+ with path.open("rb") as fh:
62
+ tar.addfile(info, fh)
63
+ else:
64
+ tar.addfile(info)
65
+
66
+ pkg_info = _metadata_text().encode("utf-8")
67
+ info = tarfile.TarInfo(name=f"{root_name}/PKG-INFO")
68
+ info.size = len(pkg_info)
69
+ info.mtime = int(time.time())
70
+ info.mode = 0o644
71
+ tar.addfile(info, io.BytesIO(pkg_info))
72
+
73
+ return SDIST_NAME
74
+
75
+
76
+ def _build_wheel(out_dir: Path, *, editable: bool) -> str:
77
+ out_dir.mkdir(parents=True, exist_ok=True)
78
+ target = out_dir / WHEEL_NAME
79
+ files: list[tuple[str, bytes]] = []
80
+
81
+ if editable:
82
+ source_path = str((ROOT / "src").resolve())
83
+ files.append((f"{PACKAGE_NAME}.pth", (source_path + "\n").encode("utf-8")))
84
+ else:
85
+ for rel_path in _wheel_files():
86
+ src = ROOT / rel_path
87
+ arcname = rel_path.relative_to("src").as_posix()
88
+ files.append((arcname, src.read_bytes()))
89
+
90
+ metadata_prefix = DIST_INFO
91
+ files.append((f"{metadata_prefix}/METADATA", _metadata_text().encode("utf-8")))
92
+ files.append((f"{metadata_prefix}/WHEEL", _wheel_text().encode("utf-8")))
93
+ files.append((f"{metadata_prefix}/top_level.txt", b"knolo\n"))
94
+
95
+ record_rows = []
96
+ for arcname, data in files:
97
+ digest = hashlib.sha256(data).digest()
98
+ encoded = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
99
+ record_rows.append((arcname, f"sha256={encoded}", str(len(data))))
100
+ record_rows.append((f"{metadata_prefix}/RECORD", "", ""))
101
+
102
+ record_bytes = _render_record(record_rows)
103
+ files.append((f"{metadata_prefix}/RECORD", record_bytes))
104
+
105
+ with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
106
+ for arcname, data in files:
107
+ zf.writestr(arcname, data)
108
+
109
+ return WHEEL_NAME
110
+
111
+
112
+ def _write_metadata_dir(metadata_directory: Path) -> str:
113
+ dist_info = metadata_directory / DIST_INFO
114
+ dist_info.mkdir(parents=True, exist_ok=True)
115
+ (dist_info / "METADATA").write_text(_metadata_text(), encoding="utf-8")
116
+ (dist_info / "WHEEL").write_text(_wheel_text(), encoding="utf-8")
117
+ (dist_info / "top_level.txt").write_text("knolo\n", encoding="utf-8")
118
+ return DIST_INFO
119
+
120
+
121
+ def _metadata_text() -> str:
122
+ headers = [
123
+ "Metadata-Version: 2.3",
124
+ f"Name: {PACKAGE_NAME}",
125
+ f"Version: {VERSION}",
126
+ "Summary: Pure-Python runtime for mounting and querying .knolo packs.",
127
+ "Author: Knolo",
128
+ "License: Apache-2.0",
129
+ "Requires-Python: >=3.10",
130
+ "Description-Content-Type: text/markdown",
131
+ "Provides-Extra: dev",
132
+ 'Requires-Dist: build>=1.2; extra == "dev"',
133
+ 'Requires-Dist: pytest>=8; extra == "dev"',
134
+ 'Requires-Dist: twine>=5; extra == "dev"',
135
+ ]
136
+ return "\n".join(headers) + "\n\n" + _read_readme().rstrip() + "\n"
137
+
138
+
139
+ def _wheel_text() -> str:
140
+ return textwrap.dedent(
141
+ f"""\
142
+ Wheel-Version: 1.0
143
+ Generator: knolo-local-backend
144
+ Root-Is-Purelib: true
145
+ Tag: py3-none-any
146
+ """
147
+ ).strip() + "\n"
148
+
149
+
150
+ def _read_readme() -> str:
151
+ return (ROOT / "README.md").read_text(encoding="utf-8")
152
+
153
+
154
+ def _wheel_files() -> list[Path]:
155
+ return [
156
+ Path("src/knolo/__init__.py"),
157
+ Path("src/knolo/errors.py"),
158
+ Path("src/knolo/models.py"),
159
+ Path("src/knolo/runtime.py"),
160
+ Path("src/knolo/tokenize.py"),
161
+ Path("src/knolo/py.typed"),
162
+ ]
163
+
164
+
165
+ def _iter_sdist_paths() -> Iterable[Path]:
166
+ skip_dirs = {
167
+ ".git",
168
+ "__pycache__",
169
+ ".pytest_cache",
170
+ ".mypy_cache",
171
+ ".python-user-base",
172
+ ".ruff_cache",
173
+ ".tox",
174
+ "dist",
175
+ }
176
+ for path in ROOT.rglob("*"):
177
+ if any(part in skip_dirs for part in path.parts):
178
+ continue
179
+ if path.is_dir():
180
+ continue
181
+ yield path
182
+
183
+
184
+ def _render_record(rows: list[tuple[str, str, str]]) -> bytes:
185
+ buf = io.StringIO()
186
+ writer = csv.writer(buf, lineterminator="\n")
187
+ writer.writerows(rows)
188
+ return buf.getvalue().encode("utf-8")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import site
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _bootstrap_local_user_site() -> None:
10
+ root = Path(__file__).resolve().parent
11
+ user_base = root / ".python-user-base"
12
+ user_site = user_base / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages"
13
+ user_site.mkdir(parents=True, exist_ok=True)
14
+
15
+ site.USER_BASE = str(user_base)
16
+ site.USER_SITE = str(user_site)
17
+ os.environ["PYTHONUSERBASE"] = str(user_base)
18
+
19
+ if str(user_site) not in sys.path:
20
+ sys.path.append(str(user_site))
21
+
22
+
23
+ _bootstrap_local_user_site()
24
+
@@ -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
+
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class KnoloError(Exception):
5
+ """Base error for knolo runtime failures."""
6
+
7
+
8
+ class InvalidPackError(KnoloError):
9
+ """Raised when a .knolo pack cannot be parsed or validated."""
10
+
@@ -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
+
@@ -0,0 +1 @@
1
+
@@ -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
@@ -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,21 @@
1
+ # Fixture Regeneration
2
+
3
+ `simple.knolo` is the committed binary fixture used by the Python tests.
4
+
5
+ It is generated from the checked-in corpus files:
6
+
7
+ - `corpus/intro.md`
8
+ - `corpus/runtime.md`
9
+ - `corpus/other.md`
10
+
11
+ The root helper script `scripts/regenerate-python-fixture.mjs` rebuilds the fixture with the existing `@knolo/core` TypeScript builder.
12
+
13
+ Tests mount the committed binary directly, so Node.js is only needed when regenerating the fixture, not at runtime.
14
+
15
+ From the repo root:
16
+
17
+ ```bash
18
+ node scripts/regenerate-python-fixture.mjs
19
+ ```
20
+
21
+ Pass `--check` to verify that the working tree bytes still match the corpus without rewriting the file.
@@ -0,0 +1,3 @@
1
+ # Alpha Intro
2
+
3
+ alpha beta
@@ -0,0 +1,3 @@
1
+ # Alpha Reference
2
+
3
+ alpha beta
@@ -0,0 +1,3 @@
1
+ # Beta Guide
2
+
3
+ beta gamma delta
Binary file
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import struct
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from knolo import (
11
+ InvalidPackError,
12
+ QueryOptions,
13
+ mount_pack,
14
+ mount_pack_from_bytes,
15
+ query,
16
+ )
17
+
18
+
19
+ FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "simple.knolo"
20
+ BUILD_BACKEND_PATH = Path(__file__).resolve().parents[1] / "setuptools" / "build_meta.py"
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def fixture_bytes() -> bytes:
25
+ return FIXTURE_PATH.read_bytes()
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def fixture_pack(fixture_bytes: bytes):
30
+ return mount_pack_from_bytes(fixture_bytes)
31
+
32
+
33
+ def _load_build_backend():
34
+ spec = importlib.util.spec_from_file_location("knolo_local_build_meta", BUILD_BACKEND_PATH)
35
+ assert spec is not None
36
+ assert spec.loader is not None
37
+
38
+ module = importlib.util.module_from_spec(spec)
39
+ spec.loader.exec_module(module)
40
+ return module
41
+
42
+
43
+ def test_mounts_from_path_and_bytes(fixture_bytes: bytes):
44
+ pack_from_path = mount_pack(FIXTURE_PATH)
45
+ pack_from_bytes = mount_pack_from_bytes(fixture_bytes)
46
+
47
+ assert pack_from_path == pack_from_bytes
48
+
49
+
50
+ def test_preserves_metadata_and_block_fields(fixture_pack):
51
+ assert fixture_pack.meta.version == 3
52
+ assert fixture_pack.meta.stats.docs == 3
53
+ assert fixture_pack.meta.stats.blocks == 3
54
+ assert fixture_pack.meta.stats.terms == 4
55
+ assert fixture_pack.blocks == ("alpha beta", "beta gamma delta", "alpha beta")
56
+ assert fixture_pack.headings == (
57
+ "Alpha Intro",
58
+ "Beta Guide",
59
+ "Alpha Reference",
60
+ )
61
+ assert fixture_pack.doc_ids == ("intro.md", "runtime.md", "other.md")
62
+ assert fixture_pack.namespaces == ("docs.alpha", "docs.beta", "docs.alpha")
63
+ assert fixture_pack.block_token_lens == (2, 3, 2)
64
+
65
+
66
+ def test_query_is_deterministic_and_ranks_by_block_id_tie_breaker(fixture_pack):
67
+ hits = query(fixture_pack, "alpha beta", top_k=5)
68
+
69
+ assert [hit.source for hit in hits[:2]] == ["intro.md", "other.md"]
70
+ assert hits[0].score == pytest.approx(hits[1].score)
71
+ assert hits[0].block_id < hits[1].block_id
72
+
73
+
74
+ def test_query_supports_namespace_and_source_filters(fixture_pack):
75
+ namespace_hits = query(fixture_pack, "alpha", namespace="docs.alpha", top_k=5)
76
+ assert [hit.source for hit in namespace_hits] == ["intro.md", "other.md"]
77
+
78
+ source_hits = query(fixture_pack, "alpha", source="other.md", top_k=5)
79
+ assert [hit.source for hit in source_hits] == ["other.md"]
80
+
81
+
82
+ def test_blank_query_returns_empty_list(fixture_pack):
83
+ assert query(fixture_pack, "") == []
84
+ assert query(fixture_pack, " ") == []
85
+
86
+
87
+ def test_top_k_limits_results(fixture_pack):
88
+ hits = query(fixture_pack, "beta", top_k=1)
89
+ assert len(hits) == 1
90
+ assert hits[0].source == "intro.md"
91
+
92
+
93
+ def test_min_score_filters_results(fixture_pack):
94
+ assert query(fixture_pack, "alpha", min_score=10.0) == []
95
+
96
+
97
+ def test_query_options_are_merged_with_explicit_kwargs(fixture_pack):
98
+ options = QueryOptions(top_k=1, namespace="docs.alpha")
99
+ hits = query(fixture_pack, "alpha beta", options, top_k=2)
100
+ assert len(hits) == 2
101
+ assert all(hit.namespace == "docs.alpha" for hit in hits)
102
+
103
+
104
+ def test_non_editable_wheel_uses_top_level_package_paths(tmp_path):
105
+ build_meta = _load_build_backend()
106
+ wheel_name = build_meta.build_wheel(tmp_path)
107
+ wheel_path = tmp_path / wheel_name
108
+
109
+ assert wheel_path.exists()
110
+
111
+ with zipfile.ZipFile(wheel_path) as wheel:
112
+ names = wheel.namelist()
113
+
114
+ assert "knolo/__init__.py" in names
115
+ assert "knolo/errors.py" in names
116
+ assert "knolo/models.py" in names
117
+ assert "knolo/runtime.py" in names
118
+ assert "knolo/tokenize.py" in names
119
+ assert "knolo/py.typed" in names
120
+ assert not any(name.startswith("src/knolo/") for name in names)
121
+
122
+
123
+ @pytest.mark.parametrize(
124
+ "payload",
125
+ [
126
+ b"not-json-at-all",
127
+ struct.pack("<I", 8) + b"{not js" + b"\x00\x00\x00\x00",
128
+ struct.pack("<I", 2) + b"{}" + struct.pack("<I", 0) + b"" + struct.pack("<I", 0) + struct.pack("<I", 0),
129
+ ],
130
+ )
131
+ def test_invalid_inputs_raise_invalid_pack_error(payload: bytes):
132
+ with pytest.raises(InvalidPackError):
133
+ mount_pack_from_bytes(payload)
134
+
135
+
136
+ @pytest.mark.parametrize(
137
+ "kwargs",
138
+ [
139
+ {"top_k": 0},
140
+ {"top_k": -1},
141
+ {"min_score": -0.01},
142
+ {"min_score": float("inf")},
143
+ ],
144
+ )
145
+ def test_invalid_query_options_raise_value_error(fixture_pack, kwargs):
146
+ with pytest.raises(ValueError):
147
+ query(fixture_pack, "alpha", **kwargs)