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,349 @@
1
+ """Exploration stages.
2
+
3
+ Implemented: :class:`SampleStage` (stratified sample), :class:`ProfileStage` (content-type
4
+ mix + LLM characterization), :class:`SynthesizeStage` (grounded synthetic queries) and
5
+ :class:`ValidateStage` (held-out retrieval eval + keep-if-better report). The rest
6
+ (ontology/crossdoc/router/codegen) are typed stubs recorded as ``planned`` so the pipeline
7
+ runs end-to-end and the pack shows the roadmap.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections import Counter
15
+
16
+ import numpy as np
17
+
18
+ from .engine import ExploreContext, Stage # noqa: F401 (re-exported for convenience)
19
+
20
+
21
+ # --------------------------------------------------------------------------- #
22
+ # tiny, dependency-free k-means so sampling stays stratified without sklearn #
23
+ # --------------------------------------------------------------------------- #
24
+ def _kmeans(x: np.ndarray, k: int, iters: int = 25, seed: int = 0):
25
+ """Return (labels, centroids). k-means++ init, cosine-friendly on L2-normed x."""
26
+ n = len(x)
27
+ k = max(1, min(k, n))
28
+ rng = np.random.default_rng(seed)
29
+ # k-means++ seeding
30
+ centers = [int(rng.integers(n))]
31
+ d2 = ((x - x[centers[0]]) ** 2).sum(1)
32
+ for _ in range(1, k):
33
+ probs = d2 / (d2.sum() or 1.0)
34
+ nxt = int(rng.choice(n, p=probs))
35
+ centers.append(nxt)
36
+ d2 = np.minimum(d2, ((x - x[nxt]) ** 2).sum(1))
37
+ cent = x[centers].copy()
38
+ labels = np.zeros(n, dtype=int)
39
+ for _ in range(iters):
40
+ # assign
41
+ dists = ((x[:, None, :] - cent[None, :, :]) ** 2).sum(2)
42
+ new = dists.argmin(1)
43
+ if np.array_equal(new, labels) and _ > 0:
44
+ break
45
+ labels = new
46
+ # update
47
+ for j in range(k):
48
+ m = labels == j
49
+ if m.any():
50
+ cent[j] = x[m].mean(0)
51
+ return labels, cent
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # Stage 1 — stratified sample #
56
+ # --------------------------------------------------------------------------- #
57
+ class SampleStage(Stage):
58
+ """Draw a *representative* working sample: pull a larger random pool, cluster it in
59
+ embedding space, then keep a few docs nearest each centroid. This surfaces rare doc
60
+ types that a flat random-n sample would miss."""
61
+
62
+ name = "sample"
63
+ produces = ["sample.jsonl", "sample_meta.json"]
64
+
65
+ def run(self, ctx: ExploreContext) -> dict:
66
+ pool_size = int(ctx.cfg("pool_size", 200))
67
+ per_cluster = int(ctx.cfg("per_cluster", 3))
68
+ pool = ctx.store.sample(pool_size)
69
+ pool = [d for d in pool if (d.text or "").strip()]
70
+ if not pool:
71
+ ctx.pack.write_jsonl("sample.jsonl", [])
72
+ ctx.pack.write_json("sample_meta.json", {"pool": 0, "clusters": 0, "sample": 0})
73
+ return {"pool": 0, "clusters": 0, "sample": 0}
74
+
75
+ vecs = np.asarray(ctx.embedder.embed([d.text or "" for d in pool]), dtype=np.float32)
76
+ vecs = vecs / (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-9)
77
+ n_clusters = int(ctx.cfg("n_clusters", max(1, min(8, len(pool) // 5))))
78
+ labels, cent = _kmeans(vecs, n_clusters, seed=int(ctx.cfg("seed", 0)))
79
+
80
+ rows, sizes = [], {}
81
+ for j in range(len(cent)):
82
+ idx = np.where(labels == j)[0]
83
+ if len(idx) == 0:
84
+ continue
85
+ sizes[str(j)] = int(len(idx))
86
+ # closest to centroid first → most typical of the cluster
87
+ order = idx[np.argsort(((vecs[idx] - cent[j]) ** 2).sum(1))]
88
+ for i in order[:per_cluster]:
89
+ d = pool[int(i)]
90
+ rows.append({"id": d.id, "cluster": j, "text": d.text,
91
+ "metadata": d.metadata or {}})
92
+ ctx.pack.write_jsonl("sample.jsonl", rows)
93
+ ctx.pack.write_json("sample_meta.json",
94
+ {"pool": len(pool), "clusters": len(sizes),
95
+ "cluster_sizes": sizes, "sample": len(rows)})
96
+ return {"pool": len(pool), "clusters": len(sizes), "sample": len(rows)}
97
+
98
+
99
+ # --------------------------------------------------------------------------- #
100
+ # Stage 2 — profile #
101
+ # --------------------------------------------------------------------------- #
102
+ class ProfileStage(Stage):
103
+ """Characterize the corpus: schema + per-chunk content-type mix (heuristic) and,
104
+ when a generator is present, an LLM-written profile of the data (data type, key
105
+ entities, recommended primitives) — both overall and per cluster."""
106
+
107
+ name = "profile"
108
+ requires = ["sample"]
109
+ produces = ["content_profile.json"]
110
+
111
+ def run(self, ctx: ExploreContext) -> dict:
112
+ from ..primitives import content_type
113
+
114
+ sample = ctx.pack.read_jsonl("sample.jsonl")
115
+ use_llm = bool(ctx.cfg("llm", ctx.generator is not None))
116
+
117
+ types: dict[str, int] = {}
118
+ for r in sample:
119
+ ct = content_type(r.get("text") or "")
120
+ types[ct] = types.get(ct, 0) + 1
121
+
122
+ try:
123
+ schema = ctx.store.describe_schema()
124
+ except Exception:
125
+ schema = {"backend": getattr(ctx.store, "backend", "?")}
126
+
127
+ overall_llm = None
128
+ cluster_profiles: dict[str, str] = {}
129
+ if use_llm and ctx.generator is not None:
130
+ overall_llm = _llm_profile(ctx, schema, [r["text"] for r in sample[:8]])
131
+ by_cluster: dict[int, list[str]] = {}
132
+ for r in sample:
133
+ by_cluster.setdefault(r.get("cluster", 0), []).append(r.get("text") or "")
134
+ for c, texts in sorted(by_cluster.items()):
135
+ cluster_profiles[str(c)] = _llm_profile(ctx, schema, texts[:4])
136
+
137
+ profile = {
138
+ "schema": schema,
139
+ "content_types": types,
140
+ "n_sampled": len(sample),
141
+ "llm_overall": overall_llm,
142
+ "llm_by_cluster": cluster_profiles,
143
+ }
144
+ ctx.pack.write_json("content_profile.json", profile)
145
+ return {"content_types": types, "clusters_profiled": len(cluster_profiles),
146
+ "llm": bool(overall_llm)}
147
+
148
+
149
+ def _llm_profile(ctx: ExploreContext, schema: dict, texts: list[str]) -> str:
150
+ fields = schema.get("fields") or schema.get("metadata_keys") or {}
151
+ rows = "\n".join(f"- {(t or '')[:300]}" for t in texts if t) or "(no text)"
152
+ prompt = (
153
+ "You are profiling a search corpus before writing retrieval code.\n"
154
+ f"Backend: {schema.get('backend') or schema.get('index')} "
155
+ f"Approx docs: {schema.get('count')}\nFields: {fields}\n\n"
156
+ f"Sample documents:\n{rows}\n\n"
157
+ "In 4-6 short lines: (1) what kind of data this is (prose, tables, curated "
158
+ "fact-cards, code, mixed?), (2) key entities/fields a query targets, (3) which "
159
+ "retrieval primitives fit best (keyword/exact & regex for part-numbers & fact-cards, "
160
+ "dense/hyde for prose, fielded/phrase for structured fields)."
161
+ )
162
+ try:
163
+ out = ctx.generator(prompt)
164
+ return out[0] if isinstance(out, list) else str(out)
165
+ except Exception as e: # pragma: no cover
166
+ return f"(llm profile unavailable: {e})"
167
+
168
+
169
+ # --------------------------------------------------------------------------- #
170
+ # Stages 3-7 — typed stubs (recorded as ``planned`` until implemented) #
171
+ # --------------------------------------------------------------------------- #
172
+ class _PlannedStage(Stage):
173
+ _todo = "not implemented yet"
174
+
175
+ def run(self, ctx: ExploreContext) -> dict:
176
+ raise NotImplementedError(self._todo)
177
+
178
+
179
+ class OntologyStage(_PlannedStage):
180
+ """LLM-induce a domain ontology (entities/relations/synonyms) from the sample; then
181
+ best-effort enrich from the web and reconcile; surface for review."""
182
+ name = "ontology"
183
+ requires = ["profile"]
184
+ produces = ["ontology.json"]
185
+ _todo = "LLM-induced + web-enriched ontology (next pass)"
186
+
187
+
188
+ class CrossDocStage(_PlannedStage):
189
+ """Link documents via the ontology (entity co-occurrence / KG edges) — the
190
+ cross-document relation layer used for candidate expansion."""
191
+ name = "crossdoc"
192
+ requires = ["ontology"]
193
+ produces = ["crossdoc.json"]
194
+ _todo = "cross-document relation graph (next pass)"
195
+
196
+
197
+ class SynthesizeStage(Stage):
198
+ """Generate stratified easy/medium/hard queries **grounded in the sampled docs** — each
199
+ query's answer lives in a known document (its ``gold_id``). This is a leakage-free eval/
200
+ training set (built from the corpus, never from the downstream test set) used by
201
+ ``validate`` (and later the router/few-shot mining)."""
202
+
203
+ name = "synthesize"
204
+ requires = ["sample"]
205
+ produces = ["synth_queries.jsonl"]
206
+
207
+ def run(self, ctx: ExploreContext) -> dict:
208
+ if ctx.generator is None:
209
+ raise RuntimeError("synthesize needs a Session generator")
210
+ sample = ctx.pack.read_jsonl("sample.jsonl")
211
+ max_docs = int(ctx.cfg("synth_docs", 30))
212
+ per_doc = int(ctx.cfg("synth_per_doc", 3))
213
+ rows = []
214
+ for r in sample[:max_docs]:
215
+ for diff, q in _gen_queries(ctx, r.get("text") or "", per_doc):
216
+ rows.append({"query": q, "gold_id": r["id"],
217
+ "cluster": r.get("cluster"), "difficulty": diff})
218
+ ctx.pack.write_jsonl("synth_queries.jsonl", rows)
219
+ return {"queries": len(rows), "from_docs": min(len(sample), max_docs),
220
+ "by_difficulty": dict(Counter(x["difficulty"] for x in rows))}
221
+
222
+
223
+ def _gen_queries(ctx: ExploreContext, text: str, per_doc: int):
224
+ if not text.strip():
225
+ return []
226
+ prompt = (
227
+ f"From the technical document below, write {per_doc} distinct search questions whose "
228
+ "answer is IN this document. Vary difficulty across: 'easy' (direct keyword/fact "
229
+ "lookup), 'medium' (paraphrased/conceptual), 'hard' (indirect or multi-constraint). "
230
+ 'Return ONLY a JSON list of {"difficulty": "...", "query": "..."}. Keep each query '
231
+ f"self-contained (no 'this document').\n\nDOCUMENT:\n{text[:1200]}"
232
+ )
233
+ try:
234
+ out = ctx.generator(prompt)
235
+ txt = out[0] if isinstance(out, list) else str(out)
236
+ except Exception:
237
+ return []
238
+ m = re.search(r"\[.*\]", txt, re.DOTALL)
239
+ if not m:
240
+ return []
241
+ res = []
242
+ try:
243
+ for o in json.loads(m.group(0)):
244
+ q = (o.get("query") or "").strip()
245
+ if q:
246
+ res.append((o.get("difficulty", "?"), q))
247
+ except Exception:
248
+ return []
249
+ return res[:per_doc]
250
+
251
+
252
+ class RouterStage(_PlannedStage):
253
+ """Run primitive combos over the synth queries, label which retrieves gold, train
254
+ the XGBoost primitive router (query+profile features → best combo)."""
255
+ name = "router"
256
+ requires = ["synthesize"]
257
+ produces = ["router.pkl", "router_meta.json"]
258
+ _todo = "combo-exploration + XGB router (port from phase4)"
259
+
260
+
261
+ class CodegenStage(_PlannedStage):
262
+ """Mine winning combos into (situation→chain) templates and (query→code) few-shots;
263
+ optionally LLM-codegen new sandbox-validated primitives; write prompt overrides."""
264
+ name = "codegen"
265
+ requires = ["router"]
266
+ produces = ["templates.json", "few_shots.json", "prompt_overrides.md"]
267
+ _todo = "template/few-shot mining + sandbox-validated primitive codegen (next pass)"
268
+
269
+
270
+ class ValidateStage(Stage):
271
+ """Measure retrieval on the grounded synth queries: recall@k for each base strategy
272
+ (dense/keyword/hybrid), overall and per cluster. Establishes the baseline and the
273
+ best strategy per data-shape — the signal later tunings must beat. Writes a JSON +
274
+ a human-readable REPORT.md."""
275
+
276
+ name = "validate"
277
+ requires = ["synthesize"]
278
+ produces = ["validation.json", "REPORT.md"]
279
+
280
+ STRATEGIES = ["dense", "keyword", "hybrid"]
281
+
282
+ def run(self, ctx: ExploreContext) -> dict:
283
+ qs = ctx.pack.read_jsonl("synth_queries.jsonl")
284
+ if not qs:
285
+ raise RuntimeError("no synth queries to validate on")
286
+ k = int(ctx.cfg("validate_k", 10))
287
+ hit = {s: 0 for s in self.STRATEGIES}
288
+ by_cluster: dict = {} # cluster -> strategy -> [hits, n]
289
+ by_diff: dict = {} # difficulty -> strategy -> [hits, n]
290
+ for row in qs:
291
+ gold, c, d = row["gold_id"], row.get("cluster"), row.get("difficulty", "?")
292
+ for s in self.STRATEGIES:
293
+ ids = _search_ids(ctx, s, row["query"], k)
294
+ h = int(gold in ids)
295
+ hit[s] += h
296
+ by_cluster.setdefault(c, {}).setdefault(s, [0, 0])
297
+ by_cluster[c][s][0] += h; by_cluster[c][s][1] += 1
298
+ by_diff.setdefault(d, {}).setdefault(s, [0, 0])
299
+ by_diff[d][s][0] += h; by_diff[d][s][1] += 1
300
+
301
+ n = len(qs)
302
+ recall = {s: hit[s] / n for s in self.STRATEGIES}
303
+ best = max(recall, key=recall.get)
304
+ cluster_best = {str(c): _best(d) for c, d in by_cluster.items()}
305
+ result = {"n": n, "k": k, "recall_at_k": recall, "best_overall": best,
306
+ "cluster_best": cluster_best,
307
+ "by_difficulty": {d: _rates(v) for d, v in by_diff.items()}}
308
+ ctx.pack.write_json("validation.json", result)
309
+ ctx.pack.path("REPORT.md").write_text(_report_md(ctx, result))
310
+ return {"n": n, "best": best,
311
+ "recall_at_k": {s: round(recall[s], 3) for s in self.STRATEGIES}}
312
+
313
+
314
+ def _search_ids(ctx: ExploreContext, mode: str, query: str, k: int) -> set:
315
+ try:
316
+ return set(ctx.session.search(query, top_k=k, mode=mode).ids())
317
+ except Exception:
318
+ return set()
319
+
320
+
321
+ def _best(strat_counts: dict) -> str:
322
+ return max(strat_counts, key=lambda s: (strat_counts[s][0] / strat_counts[s][1]
323
+ if strat_counts[s][1] else 0.0))
324
+
325
+
326
+ def _rates(strat_counts: dict) -> dict:
327
+ return {s: round(v[0] / v[1], 3) if v[1] else None for s, v in strat_counts.items()}
328
+
329
+
330
+ def _report_md(ctx: ExploreContext, res: dict) -> str:
331
+ lines = ["# Exploration validation report", "",
332
+ f"Grounded synthetic queries: **{res['n']}** · recall@{res['k']}", "",
333
+ "## Recall@k by strategy",
334
+ "| strategy | recall |", "|---|---|"]
335
+ for s, r in sorted(res["recall_at_k"].items(), key=lambda x: -x[1]):
336
+ star = " ⭐" if s == res["best_overall"] else ""
337
+ lines.append(f"| {s} | {r:.3f}{star} |")
338
+ lines += ["", "## Best strategy per cluster", "| cluster | best |", "|---|---|"]
339
+ for c, s in sorted(res["cluster_best"].items()):
340
+ lines.append(f"| {c} | {s} |")
341
+ lines += ["", "## Recall@k by difficulty", "| difficulty | " +
342
+ " | ".join(ValidateStage.STRATEGIES) + " |",
343
+ "|---|" + "---|" * len(ValidateStage.STRATEGIES)]
344
+ for d, rates in res["by_difficulty"].items():
345
+ lines.append(f"| {d} | " + " | ".join(str(rates.get(s)) for s in
346
+ ValidateStage.STRATEGIES) + " |")
347
+ lines += ["", "_Baseline for keep-if-better gating: later tunings (router, codegen) must "
348
+ "beat these recall numbers on the same queries to be kept._"]
349
+ return "\n".join(lines)
@@ -0,0 +1,102 @@
1
+ """A tiny, portable metadata-filter language.
2
+
3
+ Agent code always writes filters in one Mongo-ish dialect; each adapter either
4
+ translates it to its native filter DSL or, when the backend can't filter
5
+ server-side, we evaluate it client-side with :func:`matches`.
6
+
7
+ {"lang": "en"} # equality shorthand
8
+ {"year": {"$gte": 2020}} # operators
9
+ {"tag": {"$in": ["cve", "advisory"]}}
10
+ {"$and": [{"lang": "en"}, {"year": {"$gte": 2020}}]}
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Mapping
16
+
17
+ from .errors import InvalidFilterError
18
+
19
+ _OPS = {
20
+ "$eq": lambda a, b: a == b,
21
+ "$ne": lambda a, b: a != b,
22
+ "$gt": lambda a, b: a is not None and a > b,
23
+ "$gte": lambda a, b: a is not None and a >= b,
24
+ "$lt": lambda a, b: a is not None and a < b,
25
+ "$lte": lambda a, b: a is not None and a <= b,
26
+ "$in": lambda a, b: a in b,
27
+ "$nin": lambda a, b: a not in b,
28
+ "$exists": lambda a, b: (a is not None) == bool(b),
29
+ "$contains": lambda a, b: b in a if a is not None else False,
30
+ }
31
+
32
+
33
+ def matches(metadata: Mapping[str, Any], flt: Mapping[str, Any] | None) -> bool:
34
+ """Evaluate a filter against a metadata dict (client-side emulation)."""
35
+ if not flt:
36
+ return True
37
+ for key, cond in flt.items():
38
+ if key == "$and":
39
+ if not all(matches(metadata, c) for c in cond):
40
+ return False
41
+ elif key == "$or":
42
+ if not any(matches(metadata, c) for c in cond):
43
+ return False
44
+ elif key == "$not":
45
+ if matches(metadata, cond):
46
+ return False
47
+ elif isinstance(cond, Mapping) and any(k.startswith("$") for k in cond):
48
+ value = metadata.get(key)
49
+ for op, operand in cond.items():
50
+ fn = _OPS.get(op)
51
+ if fn is None:
52
+ raise InvalidFilterError("unknown filter operator", operator=op)
53
+ if not fn(value, operand):
54
+ return False
55
+ else:
56
+ if metadata.get(key) != cond:
57
+ return False
58
+ return True
59
+
60
+
61
+ _LOGICAL = {"$and", "$or"}
62
+
63
+
64
+ def validate(flt: Mapping[str, Any] | None) -> None:
65
+ """Raise :class:`InvalidFilterError` for malformed filters (unknown operators,
66
+ bad logical structure). Called at the boundary so every backend — including
67
+ server-side adapters that would otherwise silently drop bad operators — fails
68
+ fast with a clear, typed error."""
69
+ if not flt:
70
+ return
71
+ if not isinstance(flt, Mapping):
72
+ raise InvalidFilterError("filter must be a mapping", got=type(flt).__name__)
73
+ for key, cond in flt.items():
74
+ if key in _LOGICAL:
75
+ if not isinstance(cond, (list, tuple)):
76
+ raise InvalidFilterError(f"{key} expects a list of filters", operator=key)
77
+ for c in cond:
78
+ validate(c)
79
+ elif key == "$not":
80
+ validate(cond)
81
+ elif isinstance(cond, Mapping) and any(k.startswith("$") for k in cond):
82
+ for op in cond:
83
+ if op not in _OPS:
84
+ raise InvalidFilterError("unknown filter operator", operator=op)
85
+
86
+
87
+ def normalize(flt: Mapping[str, Any] | None) -> dict[str, Any]:
88
+ """Expand equality shorthand into explicit ``$eq`` so adapters translate a
89
+ single canonical form."""
90
+ if not flt:
91
+ return {}
92
+ out: dict[str, Any] = {}
93
+ for key, cond in flt.items():
94
+ if key in ("$and", "$or"):
95
+ out[key] = [normalize(c) for c in cond]
96
+ elif key == "$not":
97
+ out[key] = normalize(cond)
98
+ elif isinstance(cond, Mapping) and any(k.startswith("$") for k in cond):
99
+ out[key] = dict(cond)
100
+ else:
101
+ out[key] = {"$eq": cond}
102
+ return out