thread-sdk 0.2.0__cp311-abi3-win_amd64.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.
- thread_sdk/__init__.py +143 -0
- thread_sdk/_native.pyd +0 -0
- thread_sdk/_native.pyi +202 -0
- thread_sdk/attrs.py +331 -0
- thread_sdk/chunk.py +112 -0
- thread_sdk/cold.py +152 -0
- thread_sdk/embed.py +229 -0
- thread_sdk/index.py +1410 -0
- thread_sdk/lex.py +428 -0
- thread_sdk/pack.py +623 -0
- thread_sdk/py.typed +0 -0
- thread_sdk/query.py +515 -0
- thread_sdk/revision.py +192 -0
- thread_sdk/s3.py +495 -0
- thread_sdk/store.py +972 -0
- thread_sdk/warm.py +271 -0
- thread_sdk/wire.py +579 -0
- thread_sdk/write.py +1006 -0
- thread_sdk-0.2.0.dist-info/METADATA +445 -0
- thread_sdk-0.2.0.dist-info/RECORD +22 -0
- thread_sdk-0.2.0.dist-info/WHEEL +4 -0
- thread_sdk-0.2.0.dist-info/sboms/thread-py.cyclonedx.json +554 -0
thread_sdk/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""thread-sdk: query and update a thread blob-native search index (dense vectors, BM25 text, or both fused by rank)
|
|
2
|
+
from Python. The index lives as immutable objects in R2, any S3-compatible store or a local directory; the numeric
|
|
3
|
+
kernels are the same Rust code (`thread-core`) the Worker and the TypeScript SDK run, as a native extension.
|
|
4
|
+
|
|
5
|
+
from thread_sdk import S3BlobStore, open_index
|
|
6
|
+
|
|
7
|
+
store = S3BlobStore.r2(account_id, "my-bucket", key_id, secret) # or open_index("/data/idx"), a directory,
|
|
8
|
+
with open_index(store) as index: # or open_index("idx.threadpack"), one file
|
|
9
|
+
r = index.search(text="deploy failed", vector=embedding, top_k=10, docs=True)
|
|
10
|
+
hits = index.query(embedding, top_k=10).hits # dense only
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from . import _native
|
|
14
|
+
from .attrs import compile_filter, define_attrs, filter_matches, pack_attrs, unpack_attrs, validate_schema
|
|
15
|
+
from .chunk import (CHUNK_KEY_SEPARATOR, DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_WORDS, Chunk, ChunkOptions, chunk_index_of,
|
|
16
|
+
chunk_key, chunk_text, resolve_chunk_options)
|
|
17
|
+
from .index import KeyRecord, Rank, SearchHit, SearchResult, ThreadIndex, head_revision, load_rev_state, open_index, resolve_target
|
|
18
|
+
from .lex import DocRecord, KeyPage, LexHit, LexIndex, LexSearchResult, LexSegment
|
|
19
|
+
from .pack import PackedBlobStore, PackResult, PackWriter, copy_objects, pack_index, pack_to_bytes, store_at
|
|
20
|
+
from .query import MULTI_COALESCE, QUERY_INFLIGHT, Hit, QueryResult, RevState, coalesce_runs
|
|
21
|
+
from .revision import snapshot_keys
|
|
22
|
+
from .s3 import S3_INFLIGHT, S3BlobStore, SigningKeyCache, sign_v4
|
|
23
|
+
from .store import (
|
|
24
|
+
BlobCache,
|
|
25
|
+
BlobHead,
|
|
26
|
+
BlobRange,
|
|
27
|
+
BlobStore,
|
|
28
|
+
BlobTrace,
|
|
29
|
+
CachedBlobStore,
|
|
30
|
+
DiskCache,
|
|
31
|
+
FileBlobStore,
|
|
32
|
+
MemoryBlobStore,
|
|
33
|
+
MISS_POLICIES,
|
|
34
|
+
MemoryCache,
|
|
35
|
+
PrefixedBlobStore,
|
|
36
|
+
)
|
|
37
|
+
from .warm import WarmPrefixStore, build_warm_pack, parse_warm_pack, seed_warm_pack, write_warm_pack
|
|
38
|
+
from .embed import (EMBED_BATCH_DEFAULTS, EMBEDDING_PROVIDERS, embedding_batches, embedding_inputs, normalize_rows,
|
|
39
|
+
resolve_embedder, split_rows, validate_embedding_config)
|
|
40
|
+
from .wire import COMPACTION_PROFILES, CREATE_DEFAULTS, ResolvedLexConfig, ThreadError, keys
|
|
41
|
+
from .write import (
|
|
42
|
+
MAX_UPSERT,
|
|
43
|
+
CompactSegmentsResult,
|
|
44
|
+
ConfigureAttrsResult,
|
|
45
|
+
ConfigureLexicalResult,
|
|
46
|
+
DeleteResult,
|
|
47
|
+
UpsertResult,
|
|
48
|
+
create_generation,
|
|
49
|
+
create_lexical_generation,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
MissingRanges = _native.MissingRanges
|
|
53
|
+
__version__ = _native.__version__
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"open_index",
|
|
57
|
+
"create_generation",
|
|
58
|
+
"EMBED_BATCH_DEFAULTS",
|
|
59
|
+
"EMBEDDING_PROVIDERS",
|
|
60
|
+
"COMPACTION_PROFILES",
|
|
61
|
+
"CREATE_DEFAULTS",
|
|
62
|
+
"embedding_batches",
|
|
63
|
+
"embedding_inputs",
|
|
64
|
+
"normalize_rows",
|
|
65
|
+
"resolve_embedder",
|
|
66
|
+
"split_rows",
|
|
67
|
+
"validate_embedding_config",
|
|
68
|
+
"ThreadIndex",
|
|
69
|
+
"ThreadError",
|
|
70
|
+
"MissingRanges",
|
|
71
|
+
"QueryResult",
|
|
72
|
+
"Hit",
|
|
73
|
+
"SearchResult",
|
|
74
|
+
"SearchHit",
|
|
75
|
+
"Rank",
|
|
76
|
+
"LexSearchResult",
|
|
77
|
+
"LexHit",
|
|
78
|
+
"DocRecord",
|
|
79
|
+
"KeyRecord",
|
|
80
|
+
"KeyPage",
|
|
81
|
+
"UpsertResult",
|
|
82
|
+
"DeleteResult",
|
|
83
|
+
"ConfigureLexicalResult",
|
|
84
|
+
"ConfigureAttrsResult",
|
|
85
|
+
"CompactSegmentsResult",
|
|
86
|
+
"create_lexical_generation",
|
|
87
|
+
"define_attrs",
|
|
88
|
+
"validate_schema",
|
|
89
|
+
"pack_attrs",
|
|
90
|
+
"unpack_attrs",
|
|
91
|
+
"compile_filter",
|
|
92
|
+
"filter_matches",
|
|
93
|
+
"MAX_UPSERT",
|
|
94
|
+
"Chunk",
|
|
95
|
+
"ChunkOptions",
|
|
96
|
+
"chunk_text",
|
|
97
|
+
"chunk_key",
|
|
98
|
+
"chunk_index_of",
|
|
99
|
+
"resolve_chunk_options",
|
|
100
|
+
"DEFAULT_CHUNK_WORDS",
|
|
101
|
+
"DEFAULT_CHUNK_OVERLAP",
|
|
102
|
+
"CHUNK_KEY_SEPARATOR",
|
|
103
|
+
"QUERY_INFLIGHT",
|
|
104
|
+
"BlobStore",
|
|
105
|
+
"BlobRange",
|
|
106
|
+
"BlobHead",
|
|
107
|
+
"BlobTrace",
|
|
108
|
+
"MemoryBlobStore",
|
|
109
|
+
"FileBlobStore",
|
|
110
|
+
"S3BlobStore",
|
|
111
|
+
"PrefixedBlobStore",
|
|
112
|
+
"PackedBlobStore",
|
|
113
|
+
"PackWriter",
|
|
114
|
+
"PackResult",
|
|
115
|
+
"pack_index",
|
|
116
|
+
"pack_to_bytes",
|
|
117
|
+
"copy_objects",
|
|
118
|
+
"store_at",
|
|
119
|
+
"WarmPrefixStore",
|
|
120
|
+
"build_warm_pack",
|
|
121
|
+
"write_warm_pack",
|
|
122
|
+
"parse_warm_pack",
|
|
123
|
+
"seed_warm_pack",
|
|
124
|
+
"snapshot_keys",
|
|
125
|
+
"CachedBlobStore",
|
|
126
|
+
"BlobCache",
|
|
127
|
+
"MemoryCache",
|
|
128
|
+
"DiskCache",
|
|
129
|
+
"ResolvedLexConfig",
|
|
130
|
+
"RevState",
|
|
131
|
+
"LexIndex",
|
|
132
|
+
"LexSegment",
|
|
133
|
+
"keys",
|
|
134
|
+
"sign_v4",
|
|
135
|
+
"SigningKeyCache",
|
|
136
|
+
"S3_INFLIGHT",
|
|
137
|
+
"MULTI_COALESCE",
|
|
138
|
+
"MISS_POLICIES",
|
|
139
|
+
"coalesce_runs",
|
|
140
|
+
"resolve_target",
|
|
141
|
+
"load_rev_state",
|
|
142
|
+
"head_revision",
|
|
143
|
+
]
|
thread_sdk/_native.pyd
ADDED
|
Binary file
|
thread_sdk/_native.pyi
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Type stubs for the native extension (crates/thread-py): thread-core's kernels, without I/O."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from typing_extensions import Buffer
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
__version__: str
|
|
9
|
+
|
|
10
|
+
FloatsLike = Buffer | Sequence[float]
|
|
11
|
+
IdsLike = Buffer | Sequence[int]
|
|
12
|
+
|
|
13
|
+
class MissingRanges(Exception):
|
|
14
|
+
ranges: list[tuple[int, int]]
|
|
15
|
+
|
|
16
|
+
class IndexFormatError(ValueError): ...
|
|
17
|
+
class IndexCorruptError(ValueError): ...
|
|
18
|
+
class IndexStateError(RuntimeError): ...
|
|
19
|
+
|
|
20
|
+
class PqCodebook:
|
|
21
|
+
def __init__(self, codebook: Buffer) -> None: ...
|
|
22
|
+
@property
|
|
23
|
+
def dim(self) -> int: ...
|
|
24
|
+
@property
|
|
25
|
+
def m(self) -> int: ...
|
|
26
|
+
|
|
27
|
+
class Query:
|
|
28
|
+
@property
|
|
29
|
+
def dim(self) -> int: ...
|
|
30
|
+
@property
|
|
31
|
+
def has_dots(self) -> bool: ...
|
|
32
|
+
@property
|
|
33
|
+
def has_table(self) -> bool: ...
|
|
34
|
+
def vector(self) -> list[float]: ...
|
|
35
|
+
|
|
36
|
+
class Searcher:
|
|
37
|
+
def __init__(self, dim: int, k1: int, k2: int, coarse_means: FloatsLike, fine_means: FloatsLike) -> None: ...
|
|
38
|
+
@staticmethod
|
|
39
|
+
def from_table(dim: int, k1: int, k2: int, coarse_means: FloatsLike, table: Buffer, elem: str) -> Searcher: ...
|
|
40
|
+
@property
|
|
41
|
+
def dim(self) -> int: ...
|
|
42
|
+
@property
|
|
43
|
+
def cells(self) -> int: ...
|
|
44
|
+
def query(self, q: FloatsLike, dots: bool = False, codebook: PqCodebook | None = None) -> Query: ...
|
|
45
|
+
def route(self, query: Query, probe_coarse: int, nprobe: int) -> list[int]: ...
|
|
46
|
+
|
|
47
|
+
class ColdRouter:
|
|
48
|
+
def __init__(self, tree: Buffer, elem: str, head: Buffer | None = None) -> None: ...
|
|
49
|
+
@property
|
|
50
|
+
def needs_head(self) -> bool: ...
|
|
51
|
+
def set_head(self, head: Buffer) -> None: ...
|
|
52
|
+
@property
|
|
53
|
+
def dim(self) -> int: ...
|
|
54
|
+
@property
|
|
55
|
+
def k(self) -> int: ...
|
|
56
|
+
@property
|
|
57
|
+
def groups(self) -> int: ...
|
|
58
|
+
@property
|
|
59
|
+
def row_bytes(self) -> int: ...
|
|
60
|
+
@property
|
|
61
|
+
def loaded_cells(self) -> int: ...
|
|
62
|
+
@property
|
|
63
|
+
def loaded_groups(self) -> int: ...
|
|
64
|
+
def group_rows(self, g: int) -> tuple[int, int]: ...
|
|
65
|
+
def cell_row(self, c: int) -> int | None: ...
|
|
66
|
+
def missing_cells(self, cells: IdsLike) -> list[int]: ...
|
|
67
|
+
def missing_groups(self, query: Query, beam: int) -> list[int]: ...
|
|
68
|
+
def add_rows(self, first_row: int, rows: Buffer) -> int: ...
|
|
69
|
+
def query(self, q: FloatsLike, dot_cells: IdsLike | None = None, codebook: PqCodebook | None = None) -> Query: ...
|
|
70
|
+
def route_beam(self, query: Query, beam: int, nprobe: int) -> list[int]: ...
|
|
71
|
+
|
|
72
|
+
def routing_table_encode(means: FloatsLike, dim: int, elem: str) -> bytes: ...
|
|
73
|
+
def routing_table_decode(table: Buffer, dim: int, elem: str) -> bytes: ...
|
|
74
|
+
def grouped_table_encode(means: FloatsLike, dim: int, tree: Buffer, elem: str) -> bytes: ...
|
|
75
|
+
def grouped_table_info(table: Buffer) -> list[int]: ...
|
|
76
|
+
def build_centroid_tree(means: FloatsLike, dim: int, groups: int, iters: int, seed: int) -> bytes: ...
|
|
77
|
+
|
|
78
|
+
FilterLike = Buffer | Sequence[int] | None
|
|
79
|
+
|
|
80
|
+
class QueryRun:
|
|
81
|
+
def __init__(self, query: Query, t: int, do_rerank: bool, return_payload: bool,
|
|
82
|
+
filter: FilterLike = None) -> None: ...
|
|
83
|
+
def candidates(self) -> int: ...
|
|
84
|
+
def add_slab(self, cell: int, prefix: Buffer, full: Buffer | None = None,
|
|
85
|
+
tombstones: Buffer | None = None) -> tuple[bool, int]: ...
|
|
86
|
+
def speculation(self) -> list[tuple[int, int, int, list[int]]]: ...
|
|
87
|
+
def add_delta(self, delta: Buffer, tombstones: Buffer | None = None) -> None: ...
|
|
88
|
+
def add_records(self, cell: int, first_idx: int, data: Buffer, idxs: Sequence[int], spec: bool = False) -> None: ...
|
|
89
|
+
def plan(self) -> list[tuple[int, int, int, list[int]]]: ...
|
|
90
|
+
def finish(self, k: int) -> tuple[list[tuple[int, float, int, Literal["slab", "delta"], bytes | None]],
|
|
91
|
+
list[tuple[int, int, int, int]]]: ...
|
|
92
|
+
def stats(self) -> dict[str, int]: ...
|
|
93
|
+
|
|
94
|
+
def scan(slab: Buffer, query: Query, k: int, tombstones: Buffer | None = None,
|
|
95
|
+
filter: FilterLike = None) -> tuple[bytes, int, int, int]: ...
|
|
96
|
+
def scan_delta(delta: Buffer, query: Query, k: int, tombstones: Buffer | None = None,
|
|
97
|
+
filter: FilterLike = None) -> tuple[bytes, int, int, int]: ...
|
|
98
|
+
def filter_check(filter: FilterLike = None) -> int: ...
|
|
99
|
+
def filter_matches(filter: FilterLike, attrs: IdsLike) -> list[bool]: ...
|
|
100
|
+
def merge(packed: Buffer, k: int) -> bytes: ...
|
|
101
|
+
def rerank(query: Query, centroid: FloatsLike, records: Buffer, n: int) -> list[float]: ...
|
|
102
|
+
def normalize_rows(vectors: FloatsLike, dim: int) -> tuple[bytes, int]: ...
|
|
103
|
+
|
|
104
|
+
class DeltaJob:
|
|
105
|
+
def __init__(self, dim: int, k: int, means: FloatsLike, seq: int) -> None: ...
|
|
106
|
+
def set_pq_codebook(self, codebook: PqCodebook) -> None: ...
|
|
107
|
+
def add(self, vectors: FloatsLike, ids: IdsLike, attrs: IdsLike | None = None,
|
|
108
|
+
payloads: Sequence[Buffer] | None = None) -> list[int]: ...
|
|
109
|
+
def __len__(self) -> int: ...
|
|
110
|
+
@property
|
|
111
|
+
def byte_len(self) -> int: ...
|
|
112
|
+
@property
|
|
113
|
+
def pq_m(self) -> int: ...
|
|
114
|
+
def finish(self) -> bytes: ...
|
|
115
|
+
|
|
116
|
+
def delta_info(delta: Buffer) -> list[int]: ...
|
|
117
|
+
def delta_cells(delta: Buffer) -> list[tuple[int, int]]: ...
|
|
118
|
+
def delta_records_for_cell(delta: Buffer, cell: int, tombstones: Buffer | None = None) -> bytes: ...
|
|
119
|
+
def tombstones_merge(existing: Buffer, add: IdsLike) -> bytes: ...
|
|
120
|
+
|
|
121
|
+
class AssignJob:
|
|
122
|
+
def __init__(self, dim: int, k1: int, k2: int, coarse_means: FloatsLike, fine_means: FloatsLike,
|
|
123
|
+
probe_coarse: int, m: int) -> None: ...
|
|
124
|
+
def set_codebook(self, codebook: Buffer) -> None: ...
|
|
125
|
+
def add_batch(self, vectors: FloatsLike, first_id: int) -> int: ...
|
|
126
|
+
def take_part(self, g: int) -> bytes: ...
|
|
127
|
+
|
|
128
|
+
class SlabJob:
|
|
129
|
+
def __init__(self, dim: int, cell: int, centroid_mean: FloatsLike, format: int = 2) -> None: ...
|
|
130
|
+
def add_records(self, records: Buffer) -> int: ...
|
|
131
|
+
def finish(self) -> bytes: ...
|
|
132
|
+
|
|
133
|
+
def part_directory(part: Buffer) -> tuple[int, int, list[tuple[int, int, int]]]: ...
|
|
134
|
+
def slab_info(slab: Buffer) -> list[int]: ...
|
|
135
|
+
def residuals_for(vectors: FloatsLike, means: FloatsLike, assign: IdsLike, dim: int) -> bytes: ...
|
|
136
|
+
def pq_init_codebook(residuals: FloatsLike, dim: int, m: int, seed: int) -> bytes: ...
|
|
137
|
+
|
|
138
|
+
class LexTokenizer:
|
|
139
|
+
def __init__(self, config_json: str | None = None) -> None: ...
|
|
140
|
+
def tokenize(self, text: str) -> list[str]: ...
|
|
141
|
+
def config(self) -> str: ...
|
|
142
|
+
|
|
143
|
+
def tokenizer_config_json(config_json: str | None = None) -> str: ...
|
|
144
|
+
|
|
145
|
+
class SegmentJob:
|
|
146
|
+
def __init__(self, tokenizer_json: str | None, k1: float, b: float, lengths: str, bucket_terms: int, docs: bool,
|
|
147
|
+
text: bool, bucket_docs: int, keys: bool = False, bucket_keys: int = 0) -> None: ...
|
|
148
|
+
def add(self, id: int, text: str | None = None, meta: str | None = None, attrs: int = 0,
|
|
149
|
+
key: str | None = None) -> int: ...
|
|
150
|
+
def __len__(self) -> int: ...
|
|
151
|
+
@property
|
|
152
|
+
def tokens(self) -> int: ...
|
|
153
|
+
def finish(self) -> bytes: ...
|
|
154
|
+
|
|
155
|
+
def segment_meta(segment: Buffer) -> str: ...
|
|
156
|
+
def merge_segments(segments: Sequence[Buffer], tombstones: Sequence[Buffer | None], tokenizer_json: str | None,
|
|
157
|
+
k1: float, b: float, lengths: str, bucket_terms: int, docs: bool, text: bool,
|
|
158
|
+
bucket_docs: int, keys: bool = False, bucket_keys: int = 0) -> bytes: ...
|
|
159
|
+
|
|
160
|
+
class SegmentReader:
|
|
161
|
+
def __init__(self, total_len: int) -> None: ...
|
|
162
|
+
@property
|
|
163
|
+
def total_len(self) -> int: ...
|
|
164
|
+
@property
|
|
165
|
+
def held_bytes(self) -> int: ...
|
|
166
|
+
def feed(self, offset: int, data: Buffer) -> None: ...
|
|
167
|
+
def evict_ranges(self, ranges: Sequence[tuple[int, int]]) -> None: ...
|
|
168
|
+
def evict_all(self) -> None: ...
|
|
169
|
+
def missing(self, ranges: Sequence[tuple[int, int]]) -> list[tuple[int, int]]: ...
|
|
170
|
+
def plan_open(self) -> list[tuple[int, int]]: ...
|
|
171
|
+
def open(self) -> dict[str, Any]: ...
|
|
172
|
+
def plan_headers(self) -> list[tuple[int, int]]: ...
|
|
173
|
+
def info(self) -> dict[str, Any]: ...
|
|
174
|
+
def plan_directories(self) -> list[tuple[int, int]]: ...
|
|
175
|
+
def lookup(self, terms: Sequence[str]) -> list[tuple[int, int, int]]: ...
|
|
176
|
+
def search(self, infos: Sequence[tuple[int, int, int]], dfs: Sequence[float], weights: Sequence[float],
|
|
177
|
+
num_docs: int, total_len: int, k1: float, b: float, k: int, tombstones: Buffer | None = None) -> bytes: ...
|
|
178
|
+
def docs_get(self, ids: IdsLike, with_text: bool) -> list[tuple[int, str, str | None, int] | None]: ...
|
|
179
|
+
def keys_get(self, keys: Sequence[str]) -> list[int | None]: ...
|
|
180
|
+
def keys_list(self, prefix: str, after: str | None = None, limit: int = 100) -> dict[str, Any]: ...
|
|
181
|
+
|
|
182
|
+
class SearchPlan:
|
|
183
|
+
@property
|
|
184
|
+
def mode(self) -> Literal["dense", "lexical", "hybrid"]: ...
|
|
185
|
+
@property
|
|
186
|
+
def top_k(self) -> int: ...
|
|
187
|
+
@property
|
|
188
|
+
def depth(self) -> int: ...
|
|
189
|
+
@property
|
|
190
|
+
def rrf_k(self) -> float: ...
|
|
191
|
+
@property
|
|
192
|
+
def dense_weight(self) -> float: ...
|
|
193
|
+
@property
|
|
194
|
+
def lexical_weight(self) -> float: ...
|
|
195
|
+
|
|
196
|
+
FusionParams = tuple[float | None, int | None, float | None, float | None]
|
|
197
|
+
|
|
198
|
+
def plan_search(top_k: float | None, has_text: bool, has_vector: bool, has_lexical: bool, has_dense: bool,
|
|
199
|
+
request: FusionParams | None = None, manifest: FusionParams | None = None) -> SearchPlan: ...
|
|
200
|
+
def fuse_search(plan: SearchPlan, dense: tuple[Sequence[int], Sequence[float]] | None = None,
|
|
201
|
+
lexical: tuple[Sequence[int], Sequence[float]] | None = None,
|
|
202
|
+
) -> list[tuple[int, float, tuple[int, float] | None, tuple[int, float] | None]]: ...
|
thread_sdk/attrs.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Attributes and filters, the Python twin of the TypeScript SDK's `attrs.ts`.
|
|
2
|
+
|
|
3
|
+
Every document carries one 32-bit `attrs` word, stored in its dense entry (slabs and delta segments, inside the scan
|
|
4
|
+
prefix every query reads anyway) and in its `docs` entry. A generation may declare a schema (`manifest["attrs"]`)
|
|
5
|
+
that packs named fields into that word; `pack_attrs` turns `{"tenant": 5, "lang": "en"}` into the word and
|
|
6
|
+
`compile_filter` turns `{"tenant": 5, "lang": {"$in": ["en", "fr"]}}` into the clause list the kernels apply inside
|
|
7
|
+
every scan, before top-k insertion (so a filtered-out entry never takes a candidate slot). Without a schema the
|
|
8
|
+
whole word is one numeric field named `attrs`.
|
|
9
|
+
|
|
10
|
+
Compiled form (`u32` words, `thread-core/src/filter.rs`): `[n, (mask, op, a, b, n_values, values...) x n]`, the
|
|
11
|
+
clauses ANDed; every clause tests `x = attrs & mask`. Both SDKs compile the same expressions to the same words
|
|
12
|
+
(`sdk/ts/test/fixtures/filters.json` holds the shared vectors).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
from array import array
|
|
20
|
+
from typing import Any, Mapping, Sequence
|
|
21
|
+
|
|
22
|
+
from .wire import ThreadError
|
|
23
|
+
|
|
24
|
+
__all__ = ["define_attrs", "validate_schema", "schema_extends", "pack_attrs", "unpack_attrs", "compile_filter",
|
|
25
|
+
"filter_matches", "AttrSchema", "AttrFilterExpr"]
|
|
26
|
+
|
|
27
|
+
#: `{"fields": [{"name", "type": "number" | "enum" | "bool", "offset", "bits", "min"?, "values"?}, ...]}`.
|
|
28
|
+
AttrSchema = dict[str, Any]
|
|
29
|
+
#: `{field: value | {"$eq" | "$ne" | "$in" | "$nin" | "$lt" | "$lte" | "$gt" | "$gte" | "$any" | "$all" | "$none": ...}}`.
|
|
30
|
+
AttrFilterExpr = Mapping[str, Any]
|
|
31
|
+
|
|
32
|
+
OP_EQ, OP_NE, OP_IN, OP_NIN, OP_RANGE, OP_BITS_ANY, OP_BITS_ALL, OP_BITS_NONE = range(8)
|
|
33
|
+
|
|
34
|
+
_RAW_FIELD: dict[str, Any] = {"name": "attrs", "type": "number", "offset": 0, "bits": 32}
|
|
35
|
+
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
36
|
+
_U32 = 0xFFFFFFFF
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_int(v: Any) -> bool:
|
|
40
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _mask(bits: int) -> int:
|
|
44
|
+
return _U32 if bits >= 32 else (1 << bits) - 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def define_attrs(spec: Mapping[str, Any], prev: AttrSchema | None = None) -> AttrSchema:
|
|
48
|
+
"""Lays out named fields in declaration order into the 32-bit word. Each field is `{"bits": n, "min"?: m}` (an
|
|
49
|
+
unsigned integer), `{"values": [...]}` (an enum, stored as 1..n) or `"bool"`. With `prev` (the schema in
|
|
50
|
+
force), fields that already exist keep their offset and width (an enum may append values that fit its width;
|
|
51
|
+
a number keeps its bits and min), fields `prev` has that `spec` omits are kept, and new fields go after them:
|
|
52
|
+
stored words stay valid."""
|
|
53
|
+
fields: list[dict[str, Any]] = [dict(f, **({"values": list(f["values"])} if "values" in f else {}))
|
|
54
|
+
for f in (prev["fields"] if prev else [])]
|
|
55
|
+
offset = max((f["offset"] + f["bits"] for f in fields), default=0)
|
|
56
|
+
for name, s in spec.items():
|
|
57
|
+
if not isinstance(name, str) or not _NAME.match(name):
|
|
58
|
+
raise ThreadError(f"attrs: bad field name {name!r}", 400)
|
|
59
|
+
if s == "bool" or (isinstance(s, Mapping) and s.get("bool")):
|
|
60
|
+
f: dict[str, Any] = {"name": name, "type": "bool", "offset": offset, "bits": 1}
|
|
61
|
+
elif isinstance(s, Mapping) and "values" in s:
|
|
62
|
+
values = s["values"]
|
|
63
|
+
if (not isinstance(values, (list, tuple)) or not values or any(not isinstance(v, str) for v in values)
|
|
64
|
+
or len(set(values)) != len(values)):
|
|
65
|
+
raise ThreadError(f"attrs: field {name}: values must be distinct strings", 400)
|
|
66
|
+
f = {"name": name, "type": "enum", "offset": offset, "bits": max(1, math.ceil(math.log2(len(values) + 1))),
|
|
67
|
+
"values": list(values)}
|
|
68
|
+
elif isinstance(s, Mapping) and "bits" in s:
|
|
69
|
+
bits, mn = s["bits"], s.get("min")
|
|
70
|
+
if not _is_int(bits) or not 1 <= bits <= 32:
|
|
71
|
+
raise ThreadError(f"attrs: field {name}: bits must be 1..32", 400)
|
|
72
|
+
if mn is not None and not _is_int(mn):
|
|
73
|
+
raise ThreadError(f"attrs: field {name}: min must be an integer", 400)
|
|
74
|
+
f = {"name": name, "type": "number", "offset": offset, "bits": bits}
|
|
75
|
+
if mn:
|
|
76
|
+
f["min"] = mn
|
|
77
|
+
else:
|
|
78
|
+
raise ThreadError(f"attrs: field {name}: expected {{bits}}, {{values}} or \"bool\"", 400)
|
|
79
|
+
at = next((i for i, p in enumerate(fields) if p["name"] == name), -1)
|
|
80
|
+
if at >= 0:
|
|
81
|
+
# an existing field keeps its layout
|
|
82
|
+
p = fields[at]
|
|
83
|
+
same = p["type"] == f["type"] and (p["type"] != "number" or (p["bits"] == f["bits"] and (p.get("min") or 0) == (f.get("min") or 0)))
|
|
84
|
+
enum_ok = p["type"] != "enum" or (f["values"][: len(p["values"])] == p["values"] and len(f["values"]) <= _mask(p["bits"]))
|
|
85
|
+
if not same or not enum_ok:
|
|
86
|
+
raise ThreadError(f"attrs: field {name} exists with another layout (a schema only adds fields, or enum "
|
|
87
|
+
f"values that fit the field's {p['bits']} bits)", 400)
|
|
88
|
+
if "values" in f:
|
|
89
|
+
fields[at] = {**p, "values": f["values"]}
|
|
90
|
+
continue
|
|
91
|
+
offset += f["bits"]
|
|
92
|
+
fields.append(f)
|
|
93
|
+
if offset > 32:
|
|
94
|
+
raise ThreadError(f"attrs: the fields need {offset} bits, the word has 32", 400)
|
|
95
|
+
return validate_schema({"fields": fields})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_schema(schema: Any) -> AttrSchema:
|
|
99
|
+
"""Checks a schema read from a manifest (fields disjoint and within the word, names unique)."""
|
|
100
|
+
if not isinstance(schema, Mapping) or not isinstance(schema.get("fields"), list):
|
|
101
|
+
raise ThreadError("attrs: schema needs a fields array", 400)
|
|
102
|
+
names: set[str] = set()
|
|
103
|
+
used = 0
|
|
104
|
+
for f in schema["fields"]:
|
|
105
|
+
name = f.get("name") if isinstance(f, Mapping) else None
|
|
106
|
+
if not isinstance(name, str) or not name or name in names:
|
|
107
|
+
raise ThreadError(f"attrs: bad or duplicate field name {name!r}", 400)
|
|
108
|
+
names.add(name)
|
|
109
|
+
if f.get("type") not in ("number", "enum", "bool"):
|
|
110
|
+
raise ThreadError(f"attrs: field {name}: unknown type {f.get('type')!r}", 400)
|
|
111
|
+
off, bits = f.get("offset"), f.get("bits")
|
|
112
|
+
if not _is_int(off) or not _is_int(bits) or off < 0 or bits < 1 or off + bits > 32:
|
|
113
|
+
raise ThreadError(f"attrs: field {name}: bad offset/bits", 400)
|
|
114
|
+
if f["type"] == "bool" and bits != 1:
|
|
115
|
+
raise ThreadError(f"attrs: field {name}: a bool is one bit", 400)
|
|
116
|
+
if f["type"] == "enum" and (not isinstance(f.get("values"), list) or len(f["values"]) > _mask(bits)):
|
|
117
|
+
raise ThreadError(f"attrs: field {name}: enum values do not fit {bits} bits", 400)
|
|
118
|
+
m = _mask(bits) << off
|
|
119
|
+
if used & m:
|
|
120
|
+
raise ThreadError(f"attrs: field {name} overlaps another field", 400)
|
|
121
|
+
used |= m
|
|
122
|
+
return dict(schema)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def schema_extends(prev: AttrSchema, nxt: AttrSchema) -> bool:
|
|
126
|
+
"""Whether `nxt` keeps every field of `prev` as it is (an enum may append values): the rule for changing a
|
|
127
|
+
schema."""
|
|
128
|
+
by_name = {f["name"]: f for f in nxt["fields"]}
|
|
129
|
+
for p in prev["fields"]:
|
|
130
|
+
n = by_name.get(p["name"])
|
|
131
|
+
if (n is None or n["type"] != p["type"] or n["offset"] != p["offset"] or n["bits"] != p["bits"]
|
|
132
|
+
or (n.get("min") or 0) != (p.get("min") or 0)):
|
|
133
|
+
return False
|
|
134
|
+
if p["type"] == "enum" and n.get("values", [])[: len(p.get("values", []))] != p.get("values", []):
|
|
135
|
+
return False
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _field(schema: AttrSchema | None, name: str) -> dict[str, Any]:
|
|
140
|
+
if schema:
|
|
141
|
+
for f in schema["fields"]:
|
|
142
|
+
if f["name"] == name:
|
|
143
|
+
return f
|
|
144
|
+
if not (schema and schema["fields"]) and name == _RAW_FIELD["name"]:
|
|
145
|
+
return _RAW_FIELD
|
|
146
|
+
if schema:
|
|
147
|
+
raise ThreadError(f"attrs: unknown field {name!r}", 400)
|
|
148
|
+
raise ThreadError('attrs: this index has no attribute schema; the only field is "attrs"', 400)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _encode(f: dict[str, Any], v: Any) -> int | None:
|
|
152
|
+
"""A field value as the unsigned integer it is stored as, or None when it cannot be represented."""
|
|
153
|
+
if f["type"] == "bool":
|
|
154
|
+
if v is True or (v == 1 and _is_int(v)):
|
|
155
|
+
return 1
|
|
156
|
+
if v is False or (v == 0 and _is_int(v)):
|
|
157
|
+
return 0
|
|
158
|
+
return None
|
|
159
|
+
if f["type"] == "enum":
|
|
160
|
+
if not isinstance(v, str):
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
return f["values"].index(v) + 1
|
|
164
|
+
except ValueError:
|
|
165
|
+
return None
|
|
166
|
+
if not _is_int(v):
|
|
167
|
+
return None
|
|
168
|
+
x = v - (f.get("min") or 0)
|
|
169
|
+
return x if 0 <= x <= _mask(f["bits"]) else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def pack_attrs(schema: AttrSchema | None, values: Any) -> int:
|
|
173
|
+
"""Packs field values into the word (unset fields are 0). An int is taken as the raw word."""
|
|
174
|
+
if _is_int(values):
|
|
175
|
+
if not 0 <= values <= _U32:
|
|
176
|
+
raise ThreadError("attrs: a raw word must be a u32", 400)
|
|
177
|
+
return values
|
|
178
|
+
if not isinstance(values, Mapping):
|
|
179
|
+
raise ThreadError("attrs: expected a mapping of field values or a u32", 400)
|
|
180
|
+
word = 0
|
|
181
|
+
for name, v in values.items():
|
|
182
|
+
if v is None:
|
|
183
|
+
continue
|
|
184
|
+
f = _field(schema, name)
|
|
185
|
+
x = _encode(f, v)
|
|
186
|
+
if x is None:
|
|
187
|
+
extra = f" ({f['bits']} bits from {f.get('min') or 0})" if f["type"] == "number" else ""
|
|
188
|
+
raise ThreadError(f"attrs: {name}: {v!r} is not a valid {f['type']}{extra}", 400)
|
|
189
|
+
word |= x << f["offset"]
|
|
190
|
+
return word
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def unpack_attrs(schema: AttrSchema | None, word: int) -> dict[str, Any]:
|
|
194
|
+
"""The field values of a word under the schema (`{"attrs": word}` without one)."""
|
|
195
|
+
if not (schema and schema["fields"]):
|
|
196
|
+
return {"attrs": word & _U32}
|
|
197
|
+
out: dict[str, Any] = {}
|
|
198
|
+
for f in schema["fields"]:
|
|
199
|
+
x = (word >> f["offset"]) & _mask(f["bits"])
|
|
200
|
+
if f["type"] == "bool":
|
|
201
|
+
out[f["name"]] = x == 1
|
|
202
|
+
elif f["type"] == "enum":
|
|
203
|
+
out[f["name"]] = "" if x == 0 or x > len(f["values"]) else f["values"][x - 1]
|
|
204
|
+
else:
|
|
205
|
+
out[f["name"]] = x + (f.get("min") or 0)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_Clause = tuple[int, int, int, int, list[int]]
|
|
210
|
+
_NOTHING: _Clause = (0, OP_RANGE, 1, 0, [])
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def compile_filter(schema: AttrSchema | None, expr: AttrFilterExpr | None) -> array:
|
|
214
|
+
"""Compiles a filter expression against the schema into the kernel's words (`array('I')`, empty for no
|
|
215
|
+
filter). Conditions on different fields are ANDed; within a field each operator adds a clause (`$gt`/`$gte`/
|
|
216
|
+
`$lt`/`$lte` combine into one range). A value a field cannot hold makes `$eq` match nothing and `$ne` match
|
|
217
|
+
everything; such values are dropped from `$in` / `$nin`."""
|
|
218
|
+
if not expr:
|
|
219
|
+
return array("I")
|
|
220
|
+
if not isinstance(expr, Mapping):
|
|
221
|
+
raise ThreadError("filter: expected a mapping of field conditions", 400)
|
|
222
|
+
clauses: list[_Clause] = []
|
|
223
|
+
for name, cond in expr.items():
|
|
224
|
+
if not isinstance(name, str) or name.startswith("$"):
|
|
225
|
+
raise ThreadError(f"filter: unsupported operator {name} at the top level (conditions are ANDed; nest "
|
|
226
|
+
"operators under a field)", 400)
|
|
227
|
+
f = _field(schema, name)
|
|
228
|
+
shift = f["offset"]
|
|
229
|
+
fmask = _mask(f["bits"])
|
|
230
|
+
mask = fmask << shift
|
|
231
|
+
c = cond if isinstance(cond, Mapping) else {"$eq": cond}
|
|
232
|
+
|
|
233
|
+
def enc(v: Any, op: str) -> int | None:
|
|
234
|
+
if v is None or not isinstance(v, (int, str, bool)):
|
|
235
|
+
raise ThreadError(f"filter: {name} {op}: bad value {v!r}", 400)
|
|
236
|
+
x = _encode(f, v)
|
|
237
|
+
return None if x is None else x << shift
|
|
238
|
+
|
|
239
|
+
lo, hi, ranged = 0, fmask, False
|
|
240
|
+
for op, v in c.items():
|
|
241
|
+
if op == "$eq":
|
|
242
|
+
x = enc(v, op)
|
|
243
|
+
clauses.append(_NOTHING if x is None else (mask, OP_EQ, x, 0, []))
|
|
244
|
+
elif op == "$ne":
|
|
245
|
+
x = enc(v, op)
|
|
246
|
+
if x is not None:
|
|
247
|
+
clauses.append((mask, OP_NE, x, 0, []))
|
|
248
|
+
elif op in ("$in", "$nin"):
|
|
249
|
+
if not isinstance(v, (list, tuple)):
|
|
250
|
+
raise ThreadError(f"filter: {name} {op}: expected a list", 400)
|
|
251
|
+
xs = sorted({x for x in (enc(e, op) for e in v) if x is not None})
|
|
252
|
+
if op == "$in":
|
|
253
|
+
clauses.append((mask, OP_IN, 0, 0, xs) if xs else _NOTHING)
|
|
254
|
+
elif xs:
|
|
255
|
+
clauses.append((mask, OP_NIN, 0, 0, xs))
|
|
256
|
+
elif op in ("$lt", "$lte", "$gt", "$gte"):
|
|
257
|
+
if f["type"] != "number" or not _is_int(v):
|
|
258
|
+
raise ThreadError(f"filter: {name} {op}: needs an integer on a number field", 400)
|
|
259
|
+
x = v - (f.get("min") or 0)
|
|
260
|
+
ranged = True
|
|
261
|
+
if op == "$lt":
|
|
262
|
+
hi = min(hi, x - 1)
|
|
263
|
+
elif op == "$lte":
|
|
264
|
+
hi = min(hi, x)
|
|
265
|
+
elif op == "$gt":
|
|
266
|
+
lo = max(lo, x + 1)
|
|
267
|
+
else:
|
|
268
|
+
lo = max(lo, x)
|
|
269
|
+
elif op in ("$any", "$all", "$none"):
|
|
270
|
+
if f["type"] != "number" or not _is_int(v) or not 0 <= v <= _U32:
|
|
271
|
+
raise ThreadError(f"filter: {name} {op}: needs a u32 bit mask on a number field", 400)
|
|
272
|
+
m = (v & fmask) << shift
|
|
273
|
+
if m == 0:
|
|
274
|
+
if op == "$any":
|
|
275
|
+
clauses.append(_NOTHING)
|
|
276
|
+
continue
|
|
277
|
+
clauses.append((m, {"$any": OP_BITS_ANY, "$all": OP_BITS_ALL, "$none": OP_BITS_NONE}[op], 0, 0, []))
|
|
278
|
+
else:
|
|
279
|
+
raise ThreadError(f"filter: {name}: unknown operator {op}", 400)
|
|
280
|
+
if ranged:
|
|
281
|
+
lo, hi = max(lo, 0), min(hi, fmask)
|
|
282
|
+
if lo > hi:
|
|
283
|
+
clauses.append(_NOTHING)
|
|
284
|
+
elif lo > 0 or hi < fmask:
|
|
285
|
+
clauses.append((mask, OP_RANGE, lo << shift, hi << shift, []))
|
|
286
|
+
return clauses_to_words(clauses)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def clauses_to_words(clauses: Sequence[tuple[int, int, int, int, Sequence[int]]]) -> array:
|
|
290
|
+
out = array("I")
|
|
291
|
+
if not clauses:
|
|
292
|
+
return out
|
|
293
|
+
out.append(len(clauses))
|
|
294
|
+
for mask, op, a, b, values in clauses:
|
|
295
|
+
out.extend((mask & _U32, op, a & _U32, b & _U32, len(values)))
|
|
296
|
+
out.extend(v & _U32 for v in values)
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def filter_matches(words: Sequence[int] | None, attrs: int) -> bool:
|
|
301
|
+
"""Evaluates compiled words in Python (the kernel's `AttrFilter::matches` rule)."""
|
|
302
|
+
if not words:
|
|
303
|
+
return True
|
|
304
|
+
n, pos = words[0], 1
|
|
305
|
+
for _ in range(n):
|
|
306
|
+
mask, op, a, b, nv = words[pos : pos + 5]
|
|
307
|
+
pos += 5
|
|
308
|
+
values = words[pos : pos + nv]
|
|
309
|
+
pos += nv
|
|
310
|
+
x = attrs & mask
|
|
311
|
+
if op == OP_EQ:
|
|
312
|
+
ok = x == a
|
|
313
|
+
elif op == OP_NE:
|
|
314
|
+
ok = x != a
|
|
315
|
+
elif op == OP_IN:
|
|
316
|
+
ok = x in values
|
|
317
|
+
elif op == OP_NIN:
|
|
318
|
+
ok = x not in values
|
|
319
|
+
elif op == OP_RANGE:
|
|
320
|
+
ok = a <= x <= b
|
|
321
|
+
elif op == OP_BITS_ANY:
|
|
322
|
+
ok = x != 0
|
|
323
|
+
elif op == OP_BITS_ALL:
|
|
324
|
+
ok = x == mask
|
|
325
|
+
elif op == OP_BITS_NONE:
|
|
326
|
+
ok = x == 0
|
|
327
|
+
else:
|
|
328
|
+
raise ThreadError(f"filter: unknown op {op}", 400)
|
|
329
|
+
if not ok:
|
|
330
|
+
return False
|
|
331
|
+
return True
|