inferencekit-cli 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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
inferencekit/ops/nsys.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""nsys capture / nsys analyze: a timeline of every kernel, copy and CUDA API call.
|
|
2
|
+
|
|
3
|
+
Capture command (environment reduced to an allowlist, see proc.clean_env):
|
|
4
|
+
|
|
5
|
+
nsys profile -t cuda,nvtx --cuda-graph-trace=node -s none --cpuctxsw=none -o <out> <argv>
|
|
6
|
+
nsys export --type sqlite -o <out>.sqlite <out>.nsys-rep
|
|
7
|
+
|
|
8
|
+
-t cuda,nvtx trace CUDA API + GPU activity, and NVTX annotation ranges
|
|
9
|
+
--cuda-graph-trace=node record each kernel inside a CUDA graph, not one opaque block
|
|
10
|
+
(without it, a decode step under graphs is a single bar)
|
|
11
|
+
-s none / --cpuctxsw=none no CPU sampling or context-switch tracing: less overhead,
|
|
12
|
+
smaller files; this capture is about the GPU timeline
|
|
13
|
+
|
|
14
|
+
What the report file holds: nsys stores the profiled program's full command line,
|
|
15
|
+
working directory, user name and NAME=value of every environment variable it received,
|
|
16
|
+
plus the host name and the host's process list. The evidence record keeps only a digest
|
|
17
|
+
of the command and the variable names. The exported .sqlite is scrubbed of those values
|
|
18
|
+
(nsys_sqlite.scrub_metadata); the .nsys-rep cannot be edited and keeps them, so treat it
|
|
19
|
+
as sensitive. That is also why secret-looking names are refused for --keep-env.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from inferencekit import evidence as ev
|
|
27
|
+
from inferencekit.analysis import nsys_sqlite
|
|
28
|
+
from inferencekit.gpuinfo import gpu_identity
|
|
29
|
+
from inferencekit.proc import clean_env, dropped_config_env, require, run, tool_version
|
|
30
|
+
from inferencekit.registry import Arg, OpError, operation
|
|
31
|
+
|
|
32
|
+
REP_IS_SENSITIVE = ("the .nsys-rep stores the profiled command line, working directory, user and host name, and "
|
|
33
|
+
"NAME=value of every passed variable ({names}): treat it as sensitive and do not share it; the "
|
|
34
|
+
"exported .sqlite has these values blanked")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def export_sqlite(nsys: str, rep: Path, out: Path) -> None:
|
|
38
|
+
r = run([nsys, "export", "--type", "sqlite", "--force-overwrite", "true", "-o", str(out), str(rep)],
|
|
39
|
+
timeout=3600)
|
|
40
|
+
if not r.ok or not out.is_file():
|
|
41
|
+
raise OpError(f"nsys export failed (exit {r.returncode}): {r.stderr[-800:]}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def redact_outputs(staging: Path) -> None:
|
|
45
|
+
"""The program's own output may echo secrets: redact it as soon as the run ends."""
|
|
46
|
+
for name in ("stdout.txt", "stderr.txt"):
|
|
47
|
+
p = staging / name
|
|
48
|
+
if p.is_file():
|
|
49
|
+
p.write_text(ev.redact_text(p.read_text(errors="replace")))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@operation("capture_nsys", ("nsys", "capture"), "Run a command under 'nsys profile' with an allowlisted "
|
|
53
|
+
"environment (env -i equivalent) and export the trace to sqlite.",
|
|
54
|
+
[Arg("keep_env", "array", "Extra environment variable NAMES to pass through. The evidence record stores "
|
|
55
|
+
"names only, but nsys writes NAME=value of every passed variable (and the command line) into the "
|
|
56
|
+
".nsys-rep, so secret-looking names (*TOKEN*, *KEY*, *SECRET*...) are refused.", default=[]),
|
|
57
|
+
Arg("trace", "string", "nsys -t value.", default="cuda,nvtx"),
|
|
58
|
+
Arg("capture_range", "string", "Limit capture to cudaProfilerStart/Stop or an NVTX range.",
|
|
59
|
+
default="none", choices=("none", "cudaProfilerApi", "nvtx")),
|
|
60
|
+
Arg("nvtx_capture", "string", "NVTX range name for capture_range=nvtx."),
|
|
61
|
+
Arg("delay", "number", "Seconds to wait before collecting."),
|
|
62
|
+
Arg("duration", "number", "Seconds to collect."),
|
|
63
|
+
Arg("timeout", "integer", "Seconds before the run (and every process it started) is killed.",
|
|
64
|
+
default=3600)],
|
|
65
|
+
passthrough=True)
|
|
66
|
+
def capture_nsys(call, keep_env, trace, capture_range, nvtx_capture, delay, duration, timeout, argv):
|
|
67
|
+
nsys = require("nsys", "Install Nsight Systems (it ships with the CUDA toolkit).")
|
|
68
|
+
if capture_range == "nvtx" and not nvtx_capture:
|
|
69
|
+
raise OpError("capture_range=nvtx needs nvtx_capture=<range name>")
|
|
70
|
+
env, env_names = clean_env(keep_env) # raises on secret-looking names, before anything runs
|
|
71
|
+
dropped = dropped_config_env(env)
|
|
72
|
+
store = ev.Store()
|
|
73
|
+
with store.staging_dir() as staging: # removed again if anything below fails
|
|
74
|
+
base = staging / "profile"
|
|
75
|
+
cmd = [nsys, "profile", "-t", trace, "--cuda-graph-trace=node", "-s", "none", "--cpuctxsw=none",
|
|
76
|
+
"--force-overwrite", "true", "-o", str(base)]
|
|
77
|
+
if capture_range != "none":
|
|
78
|
+
cmd += ["--capture-range", capture_range]
|
|
79
|
+
if nvtx_capture:
|
|
80
|
+
cmd += ["--nvtx-capture", nvtx_capture]
|
|
81
|
+
if delay:
|
|
82
|
+
cmd += ["--delay", str(delay)]
|
|
83
|
+
if duration:
|
|
84
|
+
cmd += ["--duration", str(duration)]
|
|
85
|
+
r = run(cmd + list(argv), timeout=timeout, env=env, stdout_path=staging / "stdout.txt",
|
|
86
|
+
stderr_path=staging / "stderr.txt")
|
|
87
|
+
redact_outputs(staging)
|
|
88
|
+
rep = base.with_suffix(".nsys-rep")
|
|
89
|
+
if not rep.is_file():
|
|
90
|
+
raise OpError(f"nsys produced no report (exit {r.returncode}): {ev.redact_text(r.stderr[-800:])}")
|
|
91
|
+
sqlite = base.with_suffix(".sqlite")
|
|
92
|
+
export_sqlite(nsys, rep, sqlite)
|
|
93
|
+
scrubbed = nsys_sqlite.scrub_metadata(str(sqlite))
|
|
94
|
+
limitations, missing = [], []
|
|
95
|
+
if r.returncode != 0:
|
|
96
|
+
limitations.append(f"profiled command exited with code {r.returncode}: the trace may be partial")
|
|
97
|
+
missing.append("clean exit")
|
|
98
|
+
limitations.append(REP_IS_SENSITIVE.format(names=", ".join(env_names)))
|
|
99
|
+
if dropped:
|
|
100
|
+
limitations.append("set here but not passed to the profiled run, so the configuration may differ from a "
|
|
101
|
+
"benchmark run with the full environment: " + ", ".join(dropped)
|
|
102
|
+
+ " (pass each one that matters with --keep-env NAME)")
|
|
103
|
+
summary = f"nsys trace ({rep.stat().st_size / 1e6:.1f} MB), exit {r.returncode}; run 'nsys analyze' on it"
|
|
104
|
+
record = ev.build(call, kind="nsys_capture", claim_class="observed", summary=summary,
|
|
105
|
+
identity_=ev.identity(gpu=gpu_identity()),
|
|
106
|
+
artifacts=[ev.artifact(rep, staging, "nsys_rep"), ev.artifact(sqlite, staging, "sqlite"),
|
|
107
|
+
ev.artifact(staging / "stderr.txt", staging, "stderr")],
|
|
108
|
+
missing=missing, limitations=limitations,
|
|
109
|
+
data={"nsys_version": tool_version("nsys"), "exit_code": r.returncode,
|
|
110
|
+
"env_names": env_names, "env_dropped": dropped, "sqlite_scrubbed": scrubbed,
|
|
111
|
+
"trace": trace, "cuda_graph_trace": "node", "capture_range": capture_range})
|
|
112
|
+
return store.save(record, staging)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_input(store: ev.Store, source: str, staging: Path) -> tuple[Path, bool, dict | None]:
|
|
116
|
+
"""(sqlite path, whether it was exported into ``staging``, source record if an evidence id was given)."""
|
|
117
|
+
p = Path(source)
|
|
118
|
+
if p.suffix == ".sqlite" and p.is_file():
|
|
119
|
+
return p, False, None
|
|
120
|
+
if p.suffix == ".nsys-rep" and p.is_file():
|
|
121
|
+
out = staging / (p.stem + ".sqlite")
|
|
122
|
+
export_sqlite(require("nsys", "nsys is needed to export .nsys-rep files"), p, out)
|
|
123
|
+
nsys_sqlite.scrub_metadata(str(out)) # this copy is stored as an artifact
|
|
124
|
+
return out, True, None
|
|
125
|
+
rec = store.load(source) # an evidence id of an nsys capture
|
|
126
|
+
if rec.get("kind") != "nsys_capture":
|
|
127
|
+
raise OpError(f"{source}: expected a .sqlite, a .nsys-rep or an nsys_capture evidence id")
|
|
128
|
+
art = next((a for a in rec["artifacts"] if a.get("role") == "sqlite"), None)
|
|
129
|
+
if art is None:
|
|
130
|
+
raise OpError(f"{source}: capture has no sqlite artifact")
|
|
131
|
+
return store.run_dir(rec["evidence_id"]) / art["path"], False, rec
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def timeline_metrics(result: dict) -> tuple[list[dict], list[str]]:
|
|
135
|
+
"""Evidence metrics for an analysed trace, and any limitations they need.
|
|
136
|
+
|
|
137
|
+
Families are ranked by critical-path share of the decode step (``critical_path_pct``),
|
|
138
|
+
with GPU idle inside the step as its own row, so the rows add up to 100%. Summed
|
|
139
|
+
kernel durations are not used for ranking: when kernels overlap (programmatic
|
|
140
|
+
dependent launch, several streams) they over-count; ``kernel_sum_over_span`` > 1
|
|
141
|
+
says by how much.
|
|
142
|
+
"""
|
|
143
|
+
steps = result["steps"]
|
|
144
|
+
metrics = [ev.metric("gpu_busy_pct", result["gpu_busy_pct"], "%", "window"),
|
|
145
|
+
ev.metric("gpu_idle_ms", result["gpu_idle_ms"], "ms", "window"),
|
|
146
|
+
ev.metric("graph_launches", result["graph_launch"]["launches"], "count", "total")]
|
|
147
|
+
limitations = []
|
|
148
|
+
host = result["graph_launch"]["host_us"]
|
|
149
|
+
if host.get("n"):
|
|
150
|
+
metrics.append(ev.metric("graph_launch_host_us", host["p50"], "us", "median", n=host["n"]))
|
|
151
|
+
# step_gap_us is time with *nothing* running on the GPU between two steps (not just the
|
|
152
|
+
# gap between the steps' kernels, which can contain other GPU work).
|
|
153
|
+
for key, name in (("span_us", "step_span_us"), ("idle_in_step_us", "step_idle_us"),
|
|
154
|
+
("gap_fully_idle_us", "step_gap_us")):
|
|
155
|
+
d = steps.get(key) or {}
|
|
156
|
+
if d.get("n"):
|
|
157
|
+
metrics.append(ev.metric(name, d["p50"], "us", "median", n=d["n"]))
|
|
158
|
+
span = (steps.get("span_us") or {}).get("p50")
|
|
159
|
+
kernel_sum = (steps.get("kernel_sum_us") or {}).get("p50")
|
|
160
|
+
families = steps.get("families") or {}
|
|
161
|
+
n = steps.get("steady_steps")
|
|
162
|
+
if span and kernel_sum:
|
|
163
|
+
ratio = kernel_sum / span
|
|
164
|
+
metrics.append(ev.metric("kernel_sum_over_span", round(ratio, 3), "ratio", "median", n=n))
|
|
165
|
+
if ratio > 1.05:
|
|
166
|
+
limitations.append(f"kernels overlap: summed kernel time is {ratio:.2f}x the step span, so shares of "
|
|
167
|
+
"summed duration over-count; rank by critical_path_pct")
|
|
168
|
+
if families:
|
|
169
|
+
metrics.append(ev.metric("critical_path_pct", steps.get("idle_pct_of_step"), "%", "mean_step_share", n=n,
|
|
170
|
+
subject="GPU idle inside the step"))
|
|
171
|
+
for f, v in list(families.items())[:6]: # already sorted by critical-path time
|
|
172
|
+
metrics.append(ev.metric("critical_path_pct", v["critical_pct_of_step"], "%", "mean_step_share", n=n,
|
|
173
|
+
subject=f))
|
|
174
|
+
else: # no decode steps found: only whole-window shares of summed kernel time are available
|
|
175
|
+
for f, v in list(result["families"].items())[:6]:
|
|
176
|
+
metrics.append(ev.metric("kernel_time_pct", v["pct_kernel_time"], "%", "summed_duration_share",
|
|
177
|
+
subject=f))
|
|
178
|
+
limitations.append("no decode steps found: family shares are of summed kernel time over the whole window, "
|
|
179
|
+
"which over-counts overlapping kernels")
|
|
180
|
+
return metrics, limitations
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@operation("analyze_nsys", ("nsys", "analyze"), "Per-step GPU busy/idle, idle-gap histogram, kernel families "
|
|
184
|
+
"ranked by critical-path share of the step, graph launches and top kernels from an nsys trace.",
|
|
185
|
+
[Arg("source", "string", "A .nsys-rep, a .sqlite export, or an nsys_capture evidence id.", required=True,
|
|
186
|
+
positional=True),
|
|
187
|
+
Arg("step_regex", "string", "NVTX range names that mark one decode step.",
|
|
188
|
+
default=nsys_sqlite.DEFAULT_STEP_REGEX),
|
|
189
|
+
Arg("gap_us", "number", "Fallback segmentation: a host gap longer than this starts a new step.",
|
|
190
|
+
default=20.0),
|
|
191
|
+
Arg("top", "integer", "How many top kernels to keep.", default=25)])
|
|
192
|
+
def analyze_nsys(call, source, step_regex, gap_us, top):
|
|
193
|
+
store = ev.Store()
|
|
194
|
+
with store.staging_dir() as staging:
|
|
195
|
+
sqlite, exported, src_rec = _resolve_input(store, source, staging)
|
|
196
|
+
result = nsys_sqlite.analyze(str(sqlite), step_regex=step_regex, gap_us=gap_us, top=top)
|
|
197
|
+
if "error" in result:
|
|
198
|
+
raise OpError(result["error"])
|
|
199
|
+
steps = result["steps"]
|
|
200
|
+
metrics, limitations = timeline_metrics(result)
|
|
201
|
+
limitations = list(result["notes"]) + limitations
|
|
202
|
+
limitations.append("nsys adds a small per-launch overhead; absolute step times run slightly long under the "
|
|
203
|
+
"profiler (compare to bench numbers, not across tools)")
|
|
204
|
+
gpu = {"name": result.get("gpu_name")}
|
|
205
|
+
if src_rec:
|
|
206
|
+
gpu = src_rec["identity"]["gpu"]
|
|
207
|
+
problem = ev.integrity_problem(src_rec)
|
|
208
|
+
if problem:
|
|
209
|
+
limitations.append(f"source capture {problem}")
|
|
210
|
+
span = steps.get("span_us", {})
|
|
211
|
+
summary = (f"GPU busy {result['gpu_busy_pct']}% of {result['window_ms']:.1f} ms; "
|
|
212
|
+
f"{steps.get('steps', 0)} steps ({steps.get('segmentation')})")
|
|
213
|
+
if span.get("n"):
|
|
214
|
+
summary += f", step span p50 {span['p50']} us"
|
|
215
|
+
top_family = next(iter(steps.get("families") or {}), None)
|
|
216
|
+
if top_family:
|
|
217
|
+
summary += (f"; top family {top_family} {steps['families'][top_family]['critical_pct_of_step']}% "
|
|
218
|
+
f"of the step's critical path")
|
|
219
|
+
artifacts = [ev.artifact(sqlite, staging if exported else None, "sqlite" if exported else "input")]
|
|
220
|
+
record = ev.build(call, kind="nsys_timeline", claim_class="observed", summary=summary,
|
|
221
|
+
identity_=ev.identity(gpu=gpu), metrics=metrics, artifacts=artifacts,
|
|
222
|
+
limitations=limitations,
|
|
223
|
+
data={**result, "source_evidence": src_rec["evidence_id"] if src_rec else None})
|
|
224
|
+
return store.save(record, staging)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""prompts build / seal / verify / show: the versioned prompt sets every engine is run on.
|
|
2
|
+
|
|
3
|
+
``build`` writes the shipped dev sets (``dev``: 16 prompts per bucket; ``dev-load``:
|
|
4
|
+
1,200+ short/medium prompts for load tests) and ``exclusions.json``. ``seal`` writes the
|
|
5
|
+
sealed sets, built by the same code from disjoint source groups, to a directory *outside*
|
|
6
|
+
any git work tree, plus a ``SHA256SUMS`` file whose hashes go into the pre-registration.
|
|
7
|
+
``verify`` re-hashes a set against its manifest (and optionally an expected sha256) and
|
|
8
|
+
checks every item against the format and bucket rules; with ``--secret-file`` it also
|
|
9
|
+
checks the revealed secret against the manifest's ``secret_sha256`` (``sha256sum`` of the
|
|
10
|
+
file) and rebuilds the sealed set from the sources to compare hashes. ``show`` prints
|
|
11
|
+
per-bucket length statistics.
|
|
12
|
+
|
|
13
|
+
Numbers printed here are computed from the files (claim class ``derived``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import statistics
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from inferencekit import prompts as ps
|
|
25
|
+
from inferencekit.registry import Arg, OpError, operation
|
|
26
|
+
|
|
27
|
+
_DEFAULT_CACHE = os.path.join(os.path.expanduser("~"), ".cache", "inferencekit", "prompt-sources")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _git_root(path: Path) -> Path | None:
|
|
31
|
+
p = path.resolve()
|
|
32
|
+
for d in (p, *p.parents):
|
|
33
|
+
if (d / ".git").exists():
|
|
34
|
+
return d
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cache(sources: str | None, fetch: bool) -> Path:
|
|
39
|
+
from inferencekit.prompts import build
|
|
40
|
+
|
|
41
|
+
cache = Path(sources or _DEFAULT_CACHE).expanduser()
|
|
42
|
+
if fetch:
|
|
43
|
+
build.fetch(cache, log=lambda *_: None)
|
|
44
|
+
return cache
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _exclusions(path: str | None) -> dict:
|
|
48
|
+
from inferencekit.prompts import build
|
|
49
|
+
|
|
50
|
+
return build.read_exclusions(Path(path).expanduser() if path else ps.data_dir() / build.EXCLUSIONS_FILE)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_EXCL_ARG = Arg("exclusions", "string", "Exclusions file to apply (default: the package's exclusions.json).")
|
|
54
|
+
_BLOCK_ARG = Arg("blocklist", "string", "Private scrub-patterns file whose matches are excluded and recorded in "
|
|
55
|
+
"exclusions.json (default: $INFERENCEKIT_PROMPT_BLOCKLIST if set, else none).")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rules(blocklist: str | None) -> list:
|
|
59
|
+
from inferencekit.prompts import build
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return build.load_rules(build.blocklist_path(blocklist))
|
|
63
|
+
except FileNotFoundError as e:
|
|
64
|
+
raise OpError(str(e)) from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@operation("prompts_build", ("prompts", "build"), "Build the shipped dev prompt sets (dev, dev-load) "
|
|
68
|
+
"deterministically from the downloaded open-licence sources.",
|
|
69
|
+
[Arg("sources", "string", f"Source cache directory (default {_DEFAULT_CACHE.replace(os.path.expanduser('~'), '~')})."),
|
|
70
|
+
Arg("out", "string", "Output directory (default: the package's prompts directory). The package's "
|
|
71
|
+
"prompts directory gets .jsonl.gz like the shipped sets; any other directory plain .jsonl."),
|
|
72
|
+
Arg("fetch", "boolean", "Download missing source files first.", default=False),
|
|
73
|
+
Arg("seed", "string", "Split/rank seed (changing it changes every set).", default="inferencekit-prompts-v1"),
|
|
74
|
+
_EXCL_ARG, _BLOCK_ARG])
|
|
75
|
+
def prompts_build(call, sources, out, fetch, seed, exclusions, blocklist):
|
|
76
|
+
from inferencekit.prompts import build
|
|
77
|
+
|
|
78
|
+
cache = _cache(sources, fetch)
|
|
79
|
+
out_dir = Path(out).expanduser() if out else ps.data_dir()
|
|
80
|
+
# The shipped sets stay gzipped however the package directory is named (scripts/build_prompts.py
|
|
81
|
+
# passes it explicitly); a rebuild elsewhere writes plain JSONL. The manifests hash the JSONL either way.
|
|
82
|
+
gz = out_dir.resolve() == ps.data_dir().resolve()
|
|
83
|
+
rules = _rules(blocklist)
|
|
84
|
+
try:
|
|
85
|
+
mans, applied = build.build_split(cache, out_dir, "dev", seed, exclusions=_exclusions(exclusions),
|
|
86
|
+
rules=rules, gz=gz)
|
|
87
|
+
build.write_exclusions(out_dir / build.EXCLUSIONS_FILE, applied, build.source_names(cache))
|
|
88
|
+
except (FileNotFoundError, ValueError) as e:
|
|
89
|
+
raise OpError(str(e)) from None
|
|
90
|
+
return {"claim_class": "derived", "sets": [_brief(m) for m in mans],
|
|
91
|
+
"excluded_rows": sum(len(v) for v in applied.values()), "private_rules": len(rules)}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@operation("prompts_seal", ("prompts", "seal"), "Build the sealed prompt sets (disjoint from dev) into a "
|
|
95
|
+
"directory outside any git work tree and write SHA256SUMS for the pre-registration.",
|
|
96
|
+
[Arg("out", "string", "Output directory, outside every git work tree.", required=True),
|
|
97
|
+
Arg("sources", "string", "Source cache directory."),
|
|
98
|
+
Arg("fetch", "boolean", "Download missing source files first.", default=False),
|
|
99
|
+
Arg("seed", "string", "Split/rank seed (must match the dev build).", default="inferencekit-prompts-v1"),
|
|
100
|
+
Arg("secret_file", "string", "File holding the selection secret (default <out>/SEED.secret, created "
|
|
101
|
+
"with 32 random bytes if missing). Keep it private until the sealed run is over."),
|
|
102
|
+
Arg("public", "boolean", "Rank by the public seed only (anyone can rebuild the set; tests and demos).",
|
|
103
|
+
default=False),
|
|
104
|
+
_EXCL_ARG, _BLOCK_ARG])
|
|
105
|
+
def prompts_seal(call, out, sources, fetch, seed, secret_file, public, exclusions, blocklist):
|
|
106
|
+
import secrets as _secrets
|
|
107
|
+
|
|
108
|
+
from inferencekit.prompts import build
|
|
109
|
+
|
|
110
|
+
out_dir = Path(out).expanduser()
|
|
111
|
+
root = _git_root(out_dir)
|
|
112
|
+
if root is not None:
|
|
113
|
+
raise OpError(f"refusing to write sealed prompts inside the git work tree {root}: "
|
|
114
|
+
"a sealed set must never be committable")
|
|
115
|
+
secret = secret_sha = None
|
|
116
|
+
if not public:
|
|
117
|
+
sf = Path(secret_file).expanduser() if secret_file else out_dir / "SEED.secret"
|
|
118
|
+
if _git_root(sf) is not None:
|
|
119
|
+
raise OpError(f"refusing to keep the sealing secret inside a git work tree ({sf})")
|
|
120
|
+
if not sf.is_file():
|
|
121
|
+
sf.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
sf.write_text(_secrets.token_hex(32) + "\n", encoding="utf-8")
|
|
123
|
+
sf.chmod(0o600)
|
|
124
|
+
secret, secret_sha = _read_secret(sf)
|
|
125
|
+
cache = _cache(sources, fetch)
|
|
126
|
+
listed = _exclusions(exclusions)
|
|
127
|
+
rules = _rules(blocklist)
|
|
128
|
+
try:
|
|
129
|
+
if rules: # a public rebuild must exclude the same rows: they have to be in the exclusions file
|
|
130
|
+
_, by_rules = build.candidates(cache, seed, None, rules)
|
|
131
|
+
extra = sum(len(refs - listed.get(sha, set())) for sha, refs in by_rules.items())
|
|
132
|
+
if extra:
|
|
133
|
+
raise OpError(f"the private rules exclude {extra} row(s) the exclusions file does not list, so the "
|
|
134
|
+
"sealed set could not be rebuilt without them: run `prompts build` first (it rewrites "
|
|
135
|
+
"exclusions.json) or pass --exclusions")
|
|
136
|
+
mans, _ = build.build_split(cache, out_dir, "sealed", seed, secret, secret_sha, exclusions=listed,
|
|
137
|
+
rules=rules)
|
|
138
|
+
except (FileNotFoundError, ValueError) as e:
|
|
139
|
+
raise OpError(str(e)) from None
|
|
140
|
+
sums = build.write_sums(out_dir, mans)
|
|
141
|
+
return {"claim_class": "derived", "sets": [_brief(m) for m in mans], "sha256sums": str(sums),
|
|
142
|
+
"secret_sha256": secret_sha, "secret_rule": build.SECRET_RULE if secret else None}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _read_secret(sf: Path) -> tuple[str, str]:
|
|
146
|
+
"""(secret text used as the ranking salt, sha256 of the file bytes: the published commitment)."""
|
|
147
|
+
import hashlib
|
|
148
|
+
|
|
149
|
+
raw = sf.read_bytes()
|
|
150
|
+
secret = raw.decode("utf-8").strip()
|
|
151
|
+
if len(secret) < 16:
|
|
152
|
+
raise OpError(f"{sf}: the secret is too short (use at least 16 characters)")
|
|
153
|
+
return secret, hashlib.sha256(raw).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _brief(m: dict) -> dict:
|
|
157
|
+
return {k: m[k] for k in ("name", "n", "buckets", "sha256", "sealed")}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _sums_lookup(path: Path) -> dict[str, str]:
|
|
161
|
+
out = {}
|
|
162
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
163
|
+
parts = line.split()
|
|
164
|
+
if len(parts) == 2:
|
|
165
|
+
out[parts[1].lstrip("*")] = parts[0]
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_items(items: list[dict]) -> list[str]:
|
|
170
|
+
"""Format and bucket-rule problems (empty = fine)."""
|
|
171
|
+
problems, ids, texts = [], Counter(), Counter()
|
|
172
|
+
for n, it in enumerate(items, 1):
|
|
173
|
+
for key in ("id", "bucket", "messages", "max_tokens", "source", "kind"):
|
|
174
|
+
if key not in it:
|
|
175
|
+
problems.append(f"line {n}: missing '{key}'")
|
|
176
|
+
if it.get("bucket") not in ps.BUCKETS:
|
|
177
|
+
problems.append(f"line {n}: unknown bucket {it.get('bucket')!r}")
|
|
178
|
+
continue
|
|
179
|
+
msgs = it.get("messages")
|
|
180
|
+
if not isinstance(msgs, list) or not msgs or not all(isinstance(m, dict) and m.get("role") and
|
|
181
|
+
isinstance(m.get("content"), str) for m in msgs):
|
|
182
|
+
problems.append(f"line {n}: messages must be a non-empty OpenAI chat list")
|
|
183
|
+
continue
|
|
184
|
+
if not isinstance(it.get("max_tokens"), int) or it["max_tokens"] < 1:
|
|
185
|
+
problems.append(f"line {n}: max_tokens must be a positive integer")
|
|
186
|
+
text = ps.prompt_text(it)
|
|
187
|
+
if not ps.in_bucket(text, it["bucket"]):
|
|
188
|
+
problems.append(f"line {n} ({it.get('id')}): {ps.words(text)} words / {ps.proxy_tokens(text)} proxy "
|
|
189
|
+
f"tokens is outside the {it['bucket']} rule {ps.BUCKET_RULES[it['bucket']]}")
|
|
190
|
+
ids[it.get("id")] += 1
|
|
191
|
+
texts[text] += 1
|
|
192
|
+
problems += [f"duplicate id {i!r} ({c}x)" for i, c in ids.items() if c > 1]
|
|
193
|
+
dup_texts = sum(1 for c in texts.values() if c > 1)
|
|
194
|
+
if dup_texts:
|
|
195
|
+
problems.append(f"{dup_texts} prompt text(s) appear more than once")
|
|
196
|
+
return problems
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@operation("prompts_verify", ("prompts", "verify"), "Check a prompt set: sha256 against its manifest (and an "
|
|
200
|
+
"expected hash or SHA256SUMS), the item format, bucket rules, unique ids and prompts.",
|
|
201
|
+
[Arg("set", "string", "'dev', 'dev-load', or a path to a .jsonl / .jsonl.gz prompt set.", default="dev"),
|
|
202
|
+
Arg("expected_sha256", "string", "Pre-registered sha256 the JSONL bytes must have."),
|
|
203
|
+
Arg("sums", "string", "A SHA256SUMS file listing the set."),
|
|
204
|
+
Arg("secret_file", "string", "Sealed sets: the revealed SEED.secret. Checks `sha256sum` of the file "
|
|
205
|
+
"against the manifest's secret_sha256 and, when the sources are present, rebuilds the set."),
|
|
206
|
+
Arg("sources", "string", "Source cache directory for the rebuild (default "
|
|
207
|
+
f"{_DEFAULT_CACHE.replace(os.path.expanduser('~'), '~')})."),
|
|
208
|
+
Arg("fetch", "boolean", "Download missing source files for the rebuild.", default=False),
|
|
209
|
+
_EXCL_ARG])
|
|
210
|
+
def prompts_verify(call, set, expected_sha256, sums, secret_file, sources, fetch, exclusions): # noqa: A002
|
|
211
|
+
path = ps.resolve(set)
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
raise OpError(f"no prompt set at {path}")
|
|
214
|
+
try:
|
|
215
|
+
actual = ps.sha256_file(path)
|
|
216
|
+
except (OSError, ValueError) as e: # e.g. a truncated or corrupt .jsonl.gz
|
|
217
|
+
raise OpError(f"{path}: cannot read ({e})") from None
|
|
218
|
+
checks = []
|
|
219
|
+
|
|
220
|
+
def check(name, ok, detail):
|
|
221
|
+
checks.append({"check": name, "ok": bool(ok), "detail": detail})
|
|
222
|
+
|
|
223
|
+
man_path = ps.manifest_path(path)
|
|
224
|
+
man = None
|
|
225
|
+
if man_path.is_file():
|
|
226
|
+
man = json.loads(man_path.read_text(encoding="utf-8"))
|
|
227
|
+
check("manifest_sha256", man.get("sha256") == actual, f"manifest {man.get('sha256')} vs file {actual}")
|
|
228
|
+
else:
|
|
229
|
+
check("manifest_present", False, f"{man_path.name} not found")
|
|
230
|
+
if expected_sha256:
|
|
231
|
+
check("expected_sha256", expected_sha256.strip().lower() == actual, f"expected {expected_sha256}")
|
|
232
|
+
if sums:
|
|
233
|
+
table = _sums_lookup(Path(sums))
|
|
234
|
+
key = path.name if path.name in table else ps.set_stem(path) + ".jsonl"
|
|
235
|
+
listed = table.get(key)
|
|
236
|
+
check("sha256sums", listed == actual, f"SHA256SUMS lists {listed} for {key}")
|
|
237
|
+
try:
|
|
238
|
+
items = [json.loads(line) for line in ps.read_text(path).splitlines() if line.strip()]
|
|
239
|
+
except (ValueError, OSError) as e:
|
|
240
|
+
raise OpError(f"{path}: not JSONL ({e})") from None
|
|
241
|
+
problems = check_items(items)
|
|
242
|
+
check("items", not problems, problems[:20] or f"{len(items)} items fit the format and bucket rules")
|
|
243
|
+
if man is not None:
|
|
244
|
+
counts = {b: sum(1 for it in items if it.get("bucket") == b) for b in ps.BUCKETS}
|
|
245
|
+
counts = {b: n for b, n in counts.items() if n}
|
|
246
|
+
check("manifest_counts", man.get("n") == len(items) and man.get("buckets") == counts,
|
|
247
|
+
f"manifest n={man.get('n')} buckets={man.get('buckets')}; file n={len(items)} buckets={counts}")
|
|
248
|
+
if secret_file:
|
|
249
|
+
_verify_secret(man, Path(secret_file).expanduser(), actual, sources, fetch, exclusions, check)
|
|
250
|
+
ok = all(c["ok"] for c in checks)
|
|
251
|
+
return {"set": man.get("name") if man else ps.set_stem(path), "path": str(path), "sha256": actual,
|
|
252
|
+
"verdict": "PASS" if ok else "FAIL", "checks": checks}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _verify_secret(man: dict | None, sf: Path, actual: str, sources, fetch, exclusions, check) -> None:
|
|
256
|
+
from inferencekit.prompts import build
|
|
257
|
+
|
|
258
|
+
if not man or not man.get("secret_sha256"):
|
|
259
|
+
check("secret_sha256", False, "the manifest commits to no secret (not a secret-sealed set)")
|
|
260
|
+
return
|
|
261
|
+
if not sf.is_file():
|
|
262
|
+
raise OpError(f"no secret file at {sf}")
|
|
263
|
+
secret, got = _read_secret(sf)
|
|
264
|
+
check("secret_sha256", got == man["secret_sha256"],
|
|
265
|
+
f"sha256sum of {sf.name} = {got}; manifest secret_sha256 = {man['secret_sha256']}")
|
|
266
|
+
if got != man["secret_sha256"]:
|
|
267
|
+
return
|
|
268
|
+
cache = _cache(sources, fetch)
|
|
269
|
+
try:
|
|
270
|
+
rebuilt = build.rebuild_sha256(cache, man.get("seed") or build.SEED, secret, _exclusions(exclusions))
|
|
271
|
+
except FileNotFoundError as e:
|
|
272
|
+
check("rebuild", False, f"cannot rebuild: {e} (pass --sources DIR or --fetch)")
|
|
273
|
+
return
|
|
274
|
+
except ValueError as e:
|
|
275
|
+
check("rebuild", False, f"cannot rebuild: {e}")
|
|
276
|
+
return
|
|
277
|
+
want = rebuilt.get(man.get("role") or "single")
|
|
278
|
+
check("rebuild", want == actual, f"rebuilt from the sources with this secret: {want}; file: {actual}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@operation("prompts_show", ("prompts", "show"), "Per-bucket statistics of a prompt set (counts, word and "
|
|
282
|
+
"proxy-token lengths, sources).",
|
|
283
|
+
[Arg("set", "string", "'dev', 'dev-load', or a path to a .jsonl / .jsonl.gz prompt set.", default="dev"),
|
|
284
|
+
Arg("examples", "integer", "Show the first N prompts of each bucket (truncated).", default=0)])
|
|
285
|
+
def prompts_show(call, set, examples): # noqa: A002
|
|
286
|
+
path = ps.resolve(set)
|
|
287
|
+
if not path.is_file():
|
|
288
|
+
raise OpError(f"no prompt set at {path}")
|
|
289
|
+
try:
|
|
290
|
+
items = ps.load(str(path))
|
|
291
|
+
except (OSError, ValueError) as e: # corrupt .jsonl.gz, not JSONL
|
|
292
|
+
raise OpError(f"{path}: cannot read ({e})") from None
|
|
293
|
+
try:
|
|
294
|
+
man = ps.load_manifest(str(path))
|
|
295
|
+
except (OSError, ValueError):
|
|
296
|
+
man = {}
|
|
297
|
+
buckets = {}
|
|
298
|
+
for b in ps.BUCKETS:
|
|
299
|
+
sel = [it for it in items if it.get("bucket") == b]
|
|
300
|
+
if not sel:
|
|
301
|
+
continue
|
|
302
|
+
w = [ps.words(ps.prompt_text(it)) for it in sel]
|
|
303
|
+
t = [ps.proxy_tokens(ps.prompt_text(it)) for it in sel]
|
|
304
|
+
row = {"n": len(sel), "words": {"min": min(w), "median": statistics.median(w), "max": max(w)},
|
|
305
|
+
"proxy_tokens": {"min": min(t), "median": statistics.median(t), "max": max(t)},
|
|
306
|
+
"max_tokens": sorted({it.get("max_tokens") for it in sel}),
|
|
307
|
+
"sources": dict(Counter(it.get("source", "?").split("/")[0].split("-")[0]
|
|
308
|
+
if it.get("source", "").startswith("gutenberg") else it.get("source", "?")
|
|
309
|
+
for it in sel))}
|
|
310
|
+
if examples:
|
|
311
|
+
row["examples"] = [{"id": it["id"], "text": ps.prompt_text(it)[:200]} for it in sel[:examples]]
|
|
312
|
+
buckets[b] = row
|
|
313
|
+
return {"set": man.get("name") or ps.set_stem(path), "sha256": ps.sha256_file(path), "sealed": man.get("sealed"),
|
|
314
|
+
"n": len(items), "claim_class": "derived", "length_proxy": man.get("length_proxy",
|
|
315
|
+
"proxy_tokens = words * 1.3"),
|
|
316
|
+
"buckets": buckets}
|