smartpipe-cli 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,161 @@
1
+ """Chunking math for the recursive ``reduce`` (spec §3.5) — pure.
2
+
3
+ The reduce tree "just works": when the input exceeds what the model can hold,
4
+ smartpipe splits it into chunks, summarizes each, and recurses on the summaries —
5
+ no flags, no strategy selection. This module owns the arithmetic (token estimate,
6
+ context budget, item-boundary splitting); the verb drives the recursion, since
7
+ each level's size isn't known until the model produces the notes.
8
+
9
+ Token estimation is deliberately crude (≈4 chars/token) and used with a safety
10
+ factor — being conservative just means more levels, never a truncated call.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+
21
+ __all__ = [
22
+ "budget_for",
23
+ "chunk_indices",
24
+ "estimate_tokens",
25
+ "fits_in_one",
26
+ "halve",
27
+ "is_context_overflow",
28
+ "mean_pool",
29
+ "split_text",
30
+ ]
31
+
32
+ # Conservative per-provider input windows; ollama is deliberately small because we
33
+ # can't cheaply know a local model's context length (a smaller budget is always safe).
34
+ _CONTEXT: dict[str, int] = {
35
+ "ollama": 8000,
36
+ "openai": 128000,
37
+ "anthropic": 200000,
38
+ "mistral": 128000,
39
+ "gemini": 128000, # conservative: flash models carry ≥1M, but budget for the floor
40
+ "openrouter": 32000, # unknowable per-model — the safe floor for routed models
41
+ }
42
+ _SAFETY = 0.6
43
+ _CHARS_PER_TOKEN = 4
44
+
45
+
46
+ def estimate_tokens(text: str) -> int:
47
+ return math.ceil(len(text) / _CHARS_PER_TOKEN) if text else 0
48
+
49
+
50
+ def budget_for(provider: str, *, prompt_overhead: int, window: int | None = None) -> int:
51
+ """Token budget for one call. ``window`` (from a live probe or the
52
+ SMARTPIPE_CONTEXT_TOKENS override) beats the static table when present."""
53
+ resolved = window if window is not None else _CONTEXT.get(provider, _CONTEXT["ollama"])
54
+ return int(resolved * _SAFETY) - prompt_overhead
55
+
56
+
57
+ _OVERFLOW_MARKERS = (
58
+ "context_length",
59
+ "context length",
60
+ "context window",
61
+ "maximum context",
62
+ "too long",
63
+ "too large",
64
+ "input length",
65
+ "prompt is too long",
66
+ "exceeds the limit",
67
+ )
68
+
69
+
70
+ def is_context_overflow(message: str) -> bool:
71
+ """Does this per-item error text look like a context-window overflow?
72
+
73
+ The classifier that lets reduce self-correct (D26): estimates are hints,
74
+ the wire's own rejection is ground truth — matched loosely on the phrases
75
+ the five wired providers actually use."""
76
+ lowered = message.lower()
77
+ return any(marker in lowered for marker in _OVERFLOW_MARKERS)
78
+
79
+
80
+ def halve(chunk: tuple[int, ...]) -> tuple[tuple[int, ...], tuple[int, ...]]:
81
+ """Split a chunk of item indexes into two non-empty halves (len must be ≥ 2)."""
82
+ middle = len(chunk) // 2
83
+ return chunk[:middle], chunk[middle:]
84
+
85
+
86
+ def fits_in_one(sizes: Sequence[int], budget: int) -> bool:
87
+ return sum(sizes) <= budget
88
+
89
+
90
+ def chunk_indices(sizes: Sequence[int], budget: int) -> tuple[tuple[int, ...], ...]:
91
+ """Group consecutive item indexes into chunks whose total estimate fits the
92
+ budget. An item is never split; one that alone exceeds the budget gets its own
93
+ (over-budget) chunk rather than being dropped."""
94
+ chunks: list[tuple[int, ...]] = []
95
+ current: list[int] = []
96
+ current_size = 0
97
+ for index, size in enumerate(sizes):
98
+ if current and current_size + size > budget:
99
+ chunks.append(tuple(current))
100
+ current = []
101
+ current_size = 0
102
+ current.append(index)
103
+ current_size += size
104
+ if current:
105
+ chunks.append(tuple(current))
106
+ return tuple(chunks)
107
+
108
+
109
+ def split_text(text: str, budget: int) -> tuple[str, ...]:
110
+ """Break one oversized text into ≤-budget chunks (D26 layer 3).
111
+
112
+ Paragraph boundaries first, then lines, then a hard character cut — and the
113
+ chunks concatenate back to the original text exactly (nothing added, nothing
114
+ lost; separators travel with their preceding piece)."""
115
+ if estimate_tokens(text) <= budget:
116
+ return (text,)
117
+ pieces = _carrying_separators(text, r"\n\n+")
118
+ if len(pieces) == 1:
119
+ pieces = _carrying_separators(text, r"\n")
120
+ chunks: list[str] = []
121
+ current = ""
122
+ for piece in pieces:
123
+ for part in _hard_cut(piece, budget):
124
+ if current and estimate_tokens(current + part) > budget:
125
+ chunks.append(current)
126
+ current = ""
127
+ current += part
128
+ if current:
129
+ chunks.append(current)
130
+ return tuple(chunks)
131
+
132
+
133
+ def _carrying_separators(text: str, pattern: str) -> list[str]:
134
+ """Split by a separator regex, attaching each separator to the piece before
135
+ it, so ``"".join(result) == text``."""
136
+ import re
137
+
138
+ tokens = re.split(f"({pattern})", text)
139
+ pieces: list[str] = []
140
+ for token in tokens:
141
+ if pieces and re.fullmatch(pattern, token or " ") and token:
142
+ pieces[-1] += token
143
+ elif token:
144
+ pieces.append(token)
145
+ return pieces or [text]
146
+
147
+
148
+ def _hard_cut(piece: str, budget: int) -> tuple[str, ...]:
149
+ """A single piece that alone exceeds the budget is cut at character bounds."""
150
+ limit = max(budget * _CHARS_PER_TOKEN, 1)
151
+ if len(piece) <= limit:
152
+ return (piece,)
153
+ return tuple(piece[i : i + limit] for i in range(0, len(piece), limit))
154
+
155
+
156
+ def mean_pool(vectors: Sequence[tuple[float, ...]]) -> tuple[float, ...]:
157
+ """Component-wise mean of chunk embeddings — the standard whole-document
158
+ vector when one text had to be embedded in pieces (D26)."""
159
+ assert vectors, "mean_pool needs at least one vector"
160
+ count = len(vectors)
161
+ return tuple(sum(component) / count for component in zip(*vectors, strict=True))
@@ -0,0 +1,94 @@
1
+ """Vector clustering for distinct/outliers/cluster/diff (D38) — pure, stdlib.
2
+
3
+ Hand-rolled on purpose (frozen dependency budget: no sklearn, ever). Leader
4
+ clustering is greedy and input-order stable: deterministic re-runs are a
5
+ feature — the answer must not change under the user.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from smartpipe.engine.ranking import cosine
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ __all__ = ["adaptive_threshold", "knn_mean_distance", "leader_clusters", "merge_to_k"]
18
+
19
+
20
+ def leader_clusters(vectors: Sequence[tuple[float, ...]], *, threshold: float) -> list[list[int]]:
21
+ """Greedy leader clustering: each vector joins the FIRST cluster whose
22
+ leader (its first member) is ≥ ``threshold`` cosine-similar, else founds
23
+ its own. Returns clusters as index lists, in founding order."""
24
+ clusters: list[list[int]] = []
25
+ for index, vector in enumerate(vectors):
26
+ for members in clusters:
27
+ if cosine(vectors[members[0]], vector) >= threshold:
28
+ members.append(index)
29
+ break
30
+ else:
31
+ clusters.append([index])
32
+ return clusters
33
+
34
+
35
+ def knn_mean_distance(vectors: Sequence[tuple[float, ...]], *, k: int) -> list[float]:
36
+ """Mean cosine DISTANCE (1 - similarity) to each vector's k nearest
37
+ neighbors — the weirdness score for ``outliers``. Robust on multi-cluster
38
+ corpora where centroid distance lies. O(n²): fine at corpus sizes where a
39
+ human will read the answer."""
40
+ n = len(vectors)
41
+ neighbors = min(k, n - 1)
42
+ if neighbors <= 0:
43
+ return [0.0] * n
44
+ scores: list[float] = []
45
+ for index, vector in enumerate(vectors):
46
+ distances = sorted(
47
+ 1.0 - cosine(vector, other)
48
+ for position, other in enumerate(vectors)
49
+ if position != index
50
+ )
51
+ scores.append(sum(distances[:neighbors]) / neighbors)
52
+ return scores
53
+
54
+
55
+ def merge_to_k(
56
+ vectors: Sequence[tuple[float, ...]], clusters: list[list[int]], *, k: int
57
+ ) -> list[list[int]]:
58
+ """Force exactly ``k`` clusters: repeatedly merge the SMALLEST into the
59
+ cluster whose leader is most similar to its leader (ties: earliest).
60
+ Deterministic; returns a new list, founding order preserved."""
61
+ merged = [list(members) for members in clusters]
62
+ while len(merged) > max(1, k):
63
+ smallest = min(range(len(merged)), key=lambda index: (len(merged[index]), index))
64
+ leader = vectors[merged[smallest][0]]
65
+ best = max(
66
+ (index for index in range(len(merged)) if index != smallest),
67
+ key=lambda index: (cosine(vectors[merged[index][0]], leader), -index),
68
+ )
69
+ merged[best].extend(merged[smallest])
70
+ del merged[smallest]
71
+ return merged
72
+
73
+
74
+ _PAIR_BUDGET = 20_000 # pairwise sample cap — clustering stays interactive
75
+
76
+
77
+ def adaptive_threshold(vectors: Sequence[tuple[float, ...]]) -> float:
78
+ """A grouping threshold derived from the corpus itself: median pairwise
79
+ similarity (the cross-theme background) plus 35% of the gap to the 95th
80
+ percentile (the same-theme tail). Fixed thresholds can't serve every
81
+ embedder's geometry — gemini's same-theme pairs sit near 0.7 where
82
+ synthetic unit vectors sit near 1.0 (measured, D38/05). Deterministic
83
+ given the corpus; pairs are stride-sampled past the budget."""
84
+ n = len(vectors)
85
+ if n < 3:
86
+ return 0.99 # nothing to group — only near-identity folds
87
+ pairs = [(i, j) for i in range(n) for j in range(i + 1, n)]
88
+ if len(pairs) > _PAIR_BUDGET:
89
+ stride = len(pairs) // _PAIR_BUDGET + 1
90
+ pairs = pairs[::stride]
91
+ similarities = sorted(cosine(vectors[i], vectors[j]) for i, j in pairs)
92
+ median = similarities[len(similarities) // 2]
93
+ p95 = similarities[min(len(similarities) - 1, int(len(similarities) * 0.95))]
94
+ return median + 0.35 * (p95 - median)
@@ -0,0 +1,330 @@
1
+ """The ``where`` predicate grammar (D38/01): a closed, KQL-flavored menu.
2
+
3
+ Pure: parse once into a small AST, evaluate per item with zero I/O. Missing
4
+ fields evaluate false (KQL behavior — streams keep flowing) but are tallied so
5
+ the verb can disclose them at the end; silence must never lie.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections import Counter
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Literal, assert_never
14
+
15
+ from smartpipe.core.errors import UsageFault
16
+
17
+ if TYPE_CHECKING:
18
+ from smartpipe.io.items import Item
19
+
20
+ __all__ = [
21
+ "WHERE_MENU",
22
+ "Compare",
23
+ "Contains",
24
+ "FieldTally",
25
+ "Has",
26
+ "Matches",
27
+ "Predicate",
28
+ "evaluate",
29
+ "parse_predicate",
30
+ ]
31
+
32
+ WHERE_MENU = (
33
+ "error: can't parse the where predicate\n"
34
+ ' Operators: FIELD has "word" · FIELD contains "text" · FIELD matches /re/\n'
35
+ ' FIELD == VALUE · != · > · >= · < · <= (numbers or "strings")\n'
36
+ " Combine: and · or · not · ( ) FIELD is a record field, or text\n"
37
+ ' Example: text has "ERROR" and not text contains "retry"\n'
38
+ " Semantic condition instead? smartpipe filter judges with a model."
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Has:
44
+ field: str
45
+ word: str
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Contains:
50
+ field: str
51
+ text: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Matches:
56
+ field: str
57
+ pattern: re.Pattern[str]
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Compare:
62
+ field: str
63
+ op: Literal["==", "!=", ">", ">=", "<", "<="]
64
+ value: str | float
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class And:
69
+ left: Predicate
70
+ right: Predicate
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Or:
75
+ left: Predicate
76
+ right: Predicate
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Not:
81
+ inner: Predicate
82
+
83
+
84
+ Predicate = Has | Contains | Matches | Compare | And | Or | Not
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class FieldTally:
89
+ """Mutable disclosure state owned by the verb: what silence would hide."""
90
+
91
+ missing: Counter[str] = field(default_factory=Counter[str])
92
+ non_numeric: Counter[str] = field(default_factory=Counter[str])
93
+
94
+
95
+ # --- tokenizer --------------------------------------------------------------------
96
+
97
+ _TOKEN = re.compile(
98
+ r"""\s*(?:
99
+ (?P<lparen>\()
100
+ | (?P<rparen>\))
101
+ | (?P<op>==|!=|>=|<=|>|<)
102
+ | (?P<string>"(?P<sbody>[^"]*)")
103
+ | (?P<sopen>")
104
+ | (?P<regex>/(?P<rbody>(?:\\/|[^/])*)/)
105
+ | (?P<number>-?\d+(?:\.\d+)?)
106
+ | (?P<word>[A-Za-z_][A-Za-z0-9_.]*)
107
+ )""",
108
+ re.VERBOSE,
109
+ )
110
+
111
+ _KEYWORDS = frozenset({"and", "or", "not", "has", "contains", "matches"})
112
+
113
+
114
+ def _tokenize(text: str) -> list[tuple[str, str]]:
115
+ tokens: list[tuple[str, str]] = []
116
+ position = 0
117
+ while position < len(text):
118
+ match = _TOKEN.match(text, position)
119
+ if match is None:
120
+ remainder = text[position:].strip()
121
+ if not remainder:
122
+ break
123
+ raise UsageFault(WHERE_MENU + f"\n (stuck at: {remainder[:30]!r})")
124
+ if match.lastgroup is None or match.end() == position:
125
+ break
126
+ kind = match.lastgroup
127
+ if kind == "sopen":
128
+ raise UsageFault(WHERE_MENU + "\n (unclosed string literal)")
129
+ if kind == "string":
130
+ tokens.append(("string", match.group("sbody")))
131
+ elif kind == "regex":
132
+ tokens.append(("regex", match.group("rbody").replace("\\/", "/")))
133
+ elif kind == "word":
134
+ word = match.group("word")
135
+ tokens.append((word, word) if word in _KEYWORDS else ("field", word))
136
+ else:
137
+ tokens.append((kind, match.group(kind) or match.group(0).strip()))
138
+ position = match.end()
139
+ return tokens
140
+
141
+
142
+ # --- recursive descent: or > and > not > leaf --------------------------------------
143
+
144
+
145
+ class _Parser:
146
+ def __init__(self, tokens: list[tuple[str, str]]) -> None:
147
+ self.tokens = tokens
148
+ self.position = 0
149
+
150
+ def peek(self) -> tuple[str, str] | None:
151
+ return self.tokens[self.position] if self.position < len(self.tokens) else None
152
+
153
+ def take(self) -> tuple[str, str]:
154
+ token = self.peek()
155
+ if token is None:
156
+ raise UsageFault(WHERE_MENU + "\n (predicate ends mid-expression)")
157
+ self.position += 1
158
+ return token
159
+
160
+ def parse_or(self) -> Predicate:
161
+ node = self.parse_and()
162
+ while (token := self.peek()) is not None and token[0] == "or":
163
+ self.take()
164
+ node = Or(node, self.parse_and())
165
+ return node
166
+
167
+ def parse_and(self) -> Predicate:
168
+ node = self.parse_not()
169
+ while (token := self.peek()) is not None and token[0] == "and":
170
+ self.take()
171
+ node = And(node, self.parse_not())
172
+ return node
173
+
174
+ def parse_not(self) -> Predicate:
175
+ if (token := self.peek()) is not None and token[0] == "not":
176
+ self.take()
177
+ return Not(self.parse_not())
178
+ return self.parse_leaf()
179
+
180
+ def parse_leaf(self) -> Predicate:
181
+ kind, value = self.take()
182
+ if kind == "lparen":
183
+ inner = self.parse_or()
184
+ closing = self.peek()
185
+ if closing is None or closing[0] != "rparen":
186
+ raise UsageFault(WHERE_MENU + "\n (missing closing ')')")
187
+ self.take()
188
+ return inner
189
+ if kind != "field":
190
+ raise UsageFault(WHERE_MENU + f"\n (expected a field name, got {value!r})")
191
+ return self.parse_operator(value)
192
+
193
+ def parse_operator(self, field_name: str) -> Predicate:
194
+ kind, value = self.take()
195
+ match kind:
196
+ case "has" | "contains":
197
+ text_kind, text_value = self.take()
198
+ if text_kind != "string":
199
+ raise UsageFault(WHERE_MENU + f'\n ({kind} needs a "quoted string")')
200
+ return (
201
+ Has(field_name, text_value)
202
+ if kind == "has"
203
+ else Contains(field_name, text_value)
204
+ )
205
+ case "matches":
206
+ regex_kind, regex_value = self.take()
207
+ if regex_kind != "regex":
208
+ raise UsageFault(WHERE_MENU + "\n (matches needs /a regex/)")
209
+ try:
210
+ return Matches(field_name, re.compile(regex_value))
211
+ except re.error as exc:
212
+ raise UsageFault(WHERE_MENU + f"\n (bad regex: {exc})") from exc
213
+ case "op":
214
+ literal_kind, literal_value = self.take()
215
+ if literal_kind == "number":
216
+ return Compare(field_name, _as_op(value), float(literal_value))
217
+ if literal_kind == "string":
218
+ return Compare(field_name, _as_op(value), literal_value)
219
+ if literal_kind == "field" and literal_value in ("true", "false"):
220
+ # booleans compare as their JSON spelling
221
+ return Compare(field_name, _as_op(value), literal_value)
222
+ raise UsageFault(WHERE_MENU + f'\n ({value} needs a number or "string")')
223
+ case _:
224
+ raise UsageFault(WHERE_MENU + f"\n (expected an operator after {field_name!r})")
225
+
226
+
227
+ def _as_op(text: str) -> Literal["==", "!=", ">", ">=", "<", "<="]:
228
+ match text:
229
+ case "==" | "!=" | ">" | ">=" | "<" | "<=":
230
+ return text
231
+ case _ as unreachable: # pragma: no cover — the tokenizer only emits the six
232
+ raise AssertionError(unreachable)
233
+
234
+
235
+ def parse_predicate(text: str) -> Predicate:
236
+ parser = _Parser(_tokenize(text))
237
+ node = parser.parse_or()
238
+ leftover = parser.peek()
239
+ if leftover is not None:
240
+ raise UsageFault(WHERE_MENU + f"\n (unexpected trailing {leftover[1]!r})")
241
+ return node
242
+
243
+
244
+ # --- evaluation --------------------------------------------------------------------
245
+
246
+
247
+ def evaluate(predicate: Predicate, item: Item, tally: FieldTally) -> bool:
248
+ match predicate:
249
+ case And(left, right):
250
+ return evaluate(left, item, tally) and evaluate(right, item, tally)
251
+ case Or(left, right):
252
+ return evaluate(left, item, tally) or evaluate(right, item, tally)
253
+ case Not(inner):
254
+ return not evaluate(inner, item, tally)
255
+ case Has(field_name, word):
256
+ value = _resolve(field_name, item, tally)
257
+ if value is None:
258
+ return False
259
+ boundary = rf"(?<![A-Za-z0-9_]){re.escape(word)}(?![A-Za-z0-9_])"
260
+ return re.search(boundary, _as_text(value), re.IGNORECASE) is not None
261
+ case Contains(field_name, needle):
262
+ value = _resolve(field_name, item, tally)
263
+ return value is not None and needle.lower() in _as_text(value).lower()
264
+ case Matches(field_name, pattern):
265
+ value = _resolve(field_name, item, tally)
266
+ return value is not None and pattern.search(_as_text(value)) is not None
267
+ case Compare(field_name, op, expected):
268
+ value = _resolve(field_name, item, tally)
269
+ if value is None:
270
+ return False
271
+ return _compare(field_name, value, op, expected, tally)
272
+ case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
273
+ assert_never(unreachable)
274
+
275
+
276
+ def _resolve(field_name: str, item: Item, tally: FieldTally) -> object | None:
277
+ if field_name == "text":
278
+ return item.text
279
+ value = item.data.get(field_name) if item.data is not None else None
280
+ if value is None:
281
+ tally.missing[field_name] += 1
282
+ return value
283
+
284
+
285
+ def _as_text(value: object) -> str:
286
+ return value if isinstance(value, str) else str(value)
287
+
288
+
289
+ def _numeric(value: object) -> float | None:
290
+ if isinstance(value, bool): # bool is int; keep booleans out of arithmetic
291
+ return None
292
+ if isinstance(value, (int, float)):
293
+ return float(value)
294
+ if isinstance(value, str):
295
+ try:
296
+ return float(value)
297
+ except ValueError:
298
+ return None
299
+ return None
300
+
301
+
302
+ def _compare(
303
+ field_name: str,
304
+ value: object,
305
+ op: Literal["==", "!=", ">", ">=", "<", "<="],
306
+ expected: str | float,
307
+ tally: FieldTally,
308
+ ) -> bool:
309
+ ours = _numeric(value)
310
+ theirs = _numeric(expected)
311
+ if ours is not None and theirs is not None:
312
+ match op:
313
+ case "==":
314
+ return ours == theirs
315
+ case "!=":
316
+ return ours != theirs
317
+ case ">":
318
+ return ours > theirs
319
+ case ">=":
320
+ return ours >= theirs
321
+ case "<":
322
+ return ours < theirs
323
+ case "<=":
324
+ return ours <= theirs
325
+ if op in ("==", "!="):
326
+ # string equality — booleans/None compare as their JSON spelling
327
+ rendered = _as_text(value) if not isinstance(value, bool) else str(value).lower()
328
+ return (rendered == _as_text(expected)) is (op == "==")
329
+ tally.non_numeric[field_name] += 1 # ordered compare on a non-number: no match
330
+ return False