pycurate 0.4.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 (48) hide show
  1. pycurate/__init__.py +612 -0
  2. pycurate/compute.py +272 -0
  3. pycurate/core/blocks.py +271 -0
  4. pycurate/core/cluster.py +148 -0
  5. pycurate/core/diagnostics.py +292 -0
  6. pycurate/core/embed.py +132 -0
  7. pycurate/core/neighbors.py +120 -0
  8. pycurate/core/scores.py +112 -0
  9. pycurate/core/select.py +71 -0
  10. pycurate/data.py +343 -0
  11. pycurate/domains/_base.py +290 -0
  12. pycurate/domains/_dense.py +382 -0
  13. pycurate/domains/_posttrain.py +111 -0
  14. pycurate/domains/detection/__init__.py +729 -0
  15. pycurate/domains/image_text/__init__.py +457 -0
  16. pycurate/domains/image_text/models.py +427 -0
  17. pycurate/domains/labels/__init__.py +365 -0
  18. pycurate/domains/preference/__init__.py +123 -0
  19. pycurate/domains/rl/__init__.py +161 -0
  20. pycurate/domains/robotics/__init__.py +1251 -0
  21. pycurate/domains/segmentation/__init__.py +454 -0
  22. pycurate/domains/sft/__init__.py +240 -0
  23. pycurate/domains/text/__init__.py +217 -0
  24. pycurate/domains/text/models.py +121 -0
  25. pycurate/harness.py +329 -0
  26. pycurate/io/lance_knn.py +172 -0
  27. pycurate/recipe.py +335 -0
  28. pycurate/research/__init__.py +12 -0
  29. pycurate/research/fidelity.py +241 -0
  30. pycurate/research/loop.py +519 -0
  31. pycurate/research/multiplier.py +114 -0
  32. pycurate/research/proposers.py +351 -0
  33. pycurate/research/racing.py +123 -0
  34. pycurate/research/simulate.py +123 -0
  35. pycurate/research/trials.py +116 -0
  36. pycurate/router.py +105 -0
  37. pycurate/signals/__init__.py +274 -0
  38. pycurate/signals/adoption.py +167 -0
  39. pycurate/signals/discover.py +458 -0
  40. pycurate/signals/gate.py +253 -0
  41. pycurate/signals/sandbox.py +94 -0
  42. pycurate/signals/spec.py +449 -0
  43. pycurate/signals/store.py +217 -0
  44. pycurate-0.4.0.dist-info/METADATA +118 -0
  45. pycurate-0.4.0.dist-info/RECORD +48 -0
  46. pycurate-0.4.0.dist-info/WHEEL +5 -0
  47. pycurate-0.4.0.dist-info/licenses/LICENSE +202 -0
  48. pycurate-0.4.0.dist-info/top_level.txt +1 -0
pycurate/__init__.py ADDED
@@ -0,0 +1,612 @@
1
+ """pycurate: data curation and curation auto-research. One verb that escalates:
2
+
3
+ import pycurate as pc
4
+
5
+ ds = pc.load("data/*.parquet") # any tabular source -> a Lance-backed Dataset
6
+ cur = pc.curate(ds) # 1. routes to the right domain, applies its default
7
+ cur = pc.curate(ds, eval=train_eval, budget=20) # 2. + TPE tuning on your eval, guarded by the default
8
+ cur = pc.curate(ds, eval=train_eval, budget=20, agent=True) # 3. + agent: literature signals, LLM-written recipes
9
+ cur.report # level, guard decision (pick vs default), trials
10
+ loader = cur.to_torch(columns=["text"], batch_size=256) # zero-copy, globally shuffled training batches
11
+
12
+ ``pc.diagnose(ds)`` runs pre-flight checks; ``target_samples=`` makes curation compute-aware. ``agent=True`` is the
13
+ only path that calls an LLM. Building blocks: ``pc.search`` (returns a ``SearchResult``), ``pc.prepare`` (compute /
14
+ discover / register signals), ``pc.discover_signals``, ``pc.signals`` (catalog, provenance, protected columns),
15
+ ``pc.harness``, ``pc.research``. Domain modules load lazily: ``pc.text``, ``pc.labels``, ``pc.image_text``,
16
+ ``pc.sft``, ``pc.preference``, ``pc.rl``, ``pc.robotics``, ``pc.detection``, ``pc.segmentation``. Every curation decision is a :class:`Recipe` over signal
17
+ columns. Docs: docs/index.md (``mkdocs serve``).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Callable
25
+
26
+ from .data import Dataset
27
+ from .recipe import Recipe, RecipeError
28
+ from .router import DOMAINS, detect
29
+
30
+ __version__ = "0.4.0"
31
+ __all__ = ["Dataset", "Recipe", "RecipeError", "Curated", "load", "curate", "diagnose", "search", "detect", "DOMAINS",
32
+ "default_knob_space", "prepare", "discover_signals", "Prepared", "AgentUnavailable", "LiteratureRequired",
33
+ "AGENT_STRATEGY"]
34
+
35
+ _LAZY = {*DOMAINS, "core", "research", "compute", "io", "signals", "harness"}
36
+
37
+
38
+ def __getattr__(name: str):
39
+ if name == "discover_signals":
40
+ from .signals.discover import discover_signals
41
+
42
+ return discover_signals
43
+ if name == "LiteratureRequired":
44
+ from .signals.discover import LiteratureRequired
45
+
46
+ return LiteratureRequired
47
+ if name in DOMAINS:
48
+ return importlib.import_module(f"pycurate.domains.{name}")
49
+ if name in _LAZY:
50
+ return importlib.import_module(f"pycurate.{name}")
51
+ raise AttributeError(f"module 'pycurate' has no attribute {name!r}")
52
+
53
+
54
+ def load(source: Any, name: str | None = None, uri: str | None = None, overwrite: bool = False) -> Dataset:
55
+ """Open or ingest a dataset.
56
+
57
+ ``source``: an existing ``Dataset`` / lancedb table, a table name in ``uri``, parquet path(s) or glob,
58
+ a pandas / pyarrow table, a dict of arrays, or ``"hf://<repo>[:<split>]"`` for a Hugging Face dataset.
59
+ Ingesting into a ``name`` that already exists in ``uri`` raises unless ``overwrite=True``.
60
+ """
61
+ import glob as _glob
62
+ import os
63
+
64
+ from .data import DEFAULT_URI
65
+
66
+ if isinstance(source, Dataset):
67
+ return source
68
+ if isinstance(source, str) and source.startswith("hf://"):
69
+ from datasets import load_dataset
70
+
71
+ repo, _, split = source[5:].partition(":")
72
+ hf = load_dataset(repo, split=split or "train")
73
+ return Dataset.create(name or repo.split("/")[-1].replace("-", "_"), hf.data.table, uri=uri or DEFAULT_URI,
74
+ mode="overwrite" if overwrite else "create")
75
+ if isinstance(source, str) and not any(ch in source for ch in "*/.") and uri is not None:
76
+ return Dataset.open(source, uri)
77
+ if isinstance(source, str) and any(ch in source for ch in "*?"):
78
+ pattern, source = source, sorted(_glob.glob(source))
79
+ if not source:
80
+ raise FileNotFoundError(f"pc.load: no files match {pattern!r} (relative to {os.getcwd()})")
81
+ elif isinstance(source, str) and "://" not in source and not os.path.exists(source):
82
+ raise FileNotFoundError(f"pc.load: {source!r} is not a file, a glob, a table name in uri=, or an hf:// / "
83
+ "object-store path")
84
+ if name is None and uri is None:
85
+ return Dataset.wrap(source)
86
+ try:
87
+ return Dataset.create(name or "dataset", source, uri=uri or DEFAULT_URI,
88
+ mode="overwrite" if overwrite else "create")
89
+ except ValueError as e:
90
+ if "already exists" not in str(e):
91
+ raise
92
+ raise ValueError(f"pc.load: table {name or 'dataset'!r} already exists in {uri or DEFAULT_URI!r}. Reopen it "
93
+ f"with pc.load({name or 'dataset'!r}, uri={uri or DEFAULT_URI!r}), or pass overwrite=True to "
94
+ "ingest again") from e
95
+
96
+
97
+ def _domain(ds: Dataset, domain: str | None):
98
+ return importlib.import_module(f"pycurate.domains.{domain or detect(ds.schema)}")
99
+
100
+
101
+ @dataclass
102
+ class Curated:
103
+ """The result of :func:`curate`: the ids to train on, the recipe that produced them, a lazy view, and ``report``
104
+ (``level``, ``search``, ``guard``, ``discovery``, ...: what ran and why this recipe was applied)."""
105
+
106
+ dataset: Dataset
107
+ recipe: Recipe
108
+ ids: Any
109
+ domain: str
110
+ report: dict = field(default_factory=dict)
111
+
112
+ def __len__(self) -> int:
113
+ return len(self.ids)
114
+
115
+ def view(self, columns: list[str] | None = None):
116
+ """Zero-copy ``Permutation`` over the base table (iterate, index, or hand to a torch DataLoader)."""
117
+ if len(self.ids) == 0:
118
+ raise ValueError(f"{self!r} selected no rows; nothing to view (loosen the recipe's where / keep)")
119
+ return self.dataset.permutation(self.ids, columns=columns)
120
+
121
+ def to_torch(self, columns: list[str] | None = None, batch_size: int = 256, num_workers: int = 0, **loader_kw):
122
+ """A ``torch.utils.data.DataLoader`` over :meth:`view`: batches are dicts of column -> values, read straight
123
+ from the base table in the curated (globally shuffled) order; nothing is copied. ``columns``: what to read
124
+ (default: all; name only what you train on). Worker processes use ``spawn`` (Lance is not fork-safe)."""
125
+ try:
126
+ from torch.utils.data import DataLoader
127
+ except ImportError as e:
128
+ raise ImportError("Curated.to_torch needs PyTorch: pip install torch (or iterate cur.view() yourself)") from e
129
+ if num_workers and "multiprocessing_context" not in loader_kw:
130
+ loader_kw["multiprocessing_context"] = "spawn"
131
+ return DataLoader(self.view(columns=columns), batch_size=batch_size, num_workers=num_workers, **loader_kw)
132
+
133
+ def to_arrow(self, columns: list[str] | None = None):
134
+ """Materialise the curated rows in training order (a copy; prefer :meth:`view` for training)."""
135
+ return self.dataset.lance().take(_offsets(self.dataset, self.ids), columns=columns)
136
+
137
+ def to_pandas(self, columns: list[str] | None = None):
138
+ """Materialise the curated rows as a pandas DataFrame (a copy; for inspection)."""
139
+ return self.to_arrow(columns).to_pandas()
140
+
141
+ def mask(self):
142
+ """Boolean array over ``id`` (``mask()[i]`` is True if row ``i`` was kept at least once)."""
143
+ import numpy as np
144
+
145
+ m = np.zeros(int(self.dataset.ids().max()) + 1, bool)
146
+ m[np.asarray(self.ids, dtype=np.int64)] = True
147
+ return m
148
+
149
+ def __repr__(self) -> str:
150
+ return f"Curated({len(self):,} of {len(self.dataset):,} rows, domain={self.domain!r}, {self.recipe})"
151
+
152
+
153
+ def _offsets(ds: Dataset, ids):
154
+ """Row offsets (positions in scan order) for ``id`` values, for ``LanceDataset.take``."""
155
+ import numpy as np
156
+
157
+ all_ids = ds.lance().to_table(columns=["id"]).column("id").to_numpy()
158
+ pos = np.empty(all_ids.max() + 1, np.int64)
159
+ pos[all_ids] = np.arange(len(all_ids))
160
+ return pos[np.asarray(ids)]
161
+
162
+
163
+ AGENT_STRATEGY = "agent+diag" # agent=True's recipe proposer: the LLM agent + the diagnose report (benchmark headline)
164
+
165
+
166
+ def curate(data: Any, keep: float | int | None = None, recipe: Recipe | dict | str | None = None,
167
+ domain: str | None = None, signals: bool | list[str] | tuple = True, seed: int = 0,
168
+ target_samples: float | None = None, cap_passes: float | None = None, *, eval: Callable | None = None,
169
+ budget: int = 20, agent: bool = False, strategy: str | None = None, trials: str | None = None,
170
+ guard: str | None = "auto", guard_margin: float = 0.0, subpool: int | None = None, goal: str = "",
171
+ harness: Any = None, offline: bool = False, allow_code: bool = False, **opts) -> Curated:
172
+ """Curate ``data`` in one call and return a :class:`Curated` (ids + recipe + lazy view). One verb, three levels:
173
+
174
+ cur = pc.curate(ds) # 1. default: domain recipe, no LLM, no training
175
+ cur = pc.curate(ds, eval=train_eval, budget=20) # 2. tuned: TPE on your eval, guarded by the default
176
+ cur = pc.curate(ds, eval=train_eval, budget=20, agent=True) # 3. agent: literature signals + LLM recipes, guarded
177
+ cur.report # level, guard decision, trials, discovery
178
+
179
+ **Level 1 (default).** Route to a domain (``domain=`` overrides :func:`detect`), compute the domain's default
180
+ signals as columns (existing columns are reused; ``signals=False`` skips this, ``signals=["clip_score"]`` computes
181
+ only those), and apply ``recipe`` (a :class:`Recipe`, its dict form, or a SQL ``where`` string) or the domain's
182
+ default recipe at ``keep``. The default is the strongest established public method for the domain (image_text:
183
+ DFN top 30% + SemDeDup; text: DCLM fastText top 10% + SemDeDup; sft: reward-model top 50%; ...). Model-backed
184
+ scores run only where they can (DFN needs a GPU; nothing downloads under ``HF_HUB_OFFLINE``); otherwise the
185
+ domain falls back to the next-best signal, logs why, and records it in ``recipe.notes`` / ``report["default"]``.
186
+
187
+ **Level 2 (tuned): ``eval=``.** Runs :func:`search` (``strategy="tpe"`` unless given; ``budget`` trials) with the
188
+ guard on: the domain default (at ``keep``) is evaluated as a trial and the search's pick is applied only if it
189
+ beats the default on the validation metric your eval returns (``heldout`` / ``val``; never shown to the search) by
190
+ more than ``guard_margin``; otherwise the default is applied. ``eval`` has :func:`search`'s objective contract:
191
+ ``eval(recipe, ids)`` or ``eval(recipe, ids, device)`` returns a float or a dict with ``score`` (higher is better)
192
+ and ideally ``heldout``. Without a validation key the pick is unchecked: one warning, and
193
+ ``report["guard"]["decision"] == "unavailable"``. ``guard=None`` turns the guard off. ``subpool=N`` runs every trial
194
+ on the ``id % N = 0`` sub-pool (ratio-matched proxy; ``pc.research.matched_subpool``) and applies the pick to the
195
+ full pool. ``trials``: where every trial is recorded (default ``<uri>/_trials/<table>-<time>-<tag>.jsonl``).
196
+
197
+ **Level 3 (agent): ``eval=`` + ``agent=True``** (opt-in; calls an LLM). Before the search an agent surveys the
198
+ literature and proposes new signals (:func:`prepare` with ``discover=True``; written for review, run only with
199
+ ``allow_code=True``); then the agent proposes recipes (``strategy="agent+diag"``: it also sees :func:`diagnose`),
200
+ guarded by the default as in level 2. ``harness`` / ``offline`` / ``goal``: see :func:`search`.
201
+ ``agent=True`` **without** ``eval=`` runs the discovery step only and applies the default recipe: no recipe search
202
+ runs (there is nothing to compare recipes on); ``report["search"]`` is None and ``report["note"]`` says so.
203
+
204
+ ``cur.report``: ``level`` ("default" / "tuned" / "agent"), ``search`` (strategy, name, trials location, trial
205
+ count, pick and default search scores, applied = "pick" or "default"), ``guard`` (the decision dict),
206
+ ``discovery`` (agent mode), ``default`` (fallback notes), ``budget`` (with ``target_samples``).
207
+
208
+ ``keep``: fraction in (0, 1] or a row count; ``None`` lets the domain choose. ``opts`` go to the domain's
209
+ ``prepare`` / ``default_recipe`` (e.g. ``devices="cpu"``, ``k=20``, ``score="clip_l14"``, ``dfn=False``) and, with
210
+ ``eval=``, to :func:`search` (e.g. ``devices=[0, 1]``, ``batch``, ``task``, ``warm_start``).
211
+ ``target_samples`` (opt-in): the training budget in samples seen; ``report["budget"]`` = passes over the unique
212
+ kept rows, with a ``UserWarning`` above 8 passes. ``cap_passes``: raise ``keep`` to
213
+ ``ceil(target_samples / cap_passes)`` rows when the recipe keeps fewer (never lowers it).
214
+
215
+ cur = pc.curate(ds, keep=0.3)
216
+ cur = pc.curate(ds, recipe="lang = 'en'", keep=0.5, signals=False)
217
+ cur = pc.curate(ds, target_samples=128e6, cap_passes=6)
218
+ """
219
+ import dataclasses
220
+ import warnings
221
+
222
+ ds = load(data)
223
+ mod = _domain(ds, domain)
224
+ report: dict = {}
225
+ if eval is not None:
226
+ if recipe is not None:
227
+ raise ValueError("pc.curate: pass recipe= (apply it) or eval= (search for one), not both; to check your "
228
+ "recipe against a search, use pc.search(..., guard='auto', incumbent=recipe)")
229
+ res, recipe, report = _curate_search(ds, mod, eval, keep, budget, agent, strategy, signals, trials, guard,
230
+ guard_margin, subpool, goal, harness, offline, allow_code, seed, opts)
231
+ else:
232
+ if strategy is not None:
233
+ raise ValueError("pc.curate: strategy= picks a recipe search, which needs eval=")
234
+ report["level"] = "agent" if agent else "default"
235
+ if agent:
236
+ prep, _ = _agent_prepare(ds, mod, signals, goal, harness, offline, allow_code, opts)
237
+ report["discovery"] = prep.discovery
238
+ report["note"] = ("agent=True without eval=: literature discovery ran and the default recipe was applied; "
239
+ "no recipe search ran. Pass eval= (and budget=) to search recipes against your eval.")
240
+ else:
241
+ _prepare(mod, ds, signals, opts)
242
+ report["search"] = None
243
+ default = recipe is None
244
+ if default:
245
+ recipe = mod.default_recipe(ds, keep=keep, **opts)
246
+ elif isinstance(recipe, str):
247
+ recipe = Recipe(where=recipe)
248
+ elif isinstance(recipe, dict):
249
+ recipe = Recipe.from_dict(recipe)
250
+ else:
251
+ recipe = dataclasses.replace(recipe) # never mutate the caller's recipe
252
+ if keep is not None and recipe.keep is None:
253
+ recipe.keep = keep
254
+ if default and recipe.notes:
255
+ report["default"] = recipe.notes
256
+ recipe.validate(ds) # fail fast with the missing columns named, before any rows are read
257
+ if target_samples:
258
+ from .domains._base import compute_aware
259
+
260
+ recipe, ids, report["budget"] = compute_aware(recipe, ds, target_samples, cap_passes, seed=seed)
261
+ else:
262
+ ids = recipe.select(ds, seed=seed)
263
+ if len(ids) == 0:
264
+ warnings.warn(f"{recipe} selected 0 of {len(ds):,} rows", stacklevel=2)
265
+ return Curated(ds, recipe, ids, mod.__name__.rsplit(".", 1)[-1], report)
266
+
267
+
268
+ def _default_trials(ds) -> str:
269
+ """``<uri>/_trials/<table>-<time>.jsonl`` (next to ``_discovery``): curate's searches leave a record."""
270
+ import os
271
+ import time
272
+ import uuid
273
+
274
+ from .signals import store
275
+
276
+ uri, name = store._location(ds)
277
+ root = os.path.join(uri if "://" not in uri else os.path.join(os.getcwd(), "autocurate_trials"), "_trials")
278
+ os.makedirs(root, exist_ok=True)
279
+ return os.path.join(root, f"{name}-{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}.jsonl")
280
+
281
+
282
+ def _curate_search(ds, mod, eval, keep, budget, agent, strategy, signals, trials, guard, guard_margin, subpool, goal,
283
+ harness, offline, allow_code, seed, opts):
284
+ """Levels 2 / 3 of :func:`curate`: a guarded search; returns (SearchResult, recipe to apply, report)."""
285
+ import warnings
286
+
287
+ strategy = strategy or (AGENT_STRATEGY if agent else "tpe")
288
+ trials = trials or _default_trials(ds)
289
+ res = search(ds, eval, budget=budget, strategy=strategy, signals=signals, trials=trials, agent=agent, goal=goal,
290
+ harness=harness, offline=offline, allow_code=allow_code, seed=seed, subpool=subpool,
291
+ guard=guard, guard_margin=guard_margin, incumbent="default", _default_keep=keep, **opts)
292
+ default = res.default_recipe
293
+ g = res.guard or {"decision": "off" if not guard else "no_trials"}
294
+ use_default = res.best is None or g.get("decision") in ("default", "incumbent_best")
295
+ if res.best is None:
296
+ warnings.warn(f"pc.curate: no search trial completed (see {res.trials_path}); applying the default recipe",
297
+ stacklevel=3)
298
+ recipe = default if use_default else res.best_recipe_full_pool
299
+ sb = res.search_best
300
+ report = {"level": "agent" if agent else "tuned", "guard": g,
301
+ "search": {"strategy": res.strategy, "name": res.name, "trials": res.trials_path,
302
+ "n_trials": len(res.trials), "budget": budget, "subpool": res.subpool,
303
+ "applied": "default" if use_default else "pick",
304
+ "pick_score": sb and sb["score"], "default_score": g.get("default_score")}}
305
+ if use_default and default.notes:
306
+ report["default"] = default.notes
307
+ if res.discovery is not None:
308
+ report["discovery"] = res.discovery
309
+ if g.get("decision") == "unavailable":
310
+ report["search"]["note"] = ("the eval returned no validation metric (heldout / val), so the pick was not "
311
+ "checked against the default")
312
+ report["search_result"] = res # the full SearchResult (trials, proposer log, adoption)
313
+ return res, recipe, report
314
+
315
+
316
+ def _prepare(mod, ds, signals, opts):
317
+ if signals is True:
318
+ mod.prepare(ds, **opts)
319
+ elif signals:
320
+ mod.prepare(ds, signals=tuple([signals] if isinstance(signals, str) else signals), **opts)
321
+
322
+
323
+ class AgentUnavailable(RuntimeError):
324
+ """Agent mode was requested but no usable LLM agent harness was found (the message says how to set one up)."""
325
+
326
+
327
+ _NO_AGENT_HINT = ("Without an agent, drop agent=True: pc.search(ds, train_eval) runs TPE (no LLM, no API key) and "
328
+ "pc.curate(ds) applies the established default recipe.")
329
+
330
+
331
+ def _agent_harness(harness: Any, offline: bool):
332
+ """Resolve the harness for agent mode, failing early with what to do next."""
333
+ from . import harness as H
334
+ from .signals.discover import LiteratureRequired
335
+
336
+ try:
337
+ h = H.get_harness(harness)
338
+ except RuntimeError as e:
339
+ raise AgentUnavailable(
340
+ "agent=True needs an LLM agent, and none was found. Do one of: install and log in to Claude Code "
341
+ "(the `claude` CLI), set ANTHROPIC_API_KEY or OPENAI_API_KEY (+ PYCURATE_OPENAI_MODEL), install the "
342
+ "Codex CLI (`codex`), or pass harness=<name or Harness>. " + _NO_AGENT_HINT) from e
343
+ if not h.has_web and not offline:
344
+ raise LiteratureRequired(
345
+ f"agent=True starts with a literature survey (new signals must be grounded in published methods), and "
346
+ f"the agent harness {h.name!r} has no web access. Use a web-capable harness (claude-code, codex, "
347
+ "anthropic or openai; set PYCURATE_HARNESS or pass harness=), or pass offline=True to skip the survey "
348
+ "(recorded in provenance and flagged in the report). " + _NO_AGENT_HINT)
349
+ return h
350
+
351
+
352
+ def _agent_prepare(ds, mod, signals, goal, harness, offline, allow_code, opts, discover_opts=None):
353
+ """Agent mode's preparation: the domain's signals + literature-first discovery (propose-only by default)."""
354
+ import logging
355
+ import subprocess
356
+
357
+ h = _agent_harness(harness, offline)
358
+ names = "recommended" if signals is True else (([signals] if isinstance(signals, str) else list(signals))
359
+ if signals else None)
360
+ dom = mod.__name__.rsplit(".", 1)[-1]
361
+ try:
362
+ prep = prepare(ds, domain=dom, discover=True, goal=goal, harness=h, offline=offline, allow_code=allow_code,
363
+ signals=names, discover_opts=discover_opts, **opts)
364
+ except (subprocess.SubprocessError, TimeoutError, RuntimeError) as e:
365
+ if isinstance(e, AgentUnavailable):
366
+ raise
367
+ raise AgentUnavailable(f"signal discovery failed on the agent harness {h.name!r}: {e}. Check that it "
368
+ "works on its own (for claude-code: run `claude -p hi`; for API harnesses: the key and "
369
+ "network), or pass harness= to use another one. " + _NO_AGENT_HINT) from e
370
+ rep = prep.discovery
371
+ if rep is not None and not rep.allow_code and rep.candidates:
372
+ logging.getLogger("pycurate").warning(
373
+ "pycurate agent mode: %d signal proposal(s) written to %s for review and not run; pass "
374
+ "allow_code=True (or call pc.discover_signals(..., approve=fn)) to validate and add them",
375
+ len(rep.candidates), rep.out_dir)
376
+ return prep, h
377
+
378
+
379
+ def diagnose(data: Any, domain: str | None = None, signals: bool | list[str] | tuple = True, **opts):
380
+ """Pre-flight checks for ``data``: will curation pay off, roughly how much to keep, what could go wrong.
381
+
382
+ Domain-specific (see each domain's ``diagnose``). The checks read signal columns, so the domain's default
383
+ signals are computed first, exactly as :func:`curate` would (existing columns are reused; ``signals=False``
384
+ diagnoses only what is already there). Returns a ``Report``: ``print`` it, or ``report.to_dict()`` for what a
385
+ search agent sees.
386
+ """
387
+ ds = load(data)
388
+ mod = _domain(ds, domain)
389
+ _prepare(mod, ds, signals, opts)
390
+ return mod.diagnose(ds, **opts)
391
+
392
+
393
+ def default_knob_space(space: dict) -> dict:
394
+ """A random / TPE knob space from a domain ``search_space``: which column to rank by (either direction),
395
+ the keep fraction (log scale), and the group columns offered for ``balance_by`` / ``dedup_by``."""
396
+ numeric = [c for c in space.get("stats", {}) if not c.startswith("_")]
397
+ lo, hi = space.get("keep", [0.05, 1.0])
398
+ knobs: dict = {"score": [None, *numeric], "keep": ("float", float(lo), float(hi), "log")}
399
+ if numeric:
400
+ knobs["ascending"] = [False, True]
401
+ knobs["band"] = [None, [0.0, 0.9], [0.1, 1.0], [0.25, 0.75]] # score-percentile band (dropped when score=None)
402
+ if space.get("balance_by"):
403
+ knobs["balance_by"] = [None, *space["balance_by"]]
404
+ knobs["balance"] = ["proportional", "sqrt", "uniform"]
405
+ if space.get("dedup_by"):
406
+ knobs["dedup_by"] = [None, *space["dedup_by"]]
407
+ return knobs
408
+
409
+
410
+ def _accepts_device(fn: Callable) -> bool:
411
+ import inspect
412
+
413
+ try:
414
+ params = inspect.signature(fn).parameters.values()
415
+ except (TypeError, ValueError):
416
+ return True
417
+ if any(p.kind in (p.VAR_POSITIONAL,) for p in params):
418
+ return True
419
+ return len([p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]) >= 3
420
+
421
+
422
+ def search(data: Any, eval: Callable | None = None, budget: int = 20, strategy: str = "tpe",
423
+ domain: str | None = None, space: dict | None = None, diagnostics: bool = True, trials: str | None = None,
424
+ knob_space: dict | None = None, signals: bool | list[str] | tuple = True,
425
+ allow_unregistered: bool = False, *, agent: bool = False, goal: str = "", harness: Any = None,
426
+ offline: bool = False, allow_code: bool = False, objective: Callable | None = None, **opts):
427
+ """Search curation recipes against your eval; returns a ``SearchResult`` (``.best_recipe``, ``.guard``, ``.trials``).
428
+
429
+ The building block under ``pc.curate(ds, eval=...)``, which runs this with the guard on and applies the pick. Use
430
+ it directly to inspect trials, run several searches, or choose your own strategy / incumbent / guard:
431
+
432
+ res = pc.search(ds, train_eval, budget=20) # TPE: no LLM, no API key
433
+ res = pc.search(ds, train_eval, budget=20, guard="auto", incumbent="default") # + the guard, as pc.curate does
434
+ cur = pc.curate(ds, recipe=res.best_recipe)
435
+
436
+ ``eval(recipe, ids)`` or ``eval(recipe, ids, device)`` returns a float or a dict with ``score`` (higher is better)
437
+ and optional metrics / ``cost``; return held-out scores as ``heldout`` / ``val`` (never shown to a proposer; the
438
+ guard compares on them). ``objective=`` is the deprecated keyword name of ``eval``.
439
+
440
+ ``strategy``: ``"tpe"`` (default; Bayesian optimisation with optuna, no LLM), ``"random"``, or (advanced; prefer
441
+ ``agent=True``) the LLM proposers ``"agent"`` (writes recipes from the columns, statistics and every previous
442
+ trial) and ``"agent+diag"`` (also sees :func:`diagnose`). A bare ``strategy="agent"`` runs no discovery, no
443
+ default warm start and no guard.
444
+
445
+ ``agent=True`` is agent mode in one switch: (1) the domain's recommended signals are computed; (2) an agent
446
+ surveys the literature (web access required, else pass ``offline=True``) and proposes new signals for ``goal``,
447
+ written to disk for review and run only with ``allow_code=True``; (3) the agent proposes recipes
448
+ (``strategy="agent+diag"`` unless you pass ``"agent"``), starting from the domain's default recipe as the first
449
+ trial. The harness is ``harness=`` (a name or a :class:`~pycurate.harness.Harness`), else
450
+ ``PYCURATE_HARNESS``, else auto-detected (``ANTHROPIC_API_KEY``, ``claude`` CLI, ``OPENAI_API_KEY``, ``codex``
451
+ CLI); the same harness runs discovery and the recipe agent. ``res.discovery`` is the discovery report.
452
+
453
+ The guard (on by default in agent mode; ``guard="auto", incumbent="default"`` turns it on for any strategy): the
454
+ pick is returned only if it beats the incumbent (``"default"`` = the domain default recipe, or a recipe / dict) on
455
+ a validation metric the proposer never sees, else ``res.best`` is the incumbent; ``res.guard`` records the
456
+ decision and both scores, ``res.search_best`` is the best by search score. Without such a metric the pick stands
457
+ and one warning says so. ``guard=None`` turns it off, ``guard="<metric>"`` / ``guard_margin=`` tune it.
458
+ ``subpool=N`` runs every trial on the ``id % N = 0`` sub-pool (a ratio-matched proxy; ``N`` from
459
+ ``pc.research.matched_subpool``); apply ``res.best_recipe_full_pool`` to the full pool.
460
+ ``knob_space`` (random / TPE) defaults to :func:`default_knob_space` of the domain's search space. ``trials``:
461
+ a ``.jsonl`` path or ``"<lancedb uri>::<table>"`` shared by concurrent workers (default: a temporary file;
462
+ ``res.trials_path``). Domain signals are computed first, as in :func:`curate` (``signals=False`` to skip). Other
463
+ keyword arguments go to :func:`pycurate.research.run_search` (e.g. ``devices=[0, 1]``, ``seed``,
464
+ ``warm_start``, ``task``) or, if it does not take them, to the domain's ``prepare`` / ``diagnose``.
465
+
466
+ The search only sees registered signal columns (catalog spec + provenance, or raw ingested data) and never a
467
+ protected one (:mod:`pycurate.signals`); an unregistered column raises ``SignalGateError`` unless
468
+ ``allow_unregistered=True``, which is recorded with every trial and flagged in the result.
469
+ """
470
+ import inspect
471
+
472
+ from .research import run_search
473
+
474
+ from .research.proposers import AgentProposer, make_proposer
475
+
476
+ if objective is not None:
477
+ import warnings
478
+
479
+ warnings.warn("pc.search(objective=) is deprecated; pass eval= (or the eval as the second argument)",
480
+ DeprecationWarning, stacklevel=2)
481
+ eval = eval or objective
482
+ if eval is None:
483
+ raise TypeError("pc.search needs eval: a function (recipe, ids) -> score, see help(pc.search)")
484
+ if agent: # the default (or "tpe") gives way to the agent; another explicit non-agent strategy is a conflict
485
+ if strategy not in (None, "tpe", "agent", "agent+diag", "agent_diag"):
486
+ raise ValueError(f"agent=True runs the LLM recipe agent, but strategy={strategy!r} was also given; drop "
487
+ "one of them (strategy='agent' / 'agent+diag' is allowed with agent=True)")
488
+ strategy = AGENT_STRATEGY if strategy in (None, "tpe") else strategy
489
+ strategy = strategy or "tpe" # None (from pc.curate) = the default
490
+ default_keep = opts.pop("_default_keep", None) # pc.curate(keep=): the incumbent default's keep
491
+
492
+ accepted = {*inspect.signature(run_search).parameters, *inspect.signature(make_proposer).parameters,
493
+ *inspect.signature(AgentProposer).parameters} - {"devices", "kw", "proposer_kw", "space"}
494
+ run_opts = {k: v for k, v in opts.items() if k in accepted}
495
+ dom_opts = {k: v for k, v in opts.items() if k not in run_opts}
496
+ devices = opts.get("devices")
497
+ if isinstance(devices, (str, int)): # "cpu" / "auto" / one GPU index: one worker
498
+ devices = [devices if devices != "auto" else None]
499
+ ds = load(data)
500
+ mod = _domain(ds, domain)
501
+ prep = default = None
502
+ if agent:
503
+ prep, harness = _agent_prepare(ds, mod, signals, goal, harness, offline, allow_code, dom_opts,
504
+ discover_opts=run_opts.get("discover_opts"))
505
+ else:
506
+ _prepare(mod, ds, signals, dom_opts)
507
+ inc = run_opts.get("incumbent")
508
+ if isinstance(inc, str) and inc != "default":
509
+ raise ValueError(f"incumbent={inc!r}: pass 'default' (the domain default recipe), a Recipe or a recipe dict")
510
+ if agent or inc == "default":
511
+ default = mod.default_recipe(ds, keep=default_keep, **dom_opts)
512
+ if default_keep is not None and default.keep is None:
513
+ default.keep = default_keep
514
+ if inc is None or inc == "default":
515
+ run_opts["incumbent"] = default.to_dict() # the pick must beat it on held-out (guard=)
516
+ if agent:
517
+ if "warm_start" not in run_opts: # the established default is the first trial: the search is checked against it
518
+ run_opts["warm_start"] = [{**default.to_dict(), "rationale": "domain default"}]
519
+ run_opts.setdefault("guard", "auto")
520
+ if harness is not None:
521
+ run_opts["harness"] = harness
522
+ from .signals.gate import gate, gate_knobs, gate_space
523
+
524
+ from .signals.adoption import extend_space
525
+
526
+ g = gate(ds, allow_unregistered, opts.get("allowed_columns"), opts.get("exclude_columns"))
527
+ space = gate_space(ds, space or mod.search_space(ds), allow_unregistered, g=g)
528
+ if opts.get("extend_signals", True): # survey-new / discovered signal columns the domain space does not list
529
+ space = extend_space(ds, space, g)
530
+ if strategy in ("random", "tpe") and knob_space is None:
531
+ knob_space = default_knob_space(space)
532
+ elif knob_space is not None:
533
+ knob_space = gate_knobs(knob_space, g)
534
+ context = mod.diagnose(ds, **dom_opts).to_dict() if diagnostics and strategy in ("agent+diag", "agent_diag") \
535
+ else None
536
+ fn = eval if _accepts_device(eval) else (lambda recipe, ids, device: eval(recipe, ids))
537
+ res = run_search(ds, fn, budget=budget, strategy=strategy, space=space, knob_space=knob_space, context=context,
538
+ trials=trials, devices=devices, allow_unregistered=allow_unregistered, **run_opts)
539
+ if prep is not None:
540
+ res.discovery = prep.discovery
541
+ res.default_recipe = default # the domain default Recipe when it was the incumbent, else None
542
+ return res
543
+
544
+
545
+ @dataclass
546
+ class Prepared:
547
+ """The result of :func:`prepare`: what was computed, discovered and registered on the dataset."""
548
+
549
+ dataset: Dataset
550
+ domain: str
551
+ computed: list
552
+ registered_base: list
553
+ discovery: Any = None
554
+ protected: list = field(default_factory=list)
555
+ skipped: dict = field(default_factory=dict) # recommended survey signals not computed here, with the reason
556
+
557
+ def columns(self):
558
+ """Every column with its search status and source (``pc.signals.describe``)."""
559
+ from .signals import describe
560
+
561
+ return describe(self.dataset)
562
+
563
+ def __repr__(self) -> str:
564
+ disc = "" if self.discovery is None else f", discovered={self.discovery.accepted}" + \
565
+ (" [OFFLINE]" if self.discovery.offline else "")
566
+ skip = f", skipped={sorted(self.skipped)}" if self.skipped else ""
567
+ return (f"Prepared({self.dataset.name!r}, domain={self.domain!r}, computed={self.computed}{disc}, "
568
+ f"protected={self.protected}{skip})")
569
+
570
+
571
+ def prepare(data: Any, domain: str | None = None, discover: bool = False, goal: str = "", harness: Any = None,
572
+ offline: bool = False, allow_code: bool = False, approve: Callable | None = None, budget: int = 3,
573
+ signals: Any = "recommended", protect: list[str] | tuple = (), discover_opts: dict | None = None,
574
+ **opts) -> Prepared:
575
+ """The standard pipeline step: compute the domain's recommended catalog signals (zero-copy columns with
576
+ provenance), optionally discover new ones, and register everything so a search can use it.
577
+
578
+ 1. ``signals``: ``"recommended"`` (default; what :func:`curate` computes), a list of catalog names, or ``None``;
579
+ 2. ``protect``: oracle / eval / label columns to protect (in addition to the defaults, e.g. ``clean_label``);
580
+ 3. ``discover=True``: :func:`discover_signals` with the mandatory literature step on the configured harness
581
+ (``harness=`` / ``PYCURATE_HARNESS``; ``offline=True`` skips the survey and is recorded); running the
582
+ proposed code needs ``allow_code=True`` or ``approve=fn``;
583
+ 4. raw ingested columns are registered as base data.
584
+
585
+ prep = pc.prepare(ds) # recommended signals only
586
+ prep = pc.prepare(ds, discover=True, goal="...", allow_code=True) # benchmarks: + agentic discovery
587
+ """
588
+ from . import signals as S
589
+
590
+ ds = load(data)
591
+ dom = domain or detect(ds.schema)
592
+ if protect:
593
+ S.protect(ds, list(protect))
594
+ rep: dict = {}
595
+ computed = S.add_signals(ds, signals, domain=dom, report=rep, **opts) if signals else []
596
+ disc = None
597
+ if discover:
598
+ from .signals.discover import discover_signals
599
+
600
+ disc = discover_signals(ds, domain=dom, goal=goal, budget=budget, harness=harness, offline=offline,
601
+ allow_code=allow_code, approve=approve, **(discover_opts or {}))
602
+ try:
603
+ base = S.register_base(ds)
604
+ except ValueError as e: # version 1 cleaned up: raw columns must be declared explicitly
605
+ import logging
606
+
607
+ logging.getLogger("pycurate").warning("pycurate.prepare: %s", e)
608
+ base = []
609
+ return Prepared(ds, dom, computed, base, disc, sorted(S.protected_columns(ds)), rep.get("skipped", {}))
610
+
611
+
612
+ curate.prepare = prepare # ``pc.curate.prepare(ds, ...)``: the same entry point, next to the call it prepares for