awembed 0.1.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.
- awembed/__init__.py +12 -0
- awembed/capture.py +376 -0
- awembed/cli.py +97 -0
- awembed/corpus.py +392 -0
- awembed/distill.py +299 -0
- awembed/evaluate.py +349 -0
- awembed/probe.py +158 -0
- awembed/quantize.py +304 -0
- awembed/teacher.py +226 -0
- awembed-0.1.0.dist-info/METADATA +126 -0
- awembed-0.1.0.dist-info/RECORD +15 -0
- awembed-0.1.0.dist-info/WHEEL +5 -0
- awembed-0.1.0.dist-info/entry_points.txt +2 -0
- awembed-0.1.0.dist-info/licenses/LICENSE +202 -0
- awembed-0.1.0.dist-info/top_level.txt +1 -0
awembed/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""awembed -- Aither World Embed.
|
|
2
|
+
|
|
3
|
+
Train a small embedding model that knows your corpus: capture a large teacher's
|
|
4
|
+
judgement, distill it into a student together with your own labels, quantize with
|
|
5
|
+
a fidelity gate, and evaluate on a split that holds out whole directories. Every
|
|
6
|
+
stage is a standalone module with a `--self-test` and a gate that refuses an
|
|
7
|
+
artifact that is not fit to hand on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
awembed/capture.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
r"""Stage 1 of the code-search embedder distillation: capture TEACHER targets.
|
|
3
|
+
|
|
4
|
+
Encodes every unique query and document in the corpus through the NV-Embed-v2
|
|
5
|
+
teacher (the teacher module, /v1/embeddings, dim 4096, L2-normalized) and
|
|
6
|
+
writes, into --out:
|
|
7
|
+
|
|
8
|
+
teacher/queries/shard_NNNNN.safetensors fp16 [n, 4096] (+ .json sidecar)
|
|
9
|
+
teacher/docs/shard_NNNNN.safetensors
|
|
10
|
+
teacher/queries_index.json, teacher/docs_index.json position -> text
|
|
11
|
+
teacher_manifest.json dim, counts, corpus sha, model ids
|
|
12
|
+
pairs.jsonl one row per corpus row: query, positive, 3 negatives (TEXT),
|
|
13
|
+
split, kind, and the teacher's cosine scores t_pos / t_negs
|
|
14
|
+
|
|
15
|
+
Those scores are what Stage 2 distills (Margin-MSE on teacher score margins,
|
|
16
|
+
which is dim-agnostic — the student is dim 1024). The raw vectors are kept so
|
|
17
|
+
re-weighting a loss never re-captures, and so Stage 4 can report the
|
|
18
|
+
teacher's own retrieval numbers as the ceiling.
|
|
19
|
+
|
|
20
|
+
RESUMABLE, like k3_distill_capture.py: one checkpoint per shard, written
|
|
21
|
+
atomically; a shard whose sidecar sha matches its texts is skipped on rerun.
|
|
22
|
+
|
|
23
|
+
The teacher is CC-BY-NC-4.0 — its vectors and scores are INTERNAL training
|
|
24
|
+
targets and never ship. Only the Apache-2.0 student does.
|
|
25
|
+
|
|
26
|
+
Refuses (exit 1) a corpus without `hard_negative_summaries`: measured
|
|
27
|
+
2026-09-01, the shipped corpus had 2,265 negatives with no reading text, and
|
|
28
|
+
training against bare paths learns a shortcut instead of retrieval.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import math
|
|
37
|
+
import os
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
import urllib.error
|
|
43
|
+
import urllib.request
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
import numpy as np
|
|
47
|
+
|
|
48
|
+
DIM = 4096
|
|
49
|
+
QUERY_MODEL = "nv-embed-v2-query"
|
|
50
|
+
DOC_MODEL = "nv-embed-v2"
|
|
51
|
+
HERE = Path(__file__).resolve().parent
|
|
52
|
+
# The teacher server lives beside this file: `teacher.py` in the awembed package,
|
|
53
|
+
# `k3_teacher_serve.py` in a flat single-file deployment. Same bytes either way.
|
|
54
|
+
DEFAULT_TEACHER_SCRIPT = next(
|
|
55
|
+
(HERE / n for n in ("teacher.py", "k3_teacher_serve.py") if (HERE / n).exists()),
|
|
56
|
+
HERE / "teacher.py",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _log(msg: str) -> None:
|
|
61
|
+
print(f"[capture] {msg}", flush=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sha(texts: list[str]) -> str:
|
|
65
|
+
h = hashlib.sha256()
|
|
66
|
+
for t in texts:
|
|
67
|
+
h.update(t.encode("utf-8"))
|
|
68
|
+
h.update(b"\x00")
|
|
69
|
+
return h.hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _file_sha(path: Path) -> str:
|
|
73
|
+
h = hashlib.sha256()
|
|
74
|
+
with path.open("rb") as fh:
|
|
75
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
76
|
+
h.update(chunk)
|
|
77
|
+
return h.hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- teachers -------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
class HttpTeacher:
|
|
83
|
+
"""The real thing: the teacher module's OpenAI-compatible endpoint."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, url: str, batch: int = 64):
|
|
86
|
+
self.url = url.rstrip("/")
|
|
87
|
+
self.batch = min(batch, 256) # server caps a request at 256 inputs
|
|
88
|
+
|
|
89
|
+
def embed(self, texts: list[str], model: str) -> np.ndarray:
|
|
90
|
+
out = np.empty((len(texts), DIM), dtype=np.float32)
|
|
91
|
+
for i in range(0, len(texts), self.batch):
|
|
92
|
+
chunk = texts[i:i + self.batch]
|
|
93
|
+
body = json.dumps({"model": model, "input": chunk}).encode("utf-8")
|
|
94
|
+
req = urllib.request.Request(
|
|
95
|
+
f"{self.url}/v1/embeddings", data=body,
|
|
96
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
97
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
98
|
+
data = json.loads(resp.read())["data"]
|
|
99
|
+
for d in data:
|
|
100
|
+
out[i + d["index"]] = np.asarray(d["embedding"], dtype=np.float32)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FakeTeacher:
|
|
105
|
+
"""Deterministic stand-in for --self-test: sha256(text) seeds a unit vector.
|
|
106
|
+
|
|
107
|
+
Same text -> same vector, so the resume proof below is real; different
|
|
108
|
+
models get different vectors so a swapped alias is visible."""
|
|
109
|
+
|
|
110
|
+
def embed(self, texts: list[str], model: str) -> np.ndarray:
|
|
111
|
+
out = np.empty((len(texts), DIM), dtype=np.float32)
|
|
112
|
+
for i, t in enumerate(texts):
|
|
113
|
+
seed = int(hashlib.sha256(f"{model}|{t}".encode()).hexdigest()[:8], 16)
|
|
114
|
+
v = np.random.default_rng(seed).standard_normal(DIM).astype(np.float32)
|
|
115
|
+
out[i] = v / np.linalg.norm(v)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def make_teacher(url: str, batch: int):
|
|
120
|
+
return FakeTeacher() if url == "fake://" else HttpTeacher(url, batch)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- sharded, resumable encode ------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def encode_sharded(teacher, texts: list[str], model: str, out_dir: Path,
|
|
126
|
+
shard: int) -> np.ndarray:
|
|
127
|
+
from safetensors.numpy import load_file, save_file
|
|
128
|
+
|
|
129
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
vecs = np.empty((len(texts), DIM), dtype=np.float32)
|
|
131
|
+
n_shards = math.ceil(len(texts) / shard) if texts else 0
|
|
132
|
+
done = skipped = 0
|
|
133
|
+
for si in range(n_shards):
|
|
134
|
+
lo, hi = si * shard, min((si + 1) * shard, len(texts))
|
|
135
|
+
chunk = texts[lo:hi]
|
|
136
|
+
st = out_dir / f"shard_{si:05d}.safetensors"
|
|
137
|
+
side = out_dir / f"shard_{si:05d}.json"
|
|
138
|
+
want = {"lo": lo, "hi": hi, "sha": _sha(chunk), "model": model, "dim": DIM}
|
|
139
|
+
if st.exists() and side.exists():
|
|
140
|
+
try:
|
|
141
|
+
if json.loads(side.read_text(encoding="utf-8")) == want:
|
|
142
|
+
vecs[lo:hi] = load_file(str(st))["emb"].astype(np.float32)
|
|
143
|
+
skipped += 1
|
|
144
|
+
continue
|
|
145
|
+
except Exception: # noqa: BLE001 — a checkpoint that cannot be READ is work not done
|
|
146
|
+
# safetensors raises its own SafetensorError on a torn file
|
|
147
|
+
# (found by --self-test); whatever the reason, recompute.
|
|
148
|
+
_log(f"shard {si} unreadable — recomputing")
|
|
149
|
+
emb = teacher.embed(chunk, model)
|
|
150
|
+
if emb.shape != (hi - lo, DIM) or not np.isfinite(emb).all():
|
|
151
|
+
raise SystemExit(f"[FAIL] teacher returned shape {emb.shape} / non-finite "
|
|
152
|
+
f"for shard {si} ({model})")
|
|
153
|
+
# atomic: tmp -> replace, so a crash mid-write never leaves a torn shard
|
|
154
|
+
# The fp16 SHARD is the source of truth: round-trip through it before
|
|
155
|
+
# scoring, so a first run and a resumed run compute identical scores
|
|
156
|
+
# (found by --self-test: fp32-then-fp16 differed in the 4th decimal).
|
|
157
|
+
emb16 = emb.astype(np.float16)
|
|
158
|
+
tmp = st.with_suffix(".tmp")
|
|
159
|
+
save_file({"emb": emb16}, str(tmp))
|
|
160
|
+
os.replace(tmp, st)
|
|
161
|
+
side.write_bytes(json.dumps(want).encode("utf-8"))
|
|
162
|
+
vecs[lo:hi] = emb16.astype(np.float32)
|
|
163
|
+
done += 1
|
|
164
|
+
_log(f"{model}: {len(texts)} texts in {n_shards} shards "
|
|
165
|
+
f"({done} encoded, {skipped} resumed)")
|
|
166
|
+
return vecs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- teacher lifecycle -----------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _health(url: str) -> tuple[int, str]:
|
|
172
|
+
try:
|
|
173
|
+
with urllib.request.urlopen(f"{url}/health", timeout=10) as r:
|
|
174
|
+
return r.status, r.read().decode("utf-8", "replace")
|
|
175
|
+
except urllib.error.HTTPError as e:
|
|
176
|
+
return e.code, e.read().decode("utf-8", "replace")
|
|
177
|
+
except (urllib.error.URLError, OSError) as e:
|
|
178
|
+
return 0, str(e)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def spawn_teacher(script: Path, port: int, timeout_s: int) -> subprocess.Popen:
|
|
182
|
+
"""Start the teacher server and block until /health is 200.
|
|
183
|
+
|
|
184
|
+
500 = the model load FAILED (the server reports it rather than swallowing
|
|
185
|
+
it) — fail fast with the body instead of waiting out the timeout."""
|
|
186
|
+
if not script.exists():
|
|
187
|
+
raise SystemExit(f"[FAIL] teacher script missing: {script}")
|
|
188
|
+
env = dict(os.environ, NV_EMBED_PORT=str(port))
|
|
189
|
+
# The teacher pins transformers==4.42.4 (NV-Embed's remote code breaks on
|
|
190
|
+
# newer Cache APIs: 4.55 and 4.57 both raise DynamicCache.get_usable_length)
|
|
191
|
+
# while the student needs >=4.51 for Qwen3 -- so the teacher may live in its
|
|
192
|
+
# own venv. NV_EMBED_PYTHON names that interpreter; unset = this one.
|
|
193
|
+
python = os.environ.get("NV_EMBED_PYTHON") or sys.executable
|
|
194
|
+
proc = subprocess.Popen([python, str(script)], env=env)
|
|
195
|
+
url = f"http://127.0.0.1:{port}"
|
|
196
|
+
t0 = time.time()
|
|
197
|
+
while time.time() - t0 < timeout_s:
|
|
198
|
+
if proc.poll() is not None:
|
|
199
|
+
raise SystemExit(f"[FAIL] teacher exited early rc={proc.returncode}")
|
|
200
|
+
code, body = _health(url)
|
|
201
|
+
if code == 200:
|
|
202
|
+
_log(f"teacher up at {url} after {time.time() - t0:.0f}s")
|
|
203
|
+
return proc
|
|
204
|
+
if code == 500:
|
|
205
|
+
proc.terminate()
|
|
206
|
+
raise SystemExit(f"[FAIL] teacher model load failed: {body[:400]}")
|
|
207
|
+
time.sleep(5)
|
|
208
|
+
proc.terminate()
|
|
209
|
+
raise SystemExit(f"[FAIL] teacher not healthy after {timeout_s}s")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- the stage ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
REQUIRED = ("query", "positive_summary", "hard_negatives", "hard_negative_summaries",
|
|
215
|
+
"split", "kind")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_corpus(path: Path) -> list[dict]:
|
|
219
|
+
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()
|
|
220
|
+
if line.strip()]
|
|
221
|
+
bad = [i for i, r in enumerate(rows) if any(k not in r for k in REQUIRED)
|
|
222
|
+
or len(r["hard_negative_summaries"]) != len(r["hard_negatives"])
|
|
223
|
+
or not all(r["hard_negative_summaries"])]
|
|
224
|
+
if bad:
|
|
225
|
+
raise SystemExit(
|
|
226
|
+
f"[FAIL] {len(bad)} corpus rows lack inline negative summaries (first: row "
|
|
227
|
+
f"{bad[0]}). Regenerate with the corpus stage — training against "
|
|
228
|
+
"path-only negatives learns a shortcut, not retrieval.")
|
|
229
|
+
return rows
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run(corpus_path: Path, out: Path, seed: int, teacher, shard: int) -> int:
|
|
233
|
+
rows = load_corpus(corpus_path)
|
|
234
|
+
queries = list(dict.fromkeys(r["query"] for r in rows))
|
|
235
|
+
docs = list(dict.fromkeys(
|
|
236
|
+
[r["positive_summary"] for r in rows]
|
|
237
|
+
+ [s for r in rows for s in r["hard_negative_summaries"]]))
|
|
238
|
+
qi = {t: i for i, t in enumerate(queries)}
|
|
239
|
+
di = {t: i for i, t in enumerate(docs)}
|
|
240
|
+
_log(f"{len(rows)} rows -> {len(queries)} unique queries, {len(docs)} unique docs")
|
|
241
|
+
|
|
242
|
+
tdir = out / "teacher"
|
|
243
|
+
tdir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
(tdir / "queries_index.json").write_bytes(json.dumps(queries).encode("utf-8"))
|
|
245
|
+
(tdir / "docs_index.json").write_bytes(json.dumps(docs).encode("utf-8"))
|
|
246
|
+
q_vecs = encode_sharded(teacher, queries, QUERY_MODEL, tdir / "queries", shard)
|
|
247
|
+
d_vecs = encode_sharded(teacher, docs, DOC_MODEL, tdir / "docs", shard)
|
|
248
|
+
|
|
249
|
+
pairs = out / "pairs.jsonl"
|
|
250
|
+
tmp = pairs.with_suffix(".tmp")
|
|
251
|
+
n_bad = 0
|
|
252
|
+
margins: list[float] = []
|
|
253
|
+
with tmp.open("wb") as fh:
|
|
254
|
+
for i, r in enumerate(rows):
|
|
255
|
+
q = q_vecs[qi[r["query"]]]
|
|
256
|
+
t_pos = float(q @ d_vecs[di[r["positive_summary"]]])
|
|
257
|
+
t_negs = [float(q @ d_vecs[di[s]]) for s in r["hard_negative_summaries"]]
|
|
258
|
+
if not all(math.isfinite(x) and -1.05 <= x <= 1.05 for x in [t_pos, *t_negs]):
|
|
259
|
+
n_bad += 1
|
|
260
|
+
margins.append(t_pos - sum(t_negs) / len(t_negs))
|
|
261
|
+
fh.write(json.dumps({
|
|
262
|
+
"i": i, "query": r["query"], "positive": r["positive_summary"],
|
|
263
|
+
"positive_dir": r.get("positive_dir", ""),
|
|
264
|
+
"negatives": r["hard_negative_summaries"],
|
|
265
|
+
"split": r["split"], "kind": r["kind"],
|
|
266
|
+
"t_pos": t_pos, "t_negs": t_negs,
|
|
267
|
+
}).encode("utf-8") + b"\n")
|
|
268
|
+
if n_bad:
|
|
269
|
+
tmp.unlink(missing_ok=True)
|
|
270
|
+
raise SystemExit(f"[FAIL] {n_bad} rows have non-finite / out-of-range teacher scores")
|
|
271
|
+
os.replace(tmp, pairs)
|
|
272
|
+
|
|
273
|
+
mean_margin = float(np.mean(margins))
|
|
274
|
+
manifest = {
|
|
275
|
+
"stage": "capture", "dim": DIM, "seed": seed,
|
|
276
|
+
"n_rows": len(rows), "n_queries": len(queries), "n_docs": len(docs),
|
|
277
|
+
"corpus": corpus_path.name, "corpus_sha256": _file_sha(corpus_path),
|
|
278
|
+
"query_model": QUERY_MODEL, "doc_model": DOC_MODEL,
|
|
279
|
+
"teacher": type(teacher).__name__,
|
|
280
|
+
"teacher_mean_margin_pos_minus_neg": mean_margin,
|
|
281
|
+
"created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
282
|
+
}
|
|
283
|
+
(out / "teacher_manifest.json").write_bytes(json.dumps(manifest, indent=2).encode())
|
|
284
|
+
_log(f"pairs.jsonl: {len(rows)} rows; teacher mean margin (pos - mean neg) = "
|
|
285
|
+
f"{mean_margin:+.4f}")
|
|
286
|
+
if isinstance(teacher, HttpTeacher) and mean_margin <= 0:
|
|
287
|
+
raise SystemExit("[FAIL] the teacher does not prefer positives over negatives on "
|
|
288
|
+
"average — its targets would teach the wrong thing")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _self_test() -> int:
|
|
293
|
+
"""Offline proof with the FakeTeacher: contract, atomic resume, the corpus gate."""
|
|
294
|
+
rows = []
|
|
295
|
+
for k in range(8):
|
|
296
|
+
rows.append({"query": f"where is thing {k}?", "positive_dir": f"d/{k}",
|
|
297
|
+
"positive_summary": f"summary of thing {k}",
|
|
298
|
+
"hard_negatives": [f"d/n{k}{j}" for j in range(3)],
|
|
299
|
+
"hard_negative_summaries": [f"neg {k}-{j}" for j in range(3)],
|
|
300
|
+
"split": "train" if k < 6 else "eval", "kind": "synthetic"})
|
|
301
|
+
with tempfile.TemporaryDirectory() as td:
|
|
302
|
+
tdp = Path(td)
|
|
303
|
+
corpus = tdp / "c.jsonl"
|
|
304
|
+
corpus.write_bytes(b"".join(json.dumps(r).encode() + b"\n" for r in rows))
|
|
305
|
+
out = tdp / "out"
|
|
306
|
+
assert run(corpus, out, 1, FakeTeacher(), shard=3) == 0
|
|
307
|
+
pairs = [json.loads(x) for x in (out / "pairs.jsonl").read_text().splitlines()]
|
|
308
|
+
assert len(pairs) == 8 and all(len(p["t_negs"]) == 3 for p in pairs)
|
|
309
|
+
assert all(-1 <= p["t_pos"] <= 1 for p in pairs)
|
|
310
|
+
# resume: damage ONE doc shard, rerun, and only that shard is rewritten
|
|
311
|
+
shards = sorted((out / "teacher" / "docs").glob("shard_*.safetensors"))
|
|
312
|
+
assert len(shards) >= 2, shards
|
|
313
|
+
before = {s: s.stat().st_mtime_ns for s in shards}
|
|
314
|
+
shards[1].write_bytes(b"torn")
|
|
315
|
+
time.sleep(0.02)
|
|
316
|
+
assert run(corpus, out, 1, FakeTeacher(), shard=3) == 0
|
|
317
|
+
after = {s: s.stat().st_mtime_ns for s in shards}
|
|
318
|
+
assert after[shards[1]] != before[shards[1]], "damaged shard was not recomputed"
|
|
319
|
+
assert after[shards[0]] == before[shards[0]], "intact shard was needlessly rewritten"
|
|
320
|
+
pairs2 = [json.loads(x) for x in (out / "pairs.jsonl").read_text().splitlines()]
|
|
321
|
+
assert [p["t_pos"] for p in pairs] == [p["t_pos"] for p in pairs2], "not deterministic"
|
|
322
|
+
# the corpus gate: a row without inline summaries must be refused
|
|
323
|
+
bad = dict(rows[0])
|
|
324
|
+
bad.pop("hard_negative_summaries")
|
|
325
|
+
(tdp / "bad.jsonl").write_bytes(json.dumps(bad).encode() + b"\n")
|
|
326
|
+
try:
|
|
327
|
+
run(tdp / "bad.jsonl", tdp / "out2", 1, FakeTeacher(), shard=3)
|
|
328
|
+
except SystemExit as e:
|
|
329
|
+
assert "inline negative summaries" in str(e), e
|
|
330
|
+
else:
|
|
331
|
+
raise AssertionError("a path-only corpus was accepted")
|
|
332
|
+
print("[ok] capture: contract, atomic resume, determinism, corpus gate — all proven")
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> int:
|
|
337
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
338
|
+
ap.add_argument("--corpus", help="corpus .jsonl (needs hard_negative_summaries)")
|
|
339
|
+
ap.add_argument("--seed", type=int, default=20260901)
|
|
340
|
+
ap.add_argument("--out", help="stage output root (shared by all stages)")
|
|
341
|
+
ap.add_argument("--teacher-url", default="",
|
|
342
|
+
help="running teacher; empty = spawn --teacher-script locally; "
|
|
343
|
+
"'fake://' = deterministic stub")
|
|
344
|
+
ap.add_argument("--teacher-script", default=str(DEFAULT_TEACHER_SCRIPT))
|
|
345
|
+
ap.add_argument("--port", type=int, default=8213)
|
|
346
|
+
ap.add_argument("--batch", type=int, default=64)
|
|
347
|
+
ap.add_argument("--shard", type=int, default=256)
|
|
348
|
+
ap.add_argument("--teacher-timeout", type=int, default=1800,
|
|
349
|
+
help="seconds to wait for the 16 GB teacher to download + load")
|
|
350
|
+
ap.add_argument("--self-test", action="store_true")
|
|
351
|
+
args = ap.parse_args()
|
|
352
|
+
if args.self_test:
|
|
353
|
+
return _self_test()
|
|
354
|
+
if not args.corpus or not args.out:
|
|
355
|
+
ap.error("--corpus and --out are required")
|
|
356
|
+
|
|
357
|
+
proc = None
|
|
358
|
+
url = args.teacher_url
|
|
359
|
+
try:
|
|
360
|
+
if not url:
|
|
361
|
+
proc = spawn_teacher(Path(args.teacher_script), args.port, args.teacher_timeout)
|
|
362
|
+
url = f"http://127.0.0.1:{args.port}"
|
|
363
|
+
return run(Path(args.corpus), Path(args.out), args.seed,
|
|
364
|
+
make_teacher(url, args.batch), args.shard)
|
|
365
|
+
finally:
|
|
366
|
+
if proc is not None:
|
|
367
|
+
proc.terminate()
|
|
368
|
+
try:
|
|
369
|
+
proc.wait(timeout=15)
|
|
370
|
+
except subprocess.TimeoutExpired:
|
|
371
|
+
proc.kill()
|
|
372
|
+
_log("teacher stopped — VRAM released for Stage 2")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
sys.exit(main())
|
awembed/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""awembed -- Aither World Embed: train an embedding model that knows YOUR corpus.
|
|
2
|
+
|
|
3
|
+
awembed corpus build question -> right-answer -> hard-negative rows from a repo
|
|
4
|
+
awembed teacher serve a large teacher embedder as an OpenAI-compatible endpoint
|
|
5
|
+
awembed probe prove the teacher loads and answers on THIS machine (before you rent)
|
|
6
|
+
awembed capture score every row with the teacher (its margins are the target)
|
|
7
|
+
awembed distill train the small student against the teacher + the corpus labels
|
|
8
|
+
awembed quantize weight-only int8 export with a fidelity gate
|
|
9
|
+
awembed eval teacher vs baseline vs student vs int8 on the held-out split
|
|
10
|
+
awembed run capture -> distill -> quantize -> eval, in order, one output root
|
|
11
|
+
|
|
12
|
+
Every stage is a standalone module with its own `--self-test` and its own gate that
|
|
13
|
+
exits non-zero when the artifact it produced is not fit to hand on. A run that
|
|
14
|
+
"completed" without learning is the failure this tool is built to refuse.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import importlib
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Sequence
|
|
22
|
+
|
|
23
|
+
STAGES = {
|
|
24
|
+
"corpus": ("awembed.corpus", "build the training corpus from a repository"),
|
|
25
|
+
"teacher": ("awembed.teacher", "serve the teacher embedder (OpenAI-compatible)"),
|
|
26
|
+
"probe": ("awembed.probe", "teacher load footprint + one forward on this machine"),
|
|
27
|
+
"capture": ("awembed.capture", "score the corpus with the teacher -> pairs.jsonl"),
|
|
28
|
+
"distill": ("awembed.distill", "train the student against teacher margins + labels"),
|
|
29
|
+
"quantize": ("awembed.quantize", "weight-only int8 export, fidelity-gated"),
|
|
30
|
+
"eval": ("awembed.evaluate", "teacher / baseline / student / int8 on the held-out split"),
|
|
31
|
+
}
|
|
32
|
+
PIPELINE = ("capture", "distill", "quantize", "eval")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dispatch(stage: str, rest: Sequence[str]) -> int:
|
|
36
|
+
modname, _ = STAGES[stage]
|
|
37
|
+
mod = importlib.import_module(modname)
|
|
38
|
+
argv0 = sys.argv[0]
|
|
39
|
+
sys.argv = [f"awembed {stage}", *rest]
|
|
40
|
+
try:
|
|
41
|
+
return int(mod.main() or 0)
|
|
42
|
+
except SystemExit as exc: # the stages exit with their gate verdict
|
|
43
|
+
code = exc.code
|
|
44
|
+
if isinstance(code, int):
|
|
45
|
+
return code
|
|
46
|
+
if code:
|
|
47
|
+
print(code, file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
return 0
|
|
50
|
+
finally:
|
|
51
|
+
sys.argv[0] = argv0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run(rest: Sequence[str]) -> int:
|
|
55
|
+
"""Run the four artifact stages in order against one --out root; stop at the
|
|
56
|
+
first stage whose gate refuses. `rest` is passed to every stage (they share the
|
|
57
|
+
--corpus/--seed/--out contract and ignore what they do not use)."""
|
|
58
|
+
for stage in PIPELINE:
|
|
59
|
+
print(f"[awembed] stage {stage}", flush=True)
|
|
60
|
+
code = _dispatch(stage, rest)
|
|
61
|
+
if code != 0:
|
|
62
|
+
print(f"[awembed] stage {stage} refused (exit {code}); stopping", flush=True)
|
|
63
|
+
return code
|
|
64
|
+
print("[awembed] done: all stages passed their gates", flush=True)
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
69
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
70
|
+
ap = argparse.ArgumentParser(prog="awembed", description=__doc__.splitlines()[0],
|
|
71
|
+
epilog="Use `awembed <stage> --help` for a stage's own flags.")
|
|
72
|
+
ap.add_argument("--version", action="store_true", help="print the version and exit")
|
|
73
|
+
sub = ap.add_subparsers(dest="stage")
|
|
74
|
+
for name, (_, help_) in STAGES.items():
|
|
75
|
+
sub.add_parser(name, help=help_, add_help=False)
|
|
76
|
+
sub.add_parser("run", help="capture -> distill -> quantize -> eval", add_help=False)
|
|
77
|
+
# Split at the stage name: everything after it belongs to the stage's own parser.
|
|
78
|
+
head, rest = argv, []
|
|
79
|
+
for i, a in enumerate(argv):
|
|
80
|
+
if a in STAGES or a == "run":
|
|
81
|
+
head, rest = argv[: i + 1], argv[i + 1:]
|
|
82
|
+
break
|
|
83
|
+
ns = ap.parse_args(head)
|
|
84
|
+
if ns.version:
|
|
85
|
+
from awembed import __version__
|
|
86
|
+
print(__version__)
|
|
87
|
+
return 0
|
|
88
|
+
if not ns.stage:
|
|
89
|
+
ap.print_help()
|
|
90
|
+
return 2
|
|
91
|
+
if ns.stage == "run":
|
|
92
|
+
return _run(rest)
|
|
93
|
+
return _dispatch(ns.stage, rest)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
sys.exit(main())
|