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.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -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}