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,304 @@
1
+ """Decode video clips and score them frame by frame, inside the video pipeline's own Python.
2
+
3
+ Usage: python video_metrics.py CONFIG.json
4
+ Prints exactly one line ``INFERKIT_RESULT {...}`` on stdout; the full results go to the file named by
5
+ ``out`` in the config; logs go to stderr. Standard library at import time; the work needs PyAV and
6
+ numpy; LPIPS also needs torch and the ``lpips`` package (PSNR, SSIM and MAD are numpy only). A video
7
+ pipeline's venv has all of these; the inferencekit harness has none of them, which is why this runs as a worker (like
8
+ engines/vllm_worker.py) and never imports inferencekit.
9
+
10
+ Config: {"task": "probe" | "compare", "out": path,
11
+ "files": [{"key", "path"}] probe: what decoding the file really gives
12
+ "pairs": [{"key", "a", "b"}] compare: a = the reference side, b = the other
13
+ "metrics": ["psnr", "ssim", "lpips", "mad"], "device": "auto" | "cpu" | "cuda",
14
+ "frame_stride": 1, "psnr_cap_db": 100.0, "lpips_net": "alex", "ssim_backend": "auto" | "numpy" | "torch"}
15
+
16
+ probe, per file (decoded, not read from the container header):
17
+ width/height of the decoded frames, the decoded frame count, fps from the decoded timestamps
18
+ ((n - 1) / (t_last - t_first)), audio samples decoded / sample rate; the header's claims beside them.
19
+ compare, per pair, per frame (frames decoded in step, never all held in memory):
20
+ psnr 10 log10(255^2 / MSE) over the RGB24 pixels, capped at psnr_cap_db (identical frames)
21
+ ssim SSIM of the BT.601 luma, 11x11 Gaussian window sigma 1.5, K1 0.01, K2 0.03, valid region
22
+ (Wang et al. 2004; skimage structural_similarity with gaussian_weights=True,
23
+ use_sample_covariance=False and data_range=255 computes the same)
24
+ lpips LPIPS v0.1 (Zhang et al. 2018) with the given backbone, RGB scaled to [-1, 1]
25
+ mad mean absolute difference of the RGB24 pixels (0-255 levels)
26
+ Frames are compared in order up to the shorter clip; both counts are reported. Frames of different
27
+ sizes are not compared (the pair gets an error).
28
+ """
29
+
30
+ import json
31
+ import math
32
+ import os
33
+ import sys
34
+ import time
35
+
36
+ # Run by path, this file's directory comes first on sys.path; its neighbours (metrics.py, client.py...)
37
+ # must not shadow a top-level module that torch or PyAV imports.
38
+ _HERE = os.path.dirname(os.path.abspath(__file__))
39
+ sys.path[:] = [p for p in sys.path if os.path.abspath(p or os.curdir) != _HERE]
40
+
41
+ MARK = "INFERKIT_RESULT "
42
+ METRICS = ("psnr", "ssim", "lpips", "mad")
43
+ LUMA = (0.299, 0.587, 0.114) # BT.601
44
+ SSIM_SIGMA, SSIM_WIN, SSIM_K1, SSIM_K2 = 1.5, 11, 0.01, 0.03
45
+
46
+
47
+ def log(msg):
48
+ print(msg, file=sys.stderr, flush=True)
49
+
50
+
51
+ # --------------------------------------------------------------------------- probe
52
+
53
+ def probe(path):
54
+ """What decoding ``path`` gives, beside what its header claims."""
55
+ import av
56
+
57
+ out = {"decoder": "pyav", "video": None, "audio": None, "header": {}}
58
+ with av.open(path) as c:
59
+ out["header"]["format"] = c.format.name
60
+ out["header"]["duration_s"] = c.duration / 1e6 if c.duration else None
61
+ if c.streams.video:
62
+ vs = c.streams.video[0]
63
+ out["header"]["video"] = {"codec": vs.codec_context.name, "width": vs.codec_context.width,
64
+ "height": vs.codec_context.height, "frames": vs.frames or None,
65
+ "fps": float(vs.average_rate) if vs.average_rate else None}
66
+ sizes, times, n = set(), [], 0
67
+ for f in c.decode(vs):
68
+ n += 1
69
+ sizes.add((f.width, f.height))
70
+ if f.time is not None:
71
+ times.append(float(f.time))
72
+ times.sort()
73
+ fps = (len(times) - 1) / (times[-1] - times[0]) if len(times) >= 2 and times[-1] > times[0] else None
74
+ w, h = (sorted(sizes)[0] if sizes else (None, None))
75
+ out["video"] = {"frames": n, "width": w, "height": h, "sizes": sorted(sizes), "fps": fps,
76
+ "first_s": times[0] if times else None, "last_s": times[-1] if times else None,
77
+ "duration_s": n / fps if fps else None}
78
+ with av.open(path) as c:
79
+ if c.streams.audio:
80
+ st = c.streams.audio[0]
81
+ out["header"]["audio"] = {"codec": st.codec_context.name, "sample_rate": st.codec_context.sample_rate,
82
+ "channels": st.codec_context.channels if hasattr(st.codec_context, "channels")
83
+ else None,
84
+ "duration_s": float(st.duration * st.time_base) if st.duration else None}
85
+ samples, rate = 0, None
86
+ for f in c.decode(st):
87
+ samples += f.samples
88
+ rate = rate or f.sample_rate
89
+ out["audio"] = {"samples": samples, "sample_rate": rate,
90
+ "duration_s": samples / rate if rate else None}
91
+ return out
92
+
93
+
94
+ # --------------------------------------------------------------------------- compare
95
+
96
+ class Scorer:
97
+ """Per-frame metrics. PSNR and MAD run in numpy. SSIM runs in numpy, or in torch (same float64 maths) when
98
+ torch is loaded anyway for LPIPS and the device is a GPU (``ssim_backend`` "auto"), or when asked ("torch"):
99
+ at 1920x1088 numpy SSIM takes ~0.7 s a frame on one CPU core, which adds up over a clip set."""
100
+
101
+ def __init__(self, metrics, device, psnr_cap_db, lpips_net, ssim_backend="auto"):
102
+ import numpy as np
103
+
104
+ self.np = np
105
+ self.metrics = [m for m in metrics if m in METRICS]
106
+ self.cap = float(psnr_cap_db)
107
+ g = np.arange(SSIM_WIN, dtype=np.float64) - (SSIM_WIN - 1) / 2
108
+ g = np.exp(-(g * g) / (2 * SSIM_SIGMA ** 2))
109
+ self.window = g / g.sum()
110
+ self.torch = self.dev = self.lpips = self.lpips_error = None
111
+ self.ssim_backend = "numpy"
112
+ if "lpips" in self.metrics or ("ssim" in self.metrics and ssim_backend == "torch"):
113
+ try:
114
+ import torch
115
+
116
+ self.torch = torch
117
+ if device == "auto":
118
+ device = "cuda" if torch.cuda.is_available() else "cpu"
119
+ self.dev = torch.device(device)
120
+ except ImportError as e:
121
+ if ssim_backend == "torch":
122
+ raise
123
+ self.lpips_error = f"torch is not installed ({e})"
124
+ if "ssim" in self.metrics and self.torch is not None and (
125
+ ssim_backend == "torch" or (ssim_backend == "auto" and self.dev.type == "cuda")):
126
+ self.ssim_backend = "torch"
127
+ self.twin = self.torch.tensor(self.window, dtype=self.torch.float64, device=self.dev)
128
+ if "lpips" in self.metrics and self.torch is not None:
129
+ try:
130
+ import lpips
131
+
132
+ self.lpips = lpips.LPIPS(net=lpips_net, verbose=False).to(self.dev).eval()
133
+ except Exception as e: # no package, or the backbone weights cannot be fetched
134
+ self.lpips_error = f"{type(e).__name__}: {e}"[:500]
135
+ if self.lpips_error:
136
+ log(f"LPIPS unavailable: {self.lpips_error}")
137
+ self.device = str(self.dev) if self.lpips is not None or self.ssim_backend == "torch" else "cpu"
138
+
139
+ def _blur(self, x):
140
+ """Separable Gaussian filter over the valid region (no padding): a sum of shifted slices."""
141
+ k, n = self.window, SSIM_WIN
142
+ h, w = x.shape
143
+ rows = sum(k[i] * x[i:h - n + 1 + i, :] for i in range(n))
144
+ return sum(k[j] * rows[:, j:w - n + 1 + j] for j in range(n))
145
+
146
+ def _blur_torch(self, x):
147
+ f = self.torch.nn.functional
148
+ return f.conv2d(f.conv2d(x, self.twin.view(1, 1, -1, 1)), self.twin.view(1, 1, 1, -1))
149
+
150
+ def ssim(self, a, b):
151
+ if min(a.shape[0], a.shape[1]) < SSIM_WIN:
152
+ return None
153
+ c1, c2 = (SSIM_K1 * 255) ** 2, (SSIM_K2 * 255) ** 2
154
+ if self.ssim_backend == "torch":
155
+ torch = self.torch
156
+ luma = torch.tensor(LUMA, dtype=torch.float64, device=self.dev)
157
+ ya = (torch.from_numpy(a).to(self.dev, torch.float64) @ luma)[None, None]
158
+ yb = (torch.from_numpy(b).to(self.dev, torch.float64) @ luma)[None, None]
159
+ blur = self._blur_torch
160
+ else:
161
+ np = self.np
162
+ luma = np.asarray(LUMA, dtype=np.float64)
163
+ ya, yb = a.astype(np.float64) @ luma, b.astype(np.float64) @ luma
164
+ blur = self._blur
165
+ mu_a, mu_b = blur(ya), blur(yb)
166
+ va = blur(ya * ya) - mu_a * mu_a
167
+ vb = blur(yb * yb) - mu_b * mu_b
168
+ cov = blur(ya * yb) - mu_a * mu_b
169
+ s = ((2 * mu_a * mu_b + c1) * (2 * cov + c2)) / ((mu_a * mu_a + mu_b * mu_b + c1) * (va + vb + c2))
170
+ return float(s.mean())
171
+
172
+ def lpips_of(self, a, b):
173
+ torch = self.torch
174
+
175
+ def t(x):
176
+ return torch.from_numpy(x).to(self.dev).permute(2, 0, 1)[None].float() / 127.5 - 1.0
177
+
178
+ with torch.no_grad():
179
+ return float(self.lpips(t(a), t(b)).item())
180
+
181
+ def frame(self, a, b):
182
+ np = self.np
183
+ d = a.astype(np.float64) - b.astype(np.float64)
184
+ mse = float(np.mean(d * d))
185
+ row = {"identical": mse == 0.0}
186
+ if "psnr" in self.metrics:
187
+ row["psnr"] = self.cap if mse == 0.0 else min(self.cap, 10.0 * math.log10(255.0 ** 2 / mse))
188
+ if "mad" in self.metrics:
189
+ row["mad"] = float(np.mean(np.abs(d)))
190
+ if "ssim" in self.metrics:
191
+ row["ssim"] = 1.0 if mse == 0.0 else self.ssim(a, b)
192
+ if "lpips" in self.metrics and self.lpips is not None:
193
+ row["lpips"] = 0.0 if mse == 0.0 else self.lpips_of(a, b)
194
+ return row
195
+
196
+
197
+ def _decoder(path):
198
+ import av
199
+
200
+ c = av.open(path)
201
+ s = c.streams.video[0]
202
+ s.thread_type = "AUTO"
203
+ return c, c.decode(s)
204
+
205
+
206
+ def compare(scorer, pa, pb, stride):
207
+ out = {"frames_a": 0, "frames_b": 0, "frames_compared": 0, "size_a": None, "size_b": None,
208
+ "identical_frames": 0, "per_frame": {m: [] for m in scorer.metrics}}
209
+ ca, fa = _decoder(pa)
210
+ cb, fb = _decoder(pb)
211
+ try:
212
+ i = 0
213
+ while True:
214
+ a = next(fa, None)
215
+ b = next(fb, None)
216
+ if a is not None:
217
+ out["frames_a"] += 1
218
+ out["size_a"] = out["size_a"] or [a.width, a.height]
219
+ if b is not None:
220
+ out["frames_b"] += 1
221
+ out["size_b"] = out["size_b"] or [b.width, b.height]
222
+ if a is None or b is None:
223
+ if a is None and b is None:
224
+ break
225
+ continue # count the rest of the longer clip
226
+ if (a.width, a.height) != (b.width, b.height):
227
+ out["error"] = f"frame size differs: {a.width}x{a.height} vs {b.width}x{b.height} (frame {i})"
228
+ break
229
+ if i % stride == 0:
230
+ row = scorer.frame(a.to_ndarray(format="rgb24"), b.to_ndarray(format="rgb24"))
231
+ out["frames_compared"] += 1
232
+ out["identical_frames"] += 1 if row.pop("identical") else 0
233
+ for m, v in row.items():
234
+ out["per_frame"][m].append(v)
235
+ i += 1
236
+ finally:
237
+ ca.close()
238
+ cb.close()
239
+ out["mean"] = {m: (sum(v for v in vs if v is not None) / len([v for v in vs if v is not None])
240
+ if any(v is not None for v in vs) else None) for m, vs in out["per_frame"].items()}
241
+ out["frame_count_differs"] = out["frames_a"] != out["frames_b"]
242
+ return out
243
+
244
+
245
+ # --------------------------------------------------------------------------- main
246
+
247
+ def versions():
248
+ v = {"python": sys.version.split()[0]}
249
+ for name in ("av", "numpy", "torch", "lpips"):
250
+ mod = sys.modules.get(name)
251
+ if mod is not None:
252
+ v[name] = getattr(mod, "__version__", None)
253
+ return v
254
+
255
+
256
+ def main(argv):
257
+ if len(argv) != 2:
258
+ print(__doc__, file=sys.stderr)
259
+ return 2
260
+ with open(argv[1]) as f:
261
+ cfg = json.load(f)
262
+ t0 = time.time()
263
+ items, extra = [], {}
264
+ try:
265
+ import av # noqa: F401 (both tasks need PyAV and numpy: a missing one fails the run, not each clip)
266
+ import numpy # noqa: F401
267
+
268
+ if cfg["task"] == "probe":
269
+ for it in cfg.get("files", []):
270
+ try:
271
+ items.append({"key": it["key"], "ok": True, **probe(it["path"])})
272
+ except Exception as e:
273
+ items.append({"key": it["key"], "ok": False, "error": f"{type(e).__name__}: {e}"[:500]})
274
+ elif cfg["task"] == "compare":
275
+ scorer = Scorer(cfg.get("metrics") or ["psnr", "ssim", "lpips"], cfg.get("device") or "auto",
276
+ cfg.get("psnr_cap_db", 100.0), cfg.get("lpips_net") or "alex",
277
+ cfg.get("ssim_backend") or "auto")
278
+ stride = max(1, int(cfg.get("frame_stride") or 1))
279
+ extra = {"device": scorer.device, "metrics": scorer.metrics, "lpips_error": scorer.lpips_error,
280
+ "ssim_backend": scorer.ssim_backend, "frame_stride": stride, "psnr_cap_db": scorer.cap}
281
+ for it in cfg.get("pairs", []):
282
+ try:
283
+ r = compare(scorer, it["a"], it["b"], stride)
284
+ items.append({"key": it["key"], "ok": "error" not in r, **r})
285
+ except Exception as e:
286
+ items.append({"key": it["key"], "ok": False, "error": f"{type(e).__name__}: {e}"[:500]})
287
+ else:
288
+ raise ValueError(f"unknown task {cfg['task']!r}")
289
+ except ImportError as e: # the venv lacks PyAV / numpy / torch: say which, so the caller can point elsewhere
290
+ print(MARK + json.dumps({"ok": False, "error": f"missing module: {e}", "versions": versions()}), flush=True)
291
+ return 1
292
+ except Exception as e:
293
+ print(MARK + json.dumps({"ok": False, "error": f"{type(e).__name__}: {e}"[:500], "versions": versions()}),
294
+ flush=True)
295
+ return 1
296
+ with open(cfg["out"], "w") as f:
297
+ json.dump({"items": items, "versions": versions(), **extra}, f)
298
+ print(MARK + json.dumps({"ok": True, "n": len(items), "seconds": round(time.time() - t0, 3),
299
+ "versions": versions(), **extra}), flush=True)
300
+ return 0
301
+
302
+
303
+ if __name__ == "__main__":
304
+ raise SystemExit(main(sys.argv))
@@ -0,0 +1,206 @@
1
+ """The operation registry: the single source of truth for what inferencekit can do.
2
+
3
+ Each operation is a plain Python function decorated with :func:`operation`. The
4
+ decorator records its name (the MCP tool name), its CLI path (``("nsys", "analyze")``
5
+ becomes ``inferencekit nsys analyze``), a one-line summary and an argument list. The
6
+ CLI (``cli.py``) and the MCP server (``mcp_server.py``) are generated from this
7
+ registry, so the two interfaces cannot drift apart.
8
+
9
+ Handlers are called as ``handler(call, **validated_args)`` and return a JSON-able
10
+ dict (usually an evidence record). ``call`` carries what the evidence layer needs
11
+ to write provenance: which operation ran, with which arguments, and when.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import datetime as _dt
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Callable
20
+
21
+ from inferencekit.canonical import digest
22
+
23
+ # JSON-schema type names used for arguments.
24
+ _TYPES = {"string", "integer", "number", "boolean", "array"}
25
+
26
+
27
+ class OpError(Exception):
28
+ """A user-facing failure (bad input, missing tool). Printed without a traceback."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Arg:
33
+ """One argument of an operation.
34
+
35
+ ``positional`` only affects the CLI (``inferencekit compare A B`` instead of flags).
36
+ ``sensitive`` marks command lines and similar free text: such values are used,
37
+ but only their digest is ever written to an evidence record.
38
+ """
39
+
40
+ name: str
41
+ type: str
42
+ help: str
43
+ required: bool = False
44
+ default: Any = None
45
+ choices: tuple | None = None
46
+ positional: bool = False
47
+ items: str = "string" # element type when type == "array"
48
+ sensitive: bool = False
49
+
50
+ def __post_init__(self):
51
+ if self.type not in _TYPES:
52
+ raise ValueError(f"arg {self.name}: unknown type {self.type}")
53
+
54
+ def json_schema(self) -> dict:
55
+ if self.type == "array":
56
+ schema: dict = {"type": "array", "items": {"type": self.items}}
57
+ if self.choices:
58
+ schema["items"]["enum"] = list(self.choices)
59
+ else:
60
+ schema = {"type": self.type}
61
+ if self.choices:
62
+ schema["enum"] = list(self.choices)
63
+ schema["description"] = self.help
64
+ if self.default is not None:
65
+ schema["default"] = self.default
66
+ return schema
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Operation:
71
+ name: str
72
+ cli: tuple[str, ...]
73
+ summary: str
74
+ args: tuple[Arg, ...]
75
+ handler: Callable[..., dict]
76
+ passthrough: bool = False # True: takes a command after "--" as the "argv" argument
77
+
78
+ def input_schema(self) -> dict:
79
+ """JSON Schema for the MCP tool's input (same names and defaults as the CLI)."""
80
+ return {
81
+ "type": "object",
82
+ "properties": {a.name: a.json_schema() for a in self.args},
83
+ "required": [a.name for a in self.args if a.required],
84
+ "additionalProperties": False,
85
+ }
86
+
87
+ def validate(self, raw: dict | None) -> dict:
88
+ """Check types, fill defaults, reject unknown names. Same rules for CLI and MCP."""
89
+ raw = dict(raw or {})
90
+ known = {a.name for a in self.args}
91
+ unknown = sorted(set(raw) - known)
92
+ if unknown:
93
+ raise OpError(f"{self.name}: unknown argument(s) {unknown}; expected {sorted(known)}")
94
+ out = {}
95
+ for a in self.args:
96
+ value = raw.get(a.name)
97
+ if value is None:
98
+ if a.required:
99
+ raise OpError(f"{self.name}: missing required argument '{a.name}'")
100
+ value = copy.deepcopy(a.default)
101
+ else:
102
+ value = _coerce(self.name, a, value)
103
+ if a.choices and value is not None:
104
+ for v in value if isinstance(value, list) else [value]:
105
+ if v not in a.choices:
106
+ raise OpError(f"{self.name}: {a.name}={v!r} is not one of {list(a.choices)}")
107
+ out[a.name] = value
108
+ return out
109
+
110
+
111
+ def _coerce(op: str, a: Arg, value: Any) -> Any:
112
+ """Convert CLI strings (and lenient MCP inputs) to the declared type."""
113
+ try:
114
+ if a.type == "integer":
115
+ if isinstance(value, bool) or (isinstance(value, float) and not value.is_integer()):
116
+ raise ValueError
117
+ return int(value)
118
+ if a.type == "number":
119
+ if isinstance(value, bool):
120
+ raise ValueError
121
+ return float(value)
122
+ if a.type == "boolean":
123
+ if isinstance(value, bool):
124
+ return value
125
+ if str(value).lower() in ("1", "true", "yes", "on"):
126
+ return True
127
+ if str(value).lower() in ("0", "false", "no", "off"):
128
+ return False
129
+ raise ValueError
130
+ if a.type == "array":
131
+ if isinstance(value, (str, int, float)):
132
+ value = [value]
133
+ sub = Arg(a.name, a.items, a.help)
134
+ return [_coerce(op, sub, v) for v in value]
135
+ return str(value)
136
+ except (TypeError, ValueError):
137
+ raise OpError(f"{op}: argument '{a.name}' expects {a.type}, got {value!r}") from None
138
+
139
+
140
+ @dataclass
141
+ class Call:
142
+ """One invocation of an operation; handed to the handler for provenance."""
143
+
144
+ op: Operation
145
+ args: dict
146
+ started: str = field(default_factory=lambda: utc_now())
147
+
148
+ def argv_digest(self) -> str:
149
+ # Digest of the full invocation (operation path + every argument, including
150
+ # sensitive ones). Lets you check "was this the same command?" without storing it.
151
+ return digest(["inferencekit", *self.op.cli, self.args])
152
+
153
+ def request(self) -> dict:
154
+ """The invocation with sensitive values replaced by digests (safe to store)."""
155
+ safe = {}
156
+ for a in self.op.args:
157
+ v = self.args.get(a.name)
158
+ if a.sensitive and v not in (None, [], ""):
159
+ v = {"digest": digest(v)}
160
+ safe[a.name] = v
161
+ return {"op": self.op.name, "cli": "inferencekit " + " ".join(self.op.cli), "args": safe}
162
+
163
+
164
+ def utc_now() -> str:
165
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
166
+
167
+
168
+ REGISTRY: dict[str, Operation] = {}
169
+
170
+
171
+ def operation(name: str, cli: tuple[str, ...], summary: str, args: list[Arg] | tuple[Arg, ...] = (),
172
+ passthrough: bool = False):
173
+ """Decorator: register ``fn`` as operation ``name``."""
174
+
175
+ def register(fn: Callable[..., dict]) -> Callable[..., dict]:
176
+ if name in REGISTRY:
177
+ raise ValueError(f"operation {name} registered twice")
178
+ all_args = tuple(args)
179
+ if passthrough:
180
+ all_args += (Arg("argv", "array", "Command to run (after '--' on the CLI).", required=True,
181
+ sensitive=True),)
182
+ REGISTRY[name] = Operation(name, tuple(cli), summary, all_args, fn, passthrough)
183
+ return fn
184
+
185
+ return register
186
+
187
+
188
+ def load_all() -> dict[str, Operation]:
189
+ """Import every module in inferencekit.ops so their decorators run."""
190
+ import inferencekit.ops # noqa: F401 (the package __init__ imports each op module)
191
+
192
+ return REGISTRY
193
+
194
+
195
+ def get(name: str) -> Operation:
196
+ load_all()
197
+ if name not in REGISTRY:
198
+ raise OpError(f"unknown operation '{name}'; known: {sorted(REGISTRY)}")
199
+ return REGISTRY[name]
200
+
201
+
202
+ def call(name: str, raw_args: dict | None = None) -> dict:
203
+ """Validate arguments and run an operation. Both front ends end up here."""
204
+ op = get(name)
205
+ args = op.validate(raw_args)
206
+ return op.handler(Call(op, args), **args)
@@ -0,0 +1,6 @@
1
+ """The review page: every engine's outputs side by side, per input bucket, in one offline HTML file.
2
+
3
+ ``rundir`` reads ``runs/<run-id>/``; ``diff`` compares texts (first divergence, word
4
+ diffs, word errors); ``page`` renders the HTML. The ``review build`` operation
5
+ (``inferencekit.ops.review``) ties them together.
6
+ """
@@ -0,0 +1,163 @@
1
+ """Text comparison for the review page: first divergence, word diffs and word-error alignment.
2
+
3
+ Three questions, three functions:
4
+ * ``first_token_divergence``: where do two greedy generations stop agreeing, in tokens?
5
+ * ``word_diff``: which words of a candidate text differ from the reference text, for
6
+ highlighting (inserted, deleted and replaced runs of words, whitespace preserved)?
7
+ * ``align_words``: which words of a hypothesis transcript are substitutions,
8
+ insertions or deletions against a ground truth, and the word error rate.
9
+
10
+ Everything is plain Python (``difflib`` plus a small Levenshtein table), so the page can
11
+ be built anywhere without extra dependencies.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import re
18
+ import unicodedata
19
+ from dataclasses import dataclass, field
20
+
21
+ _WORD_SPACE = re.compile(r"(\S+)(\s*)")
22
+ _LEAD_SPACE = re.compile(r"\s*")
23
+ # Exact Levenshtein alignment up to this many table cells; above it, difflib anchors the
24
+ # alignment on long matching runs and the table only fills the gaps between them.
25
+ EXACT_CELLS = 250_000
26
+
27
+
28
+ def first_token_divergence(a: list | None, b: list | None) -> int | None:
29
+ """Index of the first differing token id; None if the sequences are identical (length included)."""
30
+ a, b = list(a or []), list(b or [])
31
+ for i, (x, y) in enumerate(zip(a, b)):
32
+ if x != y:
33
+ return i
34
+ return None if len(a) == len(b) else min(len(a), len(b))
35
+
36
+
37
+ def words(text: str) -> tuple[str, list[tuple[str, str]]]:
38
+ """(leading whitespace, [(word, whitespace after it)]): the text, losslessly, as words."""
39
+ text = text or ""
40
+ lead = _LEAD_SPACE.match(text).group(0)
41
+ return lead, _WORD_SPACE.findall(text[len(lead):])
42
+
43
+
44
+ @dataclass
45
+ class TextDiff:
46
+ """A candidate text as segments against a reference text.
47
+
48
+ ``segments`` holds ``(tag, reference_text, candidate_text)`` with tag one of
49
+ ``equal``, ``replace``, ``delete`` (only in the reference) or ``insert`` (only in the
50
+ candidate). Joining every candidate_text gives back the candidate exactly.
51
+ """
52
+
53
+ lead: str
54
+ segments: list[tuple[str, str, str]] = field(default_factory=list)
55
+ first_word: int | None = None # index of the first differing word in the candidate; None = identical
56
+ ref_words: int = 0
57
+ cand_words: int = 0
58
+
59
+ @property
60
+ def identical(self) -> bool:
61
+ return self.first_word is None
62
+
63
+
64
+ def word_diff(reference: str, candidate: str) -> TextDiff:
65
+ """Word-level diff of ``candidate`` against ``reference`` (whitespace kept for display)."""
66
+ _, ref = words(reference)
67
+ lead, cand = words(candidate)
68
+ sm = difflib.SequenceMatcher(a=[w for w, _ in ref], b=[w for w, _ in cand], autojunk=False)
69
+ out = TextDiff(lead=lead, ref_words=len(ref), cand_words=len(cand))
70
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
71
+ r = "".join(w + s for w, s in ref[i1:i2])
72
+ c = "".join(w + s for w, s in cand[j1:j2])
73
+ if tag != "equal" and out.first_word is None:
74
+ out.first_word = j1
75
+ out.segments.append((tag, r, c))
76
+ return out
77
+
78
+
79
+ # --------------------------------------------------------------------------- word errors
80
+
81
+ def normalize_word(word: str) -> str:
82
+ """Lowercase, Unicode NFKC, punctuation removed: the usual WER normalisation for one word."""
83
+ w = unicodedata.normalize("NFKC", word).lower()
84
+ return "".join(ch for ch in w if not unicodedata.category(ch).startswith("P"))
85
+
86
+
87
+ @dataclass
88
+ class Alignment:
89
+ """Word-level alignment of a hypothesis against a ground truth.
90
+
91
+ ``ops`` holds ``(op, reference_word, hypothesis_word)`` with op one of ``ok``, ``sub``,
92
+ ``del`` (a ground-truth word the hypothesis missed) or ``ins`` (an extra hypothesis
93
+ word). Words are shown in their original spelling; matching uses ``normalize_word``.
94
+ """
95
+
96
+ ops: list[tuple[str, str | None, str | None]]
97
+ ref_len: int
98
+ exact: bool # True: minimal edit distance; False: anchored on long matches (very long texts)
99
+
100
+ def count(self, op: str) -> int:
101
+ return sum(1 for o, _, _ in self.ops if o == op)
102
+
103
+ @property
104
+ def wer(self) -> float | None:
105
+ if self.ref_len == 0:
106
+ return None
107
+ return (self.count("sub") + self.count("del") + self.count("ins")) / self.ref_len
108
+
109
+
110
+ def _tokens(text: str) -> list[tuple[str, str]]:
111
+ """(normalised, original) for each word that is not pure punctuation."""
112
+ out = []
113
+ for w in (text or "").split():
114
+ n = normalize_word(w)
115
+ if n:
116
+ out.append((n, w))
117
+ return out
118
+
119
+
120
+ def _levenshtein(ref: list[tuple[str, str]], hyp: list[tuple[str, str]]) -> list[tuple[str, str | None, str | None]]:
121
+ """Minimal-edit alignment (substitution, insertion, deletion all cost 1), by dynamic programming."""
122
+ n, m = len(ref), len(hyp)
123
+ # dist[i][j]: edits to turn ref[:i] into hyp[:j].
124
+ dist = [[0] * (m + 1) for _ in range(n + 1)]
125
+ for i in range(n + 1):
126
+ dist[i][0] = i
127
+ for j in range(m + 1):
128
+ dist[0][j] = j
129
+ for i in range(1, n + 1):
130
+ ri, row, prev = ref[i - 1][0], dist[i], dist[i - 1]
131
+ for j in range(1, m + 1):
132
+ cost = 0 if ri == hyp[j - 1][0] else 1
133
+ row[j] = min(prev[j - 1] + cost, prev[j] + 1, row[j - 1] + 1)
134
+ ops = []
135
+ i, j = n, m
136
+ while i > 0 or j > 0: # walk back from the corner; prefer match/substitution, then deletion
137
+ if i > 0 and j > 0 and dist[i][j] == dist[i - 1][j - 1] + (0 if ref[i - 1][0] == hyp[j - 1][0] else 1):
138
+ ops.append(("ok" if ref[i - 1][0] == hyp[j - 1][0] else "sub", ref[i - 1][1], hyp[j - 1][1]))
139
+ i, j = i - 1, j - 1
140
+ elif i > 0 and dist[i][j] == dist[i - 1][j] + 1:
141
+ ops.append(("del", ref[i - 1][1], None))
142
+ i -= 1
143
+ else:
144
+ ops.append(("ins", None, hyp[j - 1][1]))
145
+ j -= 1
146
+ ops.reverse()
147
+ return ops
148
+
149
+
150
+ def align_words(reference: str, hypothesis: str, exact_cells: int = EXACT_CELLS) -> Alignment:
151
+ """Align a hypothesis transcript to the ground truth: substitutions, insertions, deletions."""
152
+ ref, hyp = _tokens(reference), _tokens(hypothesis)
153
+ if (len(ref) + 1) * (len(hyp) + 1) <= exact_cells:
154
+ return Alignment(_levenshtein(ref, hyp), len(ref), exact=True)
155
+ # Long transcripts (minutes of audio): anchor on matching runs, align each gap exactly.
156
+ sm = difflib.SequenceMatcher(a=[r for r, _ in ref], b=[h for h, _ in hyp], autojunk=False)
157
+ ops: list[tuple[str, str | None, str | None]] = []
158
+ i = j = 0
159
+ for block in sm.get_matching_blocks():
160
+ ops += _levenshtein(ref[i:block.a], hyp[j:block.b])
161
+ ops += [("ok", ref[block.a + k][1], hyp[block.b + k][1]) for k in range(block.size)]
162
+ i, j = block.a + block.size, block.b + block.size
163
+ return Alignment(ops, len(ref), exact=False)