search-as-code 0.0.1__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.
@@ -0,0 +1,422 @@
1
+ """Composable retrieval primitives — the atoms agent code writes against.
2
+
3
+ These are deliberately *lower level* than a monolithic ``search()`` endpoint
4
+ (the Perplexity "search as code" insight): fan-out, fusion, dedup, rerank, and
5
+ extraction are separate so the model can orchestrate them however the task
6
+ needs. Everything here is portable — it runs on ``ResultSet`` objects and never
7
+ touches a specific backend.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re as _re
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from typing import Any, Callable, Optional, Sequence
15
+
16
+ from .errors import ExtractorRequiredError
17
+ from .types import Hit, ResultSet
18
+
19
+
20
+ def fan_out(
21
+ fn: Callable[[Any], ResultSet],
22
+ items: Sequence[Any],
23
+ concurrency: int = 8,
24
+ ) -> list[ResultSet]:
25
+ """Run ``fn`` over ``items`` concurrently. The workhorse behind querying many
26
+ variants at once without serializing through model turns."""
27
+ if not items:
28
+ return []
29
+ with ThreadPoolExecutor(max_workers=max(1, concurrency)) as ex:
30
+ return list(ex.map(fn, items))
31
+
32
+
33
+ def fuse(
34
+ result_sets: Sequence[ResultSet],
35
+ weights: Optional[Sequence[float]] = None,
36
+ k: int = 60,
37
+ ) -> ResultSet:
38
+ """Reciprocal Rank Fusion across result sets.
39
+
40
+ RRF is rank-based, so it merges dense, keyword, and multi-query results
41
+ without needing comparable score scales — the portable, model-free way to
42
+ combine signals. ``weights`` optionally biases each set.
43
+ """
44
+ weights = list(weights) if weights is not None else [1.0] * len(result_sets)
45
+ agg: dict[str, float] = {}
46
+ keep: dict[str, Hit] = {}
47
+ for rs, w in zip(result_sets, weights):
48
+ for rank, hit in enumerate(sorted(rs, key=lambda h: h.score, reverse=True)):
49
+ agg[hit.id] = agg.get(hit.id, 0.0) + w * (1.0 / (k + rank + 1))
50
+ if hit.id not in keep or hit.score > keep[hit.id].score:
51
+ keep[hit.id] = hit
52
+ fused = [
53
+ Hit(id=i, score=s, document=keep[i].document, query=keep[i].query, store=keep[i].store)
54
+ for i, s in agg.items()
55
+ ]
56
+ fused.sort(key=lambda h: h.score, reverse=True)
57
+ return ResultSet(fused)
58
+
59
+
60
+ # RRF is exactly the fusion primitive above; `rrf` is an explicit, discoverable alias.
61
+ rrf = fuse
62
+
63
+
64
+ def dedup(results: ResultSet, key: Optional[Callable[[Hit], Any]] = None) -> ResultSet:
65
+ return results.dedup(key)
66
+
67
+
68
+ def score_cutoff(results: ResultSet, method: str = "band", rel_band: float = 0.1,
69
+ min_k: int = 10, max_k: int = 100) -> ResultSet:
70
+ """Adaptive result-set sizing from the score distribution — keep MORE when the
71
+ similarity curve is flat (many near-equally-relevant hits), FEWER when it drops
72
+ off sharply. This is the "don't hard-cut at k" idea from score-based retrieval.
73
+
74
+ method="band": keep hits within ``rel_band`` of the top score (relative), i.e.
75
+ score >= top*(1-rel_band). Flat curve → keeps a large pool; peaked → keeps few.
76
+ method="knee": cut at the largest score gap between consecutive ranks (elbow).
77
+ Always returns between ``min_k`` and ``max_k`` hits.
78
+ """
79
+ hits = sorted(results, key=lambda h: h.score, reverse=True)[:max_k]
80
+ if not hits:
81
+ return ResultSet()
82
+ if method == "knee":
83
+ scores = [h.score for h in hits]
84
+ best_i, best_gap = len(hits), -1.0
85
+ for i in range(min_k, len(scores)):
86
+ gap = scores[i - 1] - scores[i]
87
+ if gap > best_gap:
88
+ best_gap, best_i = gap, i
89
+ kept = hits[:best_i]
90
+ else: # band (relative to the top score)
91
+ top = hits[0].score
92
+ cut = top * (1 - rel_band) if top > 0 else top - rel_band
93
+ kept = [h for h in hits if h.score >= cut]
94
+ if len(kept) < min_k:
95
+ kept = hits[:min_k]
96
+ return ResultSet(kept[:max_k])
97
+
98
+
99
+ def normalize_scores(results: ResultSet, method: str = "minmax") -> ResultSet:
100
+ """Rescale scores so incomparable BM25/cosine scales become fusable while
101
+ KEEPING magnitude (unlike RRF, which discards it). method=minmax → [0,1];
102
+ zscore → standardized. (Weaviate hybrid-fusion.)"""
103
+ if not results:
104
+ return ResultSet()
105
+ scores = [h.score for h in results]
106
+ if method == "zscore":
107
+ mu = sum(scores) / len(scores)
108
+ var = sum((s - mu) ** 2 for s in scores) / len(scores)
109
+ sd = var ** 0.5 or 1.0
110
+ norm = [(s - mu) / sd for s in scores]
111
+ else:
112
+ lo, hi = min(scores), max(scores)
113
+ rng = (hi - lo) or 1.0
114
+ norm = [(s - lo) / rng for s in scores]
115
+ return ResultSet(Hit(id=h.id, score=float(n), document=h.document, query=h.query, store=h.store)
116
+ for h, n in zip(results, norm))
117
+
118
+
119
+ def relative_score_fusion(result_sets: Sequence[ResultSet], weights: Optional[Sequence[float]] = None,
120
+ method: str = "minmax") -> ResultSet:
121
+ """Fuse by summing NORMALIZED scores (not ranks) — preserves how much better a
122
+ hit is, not just its position. Weaviate's default fusion since v1.24; often
123
+ beats RRF when score magnitudes are meaningful."""
124
+ weights = list(weights) if weights is not None else [1.0] * len(result_sets)
125
+ agg: dict[str, float] = {}
126
+ keep: dict[str, Hit] = {}
127
+ for rs, w in zip(result_sets, weights):
128
+ for h in normalize_scores(rs, method):
129
+ agg[h.id] = agg.get(h.id, 0.0) + w * h.score
130
+ if h.id not in keep or h.score > keep[h.id].score:
131
+ keep[h.id] = h
132
+ fused = [Hit(id=i, score=s, document=keep[i].document, query=keep[i].query, store=keep[i].store)
133
+ for i, s in agg.items()]
134
+ fused.sort(key=lambda h: h.score, reverse=True)
135
+ return ResultSet(fused)
136
+
137
+
138
+ def diversity_quota(results: ResultSet, key: Callable[[Hit], Any],
139
+ max_per_group: int = 1, top_k: Optional[int] = None) -> ResultSet:
140
+ """Enforce source/topic/entity diversity: at most ``max_per_group`` hits per
141
+ group (by ``key``) while walking down the ranking. (Vespa result diversity.)"""
142
+ counts: dict[Any, int] = {}
143
+ out: list[Hit] = []
144
+ for h in sorted(results, key=lambda x: x.score, reverse=True):
145
+ g = key(h)
146
+ if counts.get(g, 0) < max_per_group:
147
+ out.append(h)
148
+ counts[g] = counts.get(g, 0) + 1
149
+ if top_k and len(out) >= top_k:
150
+ break
151
+ return ResultSet(out)
152
+
153
+
154
+ def confidence(results: ResultSet) -> dict[str, float]:
155
+ """Retrieval-confidence signals from the score curve: top score and the gap to
156
+ #2 (a large gap = a confident single winner). Feeds abstain/reformulate. (R³AG.)"""
157
+ hits = sorted(results, key=lambda h: h.score, reverse=True)
158
+ if not hits:
159
+ return {"top": 0.0, "gap": 0.0, "n": 0}
160
+ top = hits[0].score
161
+ gap = top - hits[1].score if len(hits) > 1 else top
162
+ return {"top": float(top), "gap": float(gap), "n": len(hits)}
163
+
164
+
165
+ def abstain(results: ResultSet, min_top: float = 0.0, min_gap: float = 0.0) -> bool:
166
+ """True when the result is too weak/uncertain to trust (top score or score-gap
167
+ below threshold) — the agent should reformulate or say 'insufficient' rather
168
+ than answer from noise. (R³AG confidence gating.)"""
169
+ c = confidence(results)
170
+ return c["n"] == 0 or c["top"] < min_top or c["gap"] < min_gap
171
+
172
+
173
+ def rerank(
174
+ query: str,
175
+ results: ResultSet,
176
+ reranker: Optional[Callable[[str, list[str]], list[float]]] = None,
177
+ top_k: Optional[int] = None,
178
+ ) -> ResultSet:
179
+ """Re-score ``results`` for ``query``.
180
+
181
+ Pass a ``reranker(query, texts) -> scores`` (e.g. a cross-encoder). When none
182
+ is supplied we emulate with lexical-overlap scoring so the primitive still
183
+ works on every backend — quality tracks whatever reranker you inject.
184
+ """
185
+ if not results:
186
+ return results
187
+ texts = [h.text or "" for h in results]
188
+ scores = reranker(query, texts) if reranker else _lexical_overlap(query, texts)
189
+ rescored = [
190
+ Hit(id=h.id, score=float(s), document=h.document, query=h.query, store=h.store)
191
+ for h, s in zip(results, scores)
192
+ ]
193
+ rescored.sort(key=lambda h: h.score, reverse=True)
194
+ out = ResultSet(rescored)
195
+ return out.top(top_k) if top_k else out
196
+
197
+
198
+ def freshness(
199
+ results: ResultSet,
200
+ timestamp: Callable[[Hit], float],
201
+ now: float,
202
+ half_life: float,
203
+ weight: float = 0.5,
204
+ ) -> ResultSet:
205
+ """Blend a recency decay into scores (a Hornet-style freshness primitive).
206
+
207
+ ``now``/``half_life`` are caller-supplied (seconds) because the sandbox
208
+ forbids wall-clock nondeterminism; pass ``time.time()`` from the harness.
209
+ """
210
+
211
+ out = []
212
+ for h in results:
213
+ age = max(0.0, now - timestamp(h))
214
+ decay = 0.5 ** (age / half_life) if half_life > 0 else 1.0
215
+ blended = (1 - weight) * h.score + weight * decay
216
+ out.append(Hit(id=h.id, score=blended, document=h.document, query=h.query, store=h.store))
217
+ out.sort(key=lambda h: h.score, reverse=True)
218
+ return ResultSet(out)
219
+
220
+
221
+ def mmr(
222
+ query_vector: Sequence[float],
223
+ results: ResultSet,
224
+ lambda_: float = 0.5,
225
+ top_k: int = 10,
226
+ ) -> ResultSet:
227
+ """Maximal Marginal Relevance — greedily pick results that are relevant to
228
+ the query but diverse from what's already chosen (Carbonell & Goldstein '98).
229
+
230
+ Portable and model-free: needs only the query vector and per-hit vectors, so
231
+ it complements ``dedup`` by suppressing *near*-duplicates, not just exact ids.
232
+ Hits without a vector are appended after the diversified set.
233
+ """
234
+ import numpy as np
235
+
236
+ q = np.asarray(query_vector, dtype=np.float32)
237
+ q = q / (np.linalg.norm(q) or 1.0)
238
+ withvec = [h for h in results if h.document and h.document.vector is not None]
239
+ novec = [h for h in results if not (h.document and h.document.vector is not None)]
240
+ vecs = {}
241
+ for h in withvec:
242
+ assert h.document is not None # guaranteed by the withvec filter above
243
+ v = np.asarray(h.document.vector, dtype=np.float32)
244
+ vecs[h.id] = v / (np.linalg.norm(v) or 1.0)
245
+
246
+ selected: list[Hit] = []
247
+ pool = list(withvec)
248
+ while pool and len(selected) < top_k:
249
+ best, best_score = None, float("-inf")
250
+ for h in pool:
251
+ rel = float(q @ vecs[h.id])
252
+ div = max((float(vecs[h.id] @ vecs[s.id]) for s in selected), default=0.0)
253
+ score = lambda_ * rel - (1 - lambda_) * div
254
+ if score > best_score:
255
+ best, best_score = h, score
256
+ selected.append(best) # type: ignore[arg-type]
257
+ pool.remove(best) # type: ignore[arg-type]
258
+ return ResultSet(selected + novec[: max(0, top_k - len(selected))])
259
+
260
+
261
+ def expand(query: str, generate: Callable[[str], list[str]], n: int = 4) -> list[str]:
262
+ """Query expansion / multi-query (RAG-Fusion, LangChain MultiQuery, Haystack
263
+ QueryExpander). ``generate(prompt) -> list[str]`` is your LLM; the original
264
+ query is always included so recall never drops below the baseline."""
265
+ prompt = (
266
+ f"Generate {n} alternative search queries. Crucially, include SYNONYMS, EUPHEMISMS, and "
267
+ f"domain aliases (e.g. 'disappear'->'die', 'hobbyist'->'hobby', 'CD'->'certificate of "
268
+ f"deposit'), not just reworded questions — paraphrases alone retrieve the same documents. "
269
+ f"Return one per line.\nQuery: {query}"
270
+ )
271
+ variants = [q.strip() for q in generate(prompt) if q.strip()]
272
+ return [query, *[v for v in variants if v != query]]
273
+
274
+
275
+ def rephrase(query: str, generate: Callable[[str], list[str]]) -> str:
276
+ """Rewrite a query into a single retrieval-optimized, standalone formulation
277
+ (Rewrite-Retrieve-Read). Returns the original if the model yields nothing."""
278
+ prompt = (
279
+ "Rewrite the following search query to be clearer and more retrieval-effective "
280
+ "while preserving its exact information need. Return only the rewritten query.\n"
281
+ f"Query: {query}"
282
+ )
283
+ out = [q.strip() for q in generate(prompt) if q.strip()]
284
+ return out[0] if out else query
285
+
286
+
287
+ def decompose(query: str, generate: Callable[[str], list[str]]) -> list[str]:
288
+ """Break a complex query into answerable sub-questions (least-to-most,
289
+ LlamaIndex sub-question, Haystack decomposition)."""
290
+ prompt = (
291
+ "Decompose this question into the minimal set of simpler sub-questions "
292
+ f"needed to answer it. Return one per line.\nQuestion: {query}"
293
+ )
294
+ return [q.strip() for q in generate(prompt) if q.strip()]
295
+
296
+
297
+ def topics(query: str, generate: Callable[[str], list[str]], n: int = 5) -> list[str]:
298
+ """LLM topic/entity extraction — the key concepts to search or filter on."""
299
+ prompt = (
300
+ f"List up to {n} key topics or named entities in this search query, "
301
+ f"one per line, no numbering or extra text.\nQuery: {query}"
302
+ )
303
+ return [t.strip(" -*\t") for t in generate(prompt) if t.strip()][:n]
304
+
305
+
306
+ def auto_filter(query: str, generate: Callable[[str], list[str]],
307
+ fields: Optional[Sequence[str]] = None) -> dict[str, Any]:
308
+ """Self-query: the LLM infers a metadata filter implied by the query
309
+ (LangChain SelfQueryRetriever / LlamaIndex auto-retrieval). Returns a filter
310
+ dict in the portable dialect, or {} if the query implies no constraint."""
311
+ import json
312
+ import re
313
+
314
+ fdesc = f"Available metadata fields: {', '.join(fields)}.\n" if fields else ""
315
+ prompt = (
316
+ "Infer a metadata filter that this query implies, as a JSON object in this dialect: "
317
+ '{"field": value} or {"field": {"$gte": n}} / {"$in": [...]} etc. '
318
+ "Return ONLY the JSON object, or {} if the query implies no filter.\n"
319
+ f"{fdesc}Query: {query}"
320
+ )
321
+ raw = "\n".join(generate(prompt))
322
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
323
+ if not m:
324
+ return {}
325
+ try:
326
+ out = json.loads(m.group(0))
327
+ return out if isinstance(out, dict) else {}
328
+ except Exception:
329
+ return {}
330
+
331
+
332
+ # Spelling / acronym normalization (extend per domain). Fixes the cheque↔check class.
333
+ DEFAULT_ALIASES = {
334
+ "cheque": "check", "cheques": "checks", "favour": "favor", "colour": "color",
335
+ "organisation": "organization", "cancelled": "canceled", "cheque's": "check's",
336
+ }
337
+
338
+
339
+ def normalize_query(query: str, aliases: Optional[dict] = None) -> str:
340
+ """Normalize spelling/acronym variants so query and doc share tokens
341
+ (cheque→check, US/UK, domain aliases). Apply to query AND at index time."""
342
+ aliases = aliases or DEFAULT_ALIASES
343
+ return "".join(aliases.get(t.lower(), t) if t.strip() else t
344
+ for t in _re.findall(r"\w+|\W+", query))
345
+
346
+
347
+ def rare_terms(query: str) -> list[str]:
348
+ """High-signal exact tokens worth boosting in a keyword pass: quoted phrases,
349
+ ALL-CAPS acronyms (CD, EIN, SEC, LLC), numbers/$amounts/versions, Proper Bigrams."""
350
+ terms: list[str] = []
351
+ terms += _re.findall(r'"([^"]+)"', query) # quoted phrases
352
+ terms += _re.findall(r"\b[A-Z]{2,}\b", query) # acronyms
353
+ terms += _re.findall(r"\$?\d[\d,.]*[kKmM%]?", query) # numbers / $ / versions
354
+ terms += _re.findall(r"\b[A-Z][a-z]+\s+[A-Z][a-z]+\b", query) # Proper Bigrams
355
+ seen: set = set()
356
+ out: list[str] = []
357
+ for t in terms:
358
+ t = t.strip()
359
+ if t and t.lower() not in seen:
360
+ seen.add(t.lower())
361
+ out.append(t)
362
+ return out
363
+
364
+
365
+ def quality_filter(results: ResultSet, min_chars: int = 40) -> ResultSet:
366
+ """Drop empty / near-empty docs (label & parse artifacts, pure noise)."""
367
+ return ResultSet(h for h in results if h.text and len(h.text.strip()) >= min_chars)
368
+
369
+
370
+ def extract(
371
+ results: ResultSet,
372
+ schema: dict[str, Any],
373
+ instruction: str,
374
+ extractor: Optional[Callable[[list[str], dict, str], list[dict]]] = None,
375
+ ) -> list[dict[str, Any]]:
376
+ """Pull structured records out of hits (the SaC "verification" stage).
377
+
378
+ Requires an ``extractor(texts, schema, instruction) -> list[dict]`` — usually
379
+ an LLM call. Kept pluggable so the core stays model-agnostic.
380
+ """
381
+ if extractor is None:
382
+ raise ExtractorRequiredError(
383
+ "extract() needs an extractor callable (e.g. an LLM). "
384
+ "Pass Session(..., extractor=fn) or extract(..., extractor=fn)."
385
+ )
386
+ return extractor(results.texts(), schema, instruction)
387
+
388
+
389
+ def _lexical_overlap(query: str, texts: Sequence[str]) -> list[float]:
390
+ from .embeddings import _tokenize
391
+
392
+ q = set(_tokenize(query))
393
+ if not q:
394
+ return [0.0] * len(texts)
395
+ scores = []
396
+ for t in texts:
397
+ toks = set(_tokenize(t))
398
+ scores.append(len(q & toks) / len(q) if toks else 0.0)
399
+ return scores
400
+
401
+
402
+ def content_type(text: str) -> str:
403
+ """Heuristic classifier so an agent knows the DATA SHAPE of a chunk — one of
404
+ 'table', 'fact-card', 'list', 'code', 'prose', 'short-fact', 'empty'.
405
+ Pairs with store.describe_schema()/sample() for schema-first agentic retrieval."""
406
+ if not text or not text.strip():
407
+ return "empty"
408
+ t = text.strip()
409
+ lines = [ln for ln in t.splitlines() if ln.strip()]
410
+ kv = sum(1 for ln in lines if "=" in ln and len(ln) < 90)
411
+ if t.count("|") >= 4 or t.count("\t") >= 3:
412
+ return "table"
413
+ if lines and kv >= max(2, len(lines) // 2):
414
+ return "fact-card"
415
+ if lines and sum(1 for ln in lines if ln.lstrip()[:2] in ("- ", "* ", "• ")
416
+ or ln.lstrip()[:1].isdigit()) >= max(2, len(lines) // 2):
417
+ return "list"
418
+ if any(k in t for k in ("def ", "import ", "function ", "{", "};", "</")):
419
+ return "code"
420
+ if t.count(". ") >= 2 or len(t) > 300:
421
+ return "prose"
422
+ return "short-fact"
@@ -0,0 +1,94 @@
1
+ """Concrete reranker implementations (optional; local, no API).
2
+
3
+ A reranker is any ``callable(query, texts) -> list[float]``. ``CrossEncoderReranker``
4
+ wraps a sentence-transformers cross-encoder — the standard two-stage
5
+ retrieve-then-rerank primitive — and plugs straight into ``Session(reranker=...)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Sequence
11
+
12
+
13
+ class CrossEncoderReranker:
14
+ """Cross-encoder relevance scorer (e.g. BAAI/bge-reranker, ms-marco-MiniLM).
15
+
16
+ Lazy-loads the model on first call so importing the SDK stays cheap.
17
+ """
18
+
19
+ def __init__(self, model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", device: str | None = None):
20
+ self.model_name = model
21
+ self._device = device
22
+ self._model = None
23
+
24
+ def _ensure(self):
25
+ if self._model is None:
26
+ import torch
27
+ from sentence_transformers import CrossEncoder
28
+
29
+ device = self._device or ("cuda" if torch.cuda.is_available() else "cpu")
30
+ self._model = CrossEncoder(self.model_name, device=device)
31
+ return self._model
32
+
33
+ def __call__(self, query: str, texts: Sequence[str]) -> list[float]:
34
+ if not texts:
35
+ return []
36
+ model = self._ensure()
37
+ pairs = [(query, t or "") for t in texts]
38
+ return [float(s) for s in model.predict(pairs, show_progress_bar=False)]
39
+
40
+
41
+ class QwenReranker:
42
+ """Qwen3-Reranker — an LLM reranker that scores relevance via the yes/no logit.
43
+ Much stronger than ms-marco cross-encoders on out-of-domain corpora (FiQA etc.).
44
+ Same ``callable(query, texts) -> list[float]`` interface. Lazy-loaded.
45
+ """
46
+
47
+ _PREFIX = ('<|im_start|>system\nJudge whether the Document meets the requirements based on the '
48
+ 'Query and the Instruct provided. Note that the answer can only be "yes" or "no".'
49
+ '<|im_end|>\n<|im_start|>user\n')
50
+ _SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
51
+
52
+ def __init__(self, model: str = "Qwen/Qwen3-Reranker-0.6B", device: str | None = None,
53
+ max_length: int = 512,
54
+ instruction: str = "Given a query, retrieve passages that answer it"):
55
+ self.model_name = model
56
+ self._device = device
57
+ self.max_length = max_length
58
+ self.instruction = instruction
59
+ self._model: Any = None
60
+
61
+ def _ensure(self):
62
+ if self._model is None:
63
+ import torch
64
+ from transformers import AutoModelForCausalLM, AutoTokenizer
65
+
66
+ dev = self._device or ("cuda" if torch.cuda.is_available() else "cpu")
67
+ self.tok = AutoTokenizer.from_pretrained(self.model_name, padding_side="left")
68
+ self._model = AutoModelForCausalLM.from_pretrained(
69
+ self.model_name,
70
+ torch_dtype=torch.float16 if dev == "cuda" else torch.float32,
71
+ ).to(dev).eval() # type: ignore[arg-type]
72
+ self.dev = dev
73
+ self.tid_yes = self.tok.convert_tokens_to_ids("yes")
74
+ self.tid_no = self.tok.convert_tokens_to_ids("no")
75
+ return self._model
76
+
77
+ def __call__(self, query: str, texts: Sequence[str], batch_size: int = 16) -> list[float]:
78
+ if not texts:
79
+ return []
80
+ import torch
81
+
82
+ self._ensure()
83
+ prompts = [f"{self._PREFIX}<Instruct>: {self.instruction}\n<Query>: {query}\n"
84
+ f"<Document>: {t or ''}{self._SUFFIX}" for t in texts]
85
+ scores: list[float] = []
86
+ for i in range(0, len(prompts), batch_size):
87
+ enc = self.tok(prompts[i:i + batch_size], return_tensors="pt", padding=True,
88
+ truncation=True, max_length=self.max_length).to(self.dev)
89
+ with torch.no_grad():
90
+ logits = self._model(**enc).logits[:, -1, :]
91
+ yn = torch.stack([logits[:, self.tid_no], logits[:, self.tid_yes]], dim=1)
92
+ p = torch.log_softmax(yn.float(), dim=1)[:, 1].exp()
93
+ scores.extend(p.cpu().tolist())
94
+ return scores
@@ -0,0 +1,116 @@
1
+ """Execution layer for search-as-code.
2
+
3
+ Agent-generated Python runs *here*, with a :class:`Session` injected as ``sac``
4
+ and the primitives in scope. Bulky intermediate results stay in this namespace;
5
+ only what the code ``print``s or assigns to ``evidence`` is returned to the
6
+ model. That boundary is the whole efficiency argument of code-mode retrieval.
7
+
8
+ ``LocalExecutor`` is an in-process runner for development and tests. It is NOT a
9
+ security boundary — untrusted code needs a real isolate (Docker / e2b / Pyodide)
10
+ behind the same :class:`Sandbox` interface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import abc
16
+ import contextlib
17
+ import io
18
+ import traceback
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Optional
21
+
22
+ from . import primitives as P
23
+ from .session import Session
24
+ from .types import Document, Hit, ResultSet
25
+
26
+
27
+ @dataclass
28
+ class ExecResult:
29
+ ok: bool
30
+ evidence: Any = None
31
+ stdout: str = ""
32
+ error: Optional[str] = None
33
+ state_keys: list[str] = field(default_factory=list)
34
+
35
+ def for_model(self) -> dict[str, Any]:
36
+ """The compact payload to feed back into the model context."""
37
+ payload: dict[str, Any] = {"ok": self.ok}
38
+ if self.evidence is not None:
39
+ payload["evidence"] = self.evidence
40
+ if self.stdout.strip():
41
+ payload["stdout"] = self.stdout.strip()[:4000]
42
+ if self.error:
43
+ payload["error"] = self.error
44
+ if self.state_keys:
45
+ payload["state_keys"] = self.state_keys
46
+ return payload
47
+
48
+
49
+ class Sandbox(abc.ABC):
50
+ @abc.abstractmethod
51
+ def run(self, code: str) -> ExecResult: ...
52
+
53
+
54
+ class LocalExecutor(Sandbox):
55
+ """In-process executor. State persists across ``run`` calls via the shared
56
+ Session, enabling multi-turn workflows without re-fetching."""
57
+
58
+ def __init__(self, session: Session):
59
+ self.session = session
60
+ self._globals = self._build_namespace()
61
+
62
+ def _build_namespace(self) -> dict[str, Any]:
63
+ return {
64
+ "sac": self.session, # the harness handle
65
+ "session": self.session, # alias
66
+ "Document": Document,
67
+ "Hit": Hit,
68
+ "ResultSet": ResultSet,
69
+ # primitives available as bare functions too
70
+ "fan_out": P.fan_out,
71
+ "fuse": P.fuse,
72
+ "rerank": P.rerank,
73
+ "dedup": P.dedup,
74
+ "freshness": P.freshness,
75
+ "mmr": P.mmr,
76
+ "expand": P.expand,
77
+ "decompose": P.decompose,
78
+ "__builtins__": _safe_builtins(),
79
+ }
80
+
81
+ def run(self, code: str) -> ExecResult:
82
+ self._globals.pop("evidence", None)
83
+ buf = io.StringIO()
84
+ try:
85
+ with contextlib.redirect_stdout(buf):
86
+ exec(compile(code, "<agent-code>", "exec"), self._globals) # noqa: S102
87
+ except Exception:
88
+ return ExecResult(
89
+ ok=False,
90
+ stdout=buf.getvalue(),
91
+ error=traceback.format_exc(limit=3),
92
+ state_keys=self.session.state_keys(),
93
+ )
94
+ return ExecResult(
95
+ ok=True,
96
+ evidence=self._globals.get("evidence"),
97
+ stdout=buf.getvalue(),
98
+ state_keys=self.session.state_keys(),
99
+ )
100
+
101
+
102
+ def _safe_builtins() -> dict[str, Any]:
103
+ """A conservative builtins allowlist for the local executor.
104
+
105
+ Real isolation belongs in the sandbox backend; this only trims the sharpest
106
+ edges so a stray ``open``/``__import__`` in dev doesn't touch the host.
107
+ """
108
+ import builtins
109
+
110
+ allow = {
111
+ "abs", "all", "any", "bool", "dict", "enumerate", "filter", "float",
112
+ "int", "len", "list", "map", "max", "min", "print", "range", "reversed",
113
+ "round", "set", "sorted", "str", "sum", "tuple", "zip", "isinstance",
114
+ "getattr", "hasattr", "True", "False", "None", "Exception",
115
+ }
116
+ return {k: getattr(builtins, k) for k in allow if hasattr(builtins, k)}