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 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