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.
- knolo-0.1.0/MANIFEST.in +3 -0
- knolo-0.1.0/PKG-INFO +121 -0
- knolo-0.1.0/README.md +108 -0
- knolo-0.1.0/RELEASE.md +13 -0
- knolo-0.1.0/pyproject.toml +35 -0
- knolo-0.1.0/setuptools/__init__.py +2 -0
- knolo-0.1.0/setuptools/build_meta.py +188 -0
- knolo-0.1.0/sitecustomize.py +24 -0
- knolo-0.1.0/src/knolo/__init__.py +25 -0
- knolo-0.1.0/src/knolo/errors.py +10 -0
- knolo-0.1.0/src/knolo/models.py +51 -0
- knolo-0.1.0/src/knolo/py.typed +1 -0
- knolo-0.1.0/src/knolo/runtime.py +463 -0
- knolo-0.1.0/src/knolo/tokenize.py +21 -0
- knolo-0.1.0/tests/fixtures/README.md +21 -0
- knolo-0.1.0/tests/fixtures/corpus/intro.md +3 -0
- knolo-0.1.0/tests/fixtures/corpus/other.md +3 -0
- knolo-0.1.0/tests/fixtures/corpus/runtime.md +3 -0
- knolo-0.1.0/tests/fixtures/simple.knolo +0 -0
- knolo-0.1.0/tests/test_runtime.py +147 -0
knolo-0.1.0/MANIFEST.in
ADDED
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,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,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.
|
|
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)
|