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,160 @@
1
+ """The exploration engine — a resumable, validate-before-keep stage runner.
2
+
3
+ ``explore(session, out=...)`` walks an ordered list of :class:`Stage` objects. Each
4
+ stage reads/writes artifacts on the :class:`ProfilePack`. The engine:
5
+
6
+ - **skips** stages already done (artifacts present) unless ``force`` or the corpus
7
+ fingerprint changed (drift);
8
+ - runs a stage, then calls its ``validate`` gate — a stage whose output does not beat
9
+ baseline is recorded as ``rejected`` and its artifacts are *not* trusted downstream
10
+ (the honesty rule: keep only tunings that help);
11
+ - records status/timing/summary for every stage in the manifest, and never lets one
12
+ stage's failure abort the rest.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import abc
18
+ import hashlib
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Optional
22
+
23
+ from .pack import ProfilePack
24
+
25
+
26
+ @dataclass
27
+ class ExploreContext:
28
+ """Everything a stage needs: the live Session, the pack to write to, and config."""
29
+
30
+ session: Any # search_as_code.Session
31
+ pack: ProfilePack
32
+ config: dict = field(default_factory=dict)
33
+
34
+ @property
35
+ def store(self):
36
+ return self.session.store
37
+
38
+ @property
39
+ def embedder(self):
40
+ return self.session.embedder
41
+
42
+ @property
43
+ def generator(self):
44
+ return self.session.generator
45
+
46
+ def cfg(self, key: str, default: Any = None) -> Any:
47
+ return self.config.get(key, default)
48
+
49
+
50
+ class Stage(abc.ABC):
51
+ """One unit of exploration. Subclasses declare what they produce/require."""
52
+
53
+ name: str = "stage"
54
+ produces: list[str] = [] # artifact filenames written on success
55
+ requires: list[str] = [] # names of stages that must be ``ok`` first
56
+
57
+ @abc.abstractmethod
58
+ def run(self, ctx: ExploreContext) -> dict:
59
+ """Do the work, write artifacts, return a small summary dict for the manifest."""
60
+
61
+ def validate(self, ctx: ExploreContext, summary: dict) -> tuple[bool, str]:
62
+ """Gate: return (keep, reason). Default keeps everything. Learning stages
63
+ override this to reject output that does not beat baseline."""
64
+ return True, ""
65
+
66
+
67
+ def corpus_fingerprint(store, k: int = 12) -> str:
68
+ """Cheap, stable signature of the corpus so we can detect drift. Combines the
69
+ doc count with a hash of a small deterministic-ish text sample."""
70
+ try:
71
+ count = store.count()
72
+ except Exception:
73
+ count = -1
74
+ texts = []
75
+ try:
76
+ for d in store.sample(k):
77
+ texts.append((d.text or "")[:120])
78
+ except Exception:
79
+ pass
80
+ h = hashlib.sha1(("|".join(sorted(texts))).encode("utf-8", "ignore")).hexdigest()[:12]
81
+ return f"n{count}-{h}"
82
+
83
+
84
+ def explore(session, out: str, stages: Optional[list[Stage]] = None, *,
85
+ force: bool = False, config: Optional[dict] = None) -> ProfilePack:
86
+ """Run the exploration pipeline over ``session``'s corpus into a ProfilePack at ``out``.
87
+
88
+ Parameters
89
+ ----------
90
+ session : Session the bound corpus (store + embedder + optional generator).
91
+ out : str directory for the ProfilePack (created if absent).
92
+ stages : list ordered Stage instances; defaults to :func:`default_pipeline`.
93
+ force : bool re-run every stage even if its artifacts exist.
94
+ config : dict knobs passed through to stages (sample sizes, k, etc.).
95
+ """
96
+ pack = ProfilePack.open(out)
97
+ ctx = ExploreContext(session=session, pack=pack, config=config or {})
98
+
99
+ fp = corpus_fingerprint(session.store)
100
+ drift = pack.fingerprint_changed(fp)
101
+ pack.set_fingerprint(fp)
102
+ if stages is None:
103
+ stages = default_pipeline()
104
+
105
+ done: set[str] = set()
106
+ for stage in stages:
107
+ missing = [r for r in stage.requires if r not in done]
108
+ if missing:
109
+ pack.record_stage(stage.name, "skipped",
110
+ note=f"unmet requires: {', '.join(missing)}")
111
+ continue
112
+ if pack.is_done(stage.name) and not force and not drift:
113
+ done.add(stage.name)
114
+ continue
115
+
116
+ t0 = time.time()
117
+ try:
118
+ summary = stage.run(ctx)
119
+ keep, reason = stage.validate(ctx, summary)
120
+ secs = time.time() - t0
121
+ if keep:
122
+ pack.record_stage(stage.name, "ok", seconds=secs, summary=summary,
123
+ artifacts=list(stage.produces), note=reason)
124
+ done.add(stage.name)
125
+ else:
126
+ pack.record_stage(stage.name, "rejected", seconds=secs, summary=summary,
127
+ note=reason or "did not beat baseline")
128
+ except NotImplementedError as e:
129
+ pack.record_stage(stage.name, "planned", seconds=time.time() - t0, note=str(e))
130
+ except Exception as e: # never let one stage abort the pipeline
131
+ pack.record_stage(stage.name, "error", seconds=time.time() - t0,
132
+ note=f"{type(e).__name__}: {e}")
133
+ return pack
134
+
135
+
136
+ def default_pipeline() -> list[Stage]:
137
+ """The full seven-stage pipeline. Stages not yet implemented raise
138
+ NotImplementedError and are recorded as ``planned`` — the pipeline still runs
139
+ end-to-end and the pack shows the roadmap."""
140
+ from .stages import (
141
+ CodegenStage,
142
+ CrossDocStage,
143
+ OntologyStage,
144
+ ProfileStage,
145
+ RouterStage,
146
+ SampleStage,
147
+ SynthesizeStage,
148
+ ValidateStage,
149
+ )
150
+
151
+ return [
152
+ SampleStage(),
153
+ ProfileStage(),
154
+ OntologyStage(),
155
+ CrossDocStage(),
156
+ SynthesizeStage(),
157
+ RouterStage(),
158
+ CodegenStage(),
159
+ ValidateStage(),
160
+ ]
@@ -0,0 +1,127 @@
1
+ """ProfilePack — the on-disk artifact produced by ``sac.explore``.
2
+
3
+ A ProfilePack is a *versioned directory* holding everything the exploration phase
4
+ learns about a corpus (schema, content profile, ontology, synthetic queries, a
5
+ primitive router, few-shots/templates, prompt overrides). A ``Session`` loads it
6
+ at query time to tune retrieval to the data — and works fine without it.
7
+
8
+ Design goals (robustness):
9
+ - **Everything serializes here** — nothing the explorer learns is implicit.
10
+ - **Resumable** — each stage writes its own artifact; a crash re-runs only what's missing.
11
+ - **Drift-aware** — the manifest stores a corpus *fingerprint*; when the data changes
12
+ enough, stages are re-run.
13
+ - **Self-describing** — the manifest records every stage's status/timestamp/summary so a
14
+ human (or agent) can see exactly what was built and what was rejected.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any, Optional
23
+
24
+ SCHEMA_VERSION = 1
25
+ MANIFEST_NAME = "manifest.json"
26
+
27
+
28
+ class ProfilePack:
29
+ """A directory of exploration artifacts + a manifest tracking stage status."""
30
+
31
+ def __init__(self, root: Path, manifest: dict):
32
+ self.root = root
33
+ self.manifest = manifest
34
+
35
+ # ---- lifecycle -------------------------------------------------------
36
+ @classmethod
37
+ def open(cls, path: str | Path) -> "ProfilePack":
38
+ """Open an existing pack or initialise a fresh one at ``path``."""
39
+ root = Path(path)
40
+ root.mkdir(parents=True, exist_ok=True)
41
+ mpath = root / MANIFEST_NAME
42
+ if mpath.exists():
43
+ manifest = json.loads(mpath.read_text())
44
+ else:
45
+ manifest = {
46
+ "schema_version": SCHEMA_VERSION,
47
+ "created": time.time(),
48
+ "fingerprint": None,
49
+ "stages": {}, # name -> {status, ts, seconds, summary, artifacts, note}
50
+ }
51
+ pack = cls(root, manifest)
52
+ pack.save_manifest()
53
+ return pack
54
+
55
+ def save_manifest(self) -> None:
56
+ self.manifest["updated"] = time.time()
57
+ (self.root / MANIFEST_NAME).write_text(json.dumps(self.manifest, indent=2, default=str))
58
+
59
+ # ---- artifacts -------------------------------------------------------
60
+ def path(self, name: str) -> Path:
61
+ return self.root / name
62
+
63
+ def has(self, name: str) -> bool:
64
+ return (self.root / name).exists()
65
+
66
+ def write_json(self, name: str, obj: Any) -> None:
67
+ self.path(name).write_text(json.dumps(obj, indent=2, default=str))
68
+
69
+ def read_json(self, name: str, default: Any = None) -> Any:
70
+ p = self.path(name)
71
+ return json.loads(p.read_text()) if p.exists() else default
72
+
73
+ def write_jsonl(self, name: str, rows: list) -> None:
74
+ with self.path(name).open("w") as f:
75
+ for r in rows:
76
+ f.write(json.dumps(r, default=str) + "\n")
77
+
78
+ def read_jsonl(self, name: str) -> list:
79
+ p = self.path(name)
80
+ if not p.exists():
81
+ return []
82
+ return [json.loads(ln) for ln in p.read_text().splitlines() if ln.strip()]
83
+
84
+ # ---- stage bookkeeping ----------------------------------------------
85
+ def stage(self, name: str) -> dict:
86
+ return self.manifest["stages"].get(name, {})
87
+
88
+ def stage_status(self, name: str) -> Optional[str]:
89
+ return self.stage(name).get("status")
90
+
91
+ def is_done(self, name: str) -> bool:
92
+ """A stage counts as done if it succeeded and its artifacts still exist."""
93
+ st = self.stage(name)
94
+ if st.get("status") != "ok":
95
+ return False
96
+ return all(self.has(a) for a in st.get("artifacts", []))
97
+
98
+ def record_stage(self, name: str, status: str, *, seconds: float = 0.0,
99
+ summary: Optional[dict] = None, artifacts: Optional[list[str]] = None,
100
+ note: str = "") -> None:
101
+ self.manifest["stages"][name] = {
102
+ "status": status, # ok | rejected | error | planned | skipped
103
+ "ts": time.time(),
104
+ "seconds": round(seconds, 3),
105
+ "summary": summary or {},
106
+ "artifacts": artifacts or [],
107
+ "note": note,
108
+ }
109
+ self.save_manifest()
110
+
111
+ # ---- drift -----------------------------------------------------------
112
+ def set_fingerprint(self, fp: str) -> None:
113
+ self.manifest["fingerprint"] = fp
114
+ self.save_manifest()
115
+
116
+ def fingerprint_changed(self, fp: str) -> bool:
117
+ prev = self.manifest.get("fingerprint")
118
+ return prev is not None and prev != fp
119
+
120
+ # ---- summary ---------------------------------------------------------
121
+ def report(self) -> str:
122
+ lines = [f"ProfilePack {self.root} (schema v{self.manifest.get('schema_version')})"]
123
+ for name, st in self.manifest["stages"].items():
124
+ summ = ", ".join(f"{k}={v}" for k, v in (st.get("summary") or {}).items())
125
+ lines.append(f" [{st.get('status','?'):8}] {name:14} {summ}"
126
+ + (f" # {st['note']}" if st.get("note") else ""))
127
+ return "\n".join(lines)
@@ -0,0 +1,72 @@
1
+ """CSV export for a ProfilePack — flat tables for spreadsheets / quick review.
2
+
3
+ Writes up to three CSVs next to the pack (or a chosen dir):
4
+ - ``stages.csv`` one row per pipeline stage (status/timing/summary)
5
+ - ``clusters.csv`` one row per sample cluster (size, content-type mix, LLM profile)
6
+ - ``documents.csv`` one row per sampled document (cluster, content_type, snippet)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ from collections import Counter
13
+ from pathlib import Path
14
+
15
+ from ..primitives import content_type
16
+ from .pack import ProfilePack
17
+
18
+
19
+ def write_csv_report(pack: ProfilePack, out_dir: str | None = None) -> dict[str, str]:
20
+ """Write the CSV tables; return {name: path}. Missing artifacts are skipped."""
21
+ out = Path(out_dir) if out_dir else pack.root
22
+ out.mkdir(parents=True, exist_ok=True)
23
+ written: dict[str, str] = {}
24
+
25
+ # ---- stages.csv ------------------------------------------------------
26
+ spath = out / "stages.csv"
27
+ with spath.open("w", newline="") as f:
28
+ w = csv.writer(f)
29
+ w.writerow(["stage", "status", "seconds", "summary", "artifacts", "note"])
30
+ for name, st in pack.manifest.get("stages", {}).items():
31
+ summary = "; ".join(f"{k}={v}" for k, v in (st.get("summary") or {}).items())
32
+ w.writerow([name, st.get("status", ""), st.get("seconds", ""),
33
+ summary, " ".join(st.get("artifacts", [])), st.get("note", "")])
34
+ written["stages"] = str(spath)
35
+
36
+ sample = pack.read_jsonl("sample.jsonl")
37
+ profile = pack.read_json("content_profile.json") or {}
38
+ llm_by_cluster = profile.get("llm_by_cluster") or {}
39
+ sizes = (pack.read_json("sample_meta.json") or {}).get("cluster_sizes", {})
40
+
41
+ if sample:
42
+ # ---- documents.csv ----------------------------------------------
43
+ dpath = out / "documents.csv"
44
+ with dpath.open("w", newline="") as f:
45
+ w = csv.writer(f)
46
+ w.writerow(["id", "cluster", "content_type", "chars", "snippet"])
47
+ for r in sample:
48
+ text = r.get("text") or ""
49
+ snippet = " ".join(text.split())[:200]
50
+ w.writerow([r.get("id"), r.get("cluster"),
51
+ content_type(text), len(text), snippet])
52
+ written["documents"] = str(dpath)
53
+
54
+ # ---- clusters.csv -----------------------------------------------
55
+ by_cluster: dict = {}
56
+ for r in sample:
57
+ by_cluster.setdefault(r.get("cluster"), []).append(r)
58
+ cpath = out / "clusters.csv"
59
+ with cpath.open("w", newline="") as f:
60
+ w = csv.writer(f)
61
+ w.writerow(["cluster", "pool_size", "n_sampled", "content_types",
62
+ "llm_profile", "example_snippet"])
63
+ for c in sorted(by_cluster, key=lambda x: (x is None, x)):
64
+ rows = by_cluster[c]
65
+ cts = Counter(content_type(r.get("text") or "") for r in rows)
66
+ ct_str = ", ".join(f"{k}:{v}" for k, v in cts.most_common())
67
+ example = " ".join((rows[0].get("text") or "").split())[:160]
68
+ w.writerow([c, sizes.get(str(c), ""), len(rows), ct_str,
69
+ (llm_by_cluster.get(str(c)) or "").strip(), example])
70
+ written["clusters"] = str(cpath)
71
+
72
+ return written