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 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())