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,427 @@
1
+ """Evidence records: build, validate, store, load.
2
+
3
+ A record is the unit of truth in inferencekit. Its id is ``"ev_" + sha256(canonical JSON
4
+ of the record without evidence_id)``, so:
5
+ * the same id always means the same content (a report citing ev_ab12... cites
6
+ exactly those numbers), and
7
+ * tampering is detectable: ``verify`` recomputes the id.
8
+
9
+ Schemas: new records are written as ``inferencekit.evidence.v2`` (``schemas/evidence.v2.json``);
10
+ v1 records (``inferkit.evidence.v1``) still load and validate against their own schema.
11
+ v2 keeps the v1 envelope and adds serving workloads and the kinds of bench serve, gates and quality,
12
+ and clip workloads and the kinds of bench clip, gates clip and quality clip.
13
+
14
+ Storage is ``<runs>/<evidence_id>/evidence.json`` plus any artifact files (traces,
15
+ logs). ``<runs>`` is ``./runs`` unless ``--runs-dir`` or ``INFERENCEKIT_RUNS_DIR`` says
16
+ otherwise (``INFERKIT_RUNS_DIR``, the 0.1 name, is still read as a deprecated fallback).
17
+ A record made with a ``run_dir`` argument is also copied to ``<run_dir>/evidence/<evidence_id>.json``,
18
+ so one copy of a run directory (e.g. an rsync from a GPU host) carries every record of that run.
19
+
20
+ Secret safety: records never contain environment variable values, and commands are
21
+ stored only as digests. As a second line of defence, ``redact`` runs before hashing and
22
+ replaces (1) any text that contains the value of a secret-looking environment variable
23
+ (``*TOKEN*``, ``*KEY*``...) and (2) the value of any secret-looking *key* in a dict or in
24
+ JSON-like text, such as ``{"hf_token": "..."}`` typed into ``--engine-args``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import json
31
+ import math
32
+ import os
33
+ import re
34
+ import shutil
35
+ import uuid
36
+ from importlib import resources
37
+ from pathlib import Path
38
+ from typing import Any, Iterable
39
+
40
+ from inferencekit import __version__
41
+ from inferencekit.canonical import canonical_json, file_sha256, sha256_hex
42
+ from inferencekit.registry import Call, OpError, utc_now
43
+
44
+ # New records are written as v2; v1 records (written by early pre-release versions) still read and validate.
45
+ SCHEMA_V1 = "inferkit.evidence.v1"
46
+ SCHEMA_V2 = "inferencekit.evidence.v2"
47
+ SCHEMA_ID = SCHEMA_V2
48
+ SCHEMA_FILES = {SCHEMA_V1: "evidence.v1.json", SCHEMA_V2: "evidence.v2.json"}
49
+ KINDS_V1 = ("environment", "roofline", "ceiling", "benchmark", "nsys_capture", "nsys_timeline",
50
+ "ncu_capture", "ncu_kernels", "correctness", "comparison")
51
+ KINDS = KINDS_V1 + ("serve_benchmark", "capacity", "gates", "quality_trajectory", "quality_task", "manifest")
52
+ # Video clips: bench clip, gates clip, quality clip, quality clip-steps.
53
+ KINDS += ("clip_benchmark", "clip_gates", "clip_quality", "clip_steps")
54
+ # Client-timed images and speech. Structural validity never establishes semantic quality.
55
+ KINDS += ("image_benchmark", "audio_benchmark", "rocprof_capture", "rocprof_summary")
56
+ # identity.workload fields: the v1 four are always present (null when unknown); the v2 serving fields
57
+ # are written only when the operation knows them.
58
+ WORKLOAD_V1 = ("prompt_len", "gen_len", "batch", "seed")
59
+ WORKLOAD_V2 = ("mode", "bucket", "concurrency", "rate_rps", "duration_s", "prompt_set", "slo", "endpoint_kind",
60
+ "prefix_cache", "request_set", "image_task_set", "concurrency_source")
61
+ # Clip workloads: the requested shape, the seed policy and the clip set; written only when known.
62
+ WORKLOAD_V2 += ("task", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "seed_policy", "seeds",
63
+ "clip_set", "audio")
64
+ CLAIM_CLASSES = ("observed", "derived", "inferred")
65
+
66
+ _default_root: Path | None = None
67
+
68
+
69
+ def set_default_root(path: str | os.PathLike | None) -> None:
70
+ global _default_root
71
+ _default_root = Path(path) if path else None
72
+
73
+
74
+ def default_root() -> Path:
75
+ if _default_root is not None:
76
+ return _default_root
77
+ return Path(os.environ.get("INFERENCEKIT_RUNS_DIR") or os.environ.get("INFERKIT_RUNS_DIR") or "runs")
78
+
79
+
80
+ # --------------------------------------------------------------------------- building
81
+
82
+ def metric(name: str, value: float | int | None, unit: str, stat: str, n: int | None = None,
83
+ ci95: Iterable[float | None] | None = None, subject: str | None = None) -> dict:
84
+ """One number with its unit and the statistic it is (median, max, ceiling...)."""
85
+ m = {"name": name, "value": _finite(value), "unit": unit, "stat": stat, "n": n,
86
+ "ci95": [_finite(x) for x in ci95] if ci95 is not None else None}
87
+ if subject is not None:
88
+ m["subject"] = subject
89
+ return m
90
+
91
+
92
+ def identity(gpu: dict | None = None, engine: dict | None = None, model: dict | None = None,
93
+ workload: dict | None = None) -> dict:
94
+ """The four things a number depends on. Unknown fields are null, never guessed."""
95
+ g = {"name": None, "count": None, "driver": None, "cuda": None, "clocks": None}
96
+ e = {"name": None, "version": None, "commit": None, "flags": {}}
97
+ m = {"id": None, "revision": None, "dtype": None, "quant": None}
98
+ w = {k: None for k in WORKLOAD_V1}
99
+ for base, extra in ((g, gpu), (e, engine), (m, model), (w, workload)):
100
+ for k, v in (extra or {}).items():
101
+ if k in base:
102
+ base[k] = v
103
+ for k in WORKLOAD_V2: # serving workloads (v2): mode, concurrency grid, prompt set, SLO...
104
+ if (workload or {}).get(k) is not None:
105
+ w[k] = workload[k]
106
+ return {"gpu": g, "engine": e, "model": m, "workload": w}
107
+
108
+
109
+ def build(call: Call, *, kind: str, claim_class: str, summary: str, identity_: dict | None = None,
110
+ metrics: list[dict] | None = None, artifacts: list[dict] | None = None,
111
+ missing: list[str] | None = None, limitations: list[str] | None = None,
112
+ data: dict | None = None) -> dict:
113
+ """Assemble a record (without its id). ``missing`` lists what could not be measured."""
114
+ if kind not in KINDS:
115
+ raise ValueError(f"unknown evidence kind {kind}")
116
+ if claim_class not in CLAIM_CLASSES:
117
+ raise ValueError(f"unknown claim class {claim_class}")
118
+ missing = sorted(set(missing or []))
119
+ body_data = dict(data or {})
120
+ body_data.setdefault("request", call.request())
121
+ return {
122
+ "schema": SCHEMA_ID,
123
+ "kind": kind,
124
+ "claim_class": claim_class,
125
+ "summary": summary[:600],
126
+ "identity": identity_ or identity(),
127
+ "provenance": {"tool": "inferencekit", "tool_version": __version__, "argv_digest": call.argv_digest(),
128
+ "started": call.started, "ended": utc_now()},
129
+ "metrics": metrics or [],
130
+ "artifacts": artifacts or [],
131
+ "coverage": {"complete": not missing, "missing": missing},
132
+ "limitations": _dedupe(limitations or []),
133
+ "data": body_data,
134
+ }
135
+
136
+
137
+ def artifact(path: str | os.PathLike, run_dir: str | os.PathLike | None, role: str) -> dict:
138
+ """Describe a file by content hash. Paths inside the run dir are stored relative to it."""
139
+ p = Path(path)
140
+ sha, size = file_sha256(p)
141
+ if run_dir is not None:
142
+ try:
143
+ rel = p.resolve().relative_to(Path(run_dir).resolve())
144
+ return {"path": rel.as_posix(), "sha256": sha, "bytes": size, "role": role}
145
+ except ValueError:
146
+ pass
147
+ # An input that lives elsewhere (e.g. a trace the user passed in): keep the path
148
+ # relative to the working directory when possible, so home directories do not leak.
149
+ try:
150
+ shown = p.resolve().relative_to(Path.cwd().resolve()).as_posix()
151
+ except ValueError:
152
+ shown = str(p)
153
+ return {"path": shown, "sha256": sha, "bytes": size, "role": role}
154
+
155
+
156
+ def _dedupe(items: list[str]) -> list[str]:
157
+ seen, out = set(), []
158
+ for s in items:
159
+ if s not in seen:
160
+ seen.add(s)
161
+ out.append(s)
162
+ return out
163
+
164
+
165
+ def _finite(x):
166
+ """NaN/inf cannot be stored in canonical JSON; they mean 'undefined', i.e. null."""
167
+ if isinstance(x, float) and (math.isnan(x) or math.isinf(x)):
168
+ return None
169
+ return x
170
+
171
+
172
+ def _clean(value: Any) -> Any:
173
+ if isinstance(value, dict):
174
+ return {str(k): _clean(v) for k, v in value.items()}
175
+ if isinstance(value, (list, tuple)):
176
+ return [_clean(v) for v in value]
177
+ if isinstance(value, float):
178
+ return _finite(value)
179
+ return value
180
+
181
+
182
+ # --------------------------------------------------------------------------- secrets
183
+
184
+ # Environment variable NAMES that usually hold a secret (HF_TOKEN, OPENAI_API_KEY, AWS_SECRET_ACCESS_KEY...).
185
+ _SECRET_NAME = re.compile(r"TOKEN|SECRET|PASSW|(^|_)KEY($|_)|API_?KEY|ACCESS_?KEY|PRIVATE|CREDENTIAL|AUTH|COOKIE",
186
+ re.IGNORECASE)
187
+ # Dict / JSON KEYS that hold a secret. Stricter than _SECRET_NAME on purpose: the key must *end* in the
188
+ # secret word, so "hf_token" and "api_key" match but "max_num_batched_tokens" and "tokenizer" do not.
189
+ _SECRET_KEY = re.compile(r"(^|[_-])(token|api_?key|access_?key|secret(_?key)?|password|passwd|credentials?|auth)$",
190
+ re.IGNORECASE)
191
+ # "key": "value", 'key': 'value' (JSON or a Python dict repr) or key="value". Group 2 is the key; the
192
+ # lookbehind and the length cap keep matching linear on long runs of word characters (hex dumps, logs).
193
+ _KEY_VALUE = re.compile(r"""(["']?)(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]{1,64})\1(\s*[:=]\s*)(["'])(.*?)(?<!\\)\4""")
194
+ REDACTED = "<redacted>"
195
+
196
+
197
+ def looks_secret_name(name: str) -> bool:
198
+ """True for environment variable names such as HF_TOKEN or MY_API_KEY."""
199
+ return bool(_SECRET_NAME.search(name))
200
+
201
+
202
+ def looks_secret_key(key: str) -> bool:
203
+ """True for dict / JSON keys such as hf_token, api_key or password."""
204
+ return bool(_SECRET_KEY.search(str(key)))
205
+
206
+
207
+ def _secret_values() -> list[tuple[str, str]]:
208
+ """(name, value) of environment variables whose *names* look secret. Values stay in memory only."""
209
+ out = []
210
+ for name, value in os.environ.items():
211
+ if looks_secret_name(name) and value and len(value) >= 8: # short values would redact common words
212
+ out.append((name, value))
213
+ return sorted(out, key=lambda nv: -len(nv[1]))
214
+
215
+
216
+ def _redact_key_value(m: re.Match) -> str:
217
+ if not looks_secret_key(m.group(2)):
218
+ return m.group(0)
219
+ q = m.group(4)
220
+ return f"{m.group(1)}{m.group(2)}{m.group(1)}{m.group(3)}{q}{REDACTED}{q}"
221
+
222
+
223
+ def redact_text(text: str) -> str:
224
+ for name, value in _secret_values():
225
+ if value in text:
226
+ text = text.replace(value, f"<redacted:{name}>")
227
+ # A secret typed straight into a JSON argument or echoed in a config dump: '"hf_token": "hf_..."'.
228
+ text = _KEY_VALUE.sub(_redact_key_value, text)
229
+ # Paths under the home directory name the user; write them as ~/... instead.
230
+ home = os.path.expanduser("~").rstrip("/")
231
+ if len(home) > 1 and home + "/" in text:
232
+ text = text.replace(home + "/", "~/")
233
+ return text
234
+
235
+
236
+ def redact(value: Any) -> Any:
237
+ if isinstance(value, dict):
238
+ return {k: REDACTED if looks_secret_key(k) and isinstance(v, str) else redact(v) for k, v in value.items()}
239
+ if isinstance(value, list):
240
+ return [redact(v) for v in value]
241
+ if isinstance(value, str):
242
+ return redact_text(value)
243
+ return value
244
+
245
+
246
+ # --------------------------------------------------------------------------- ids and schema
247
+
248
+ def compute_id(record: dict) -> str:
249
+ body = {k: v for k, v in record.items() if k != "evidence_id"}
250
+ return "ev_" + sha256_hex(canonical_json(body))
251
+
252
+
253
+ def finalize(record: dict) -> dict:
254
+ """Clean numbers, redact secrets, then stamp the content hash as the id."""
255
+ body = redact(_clean({k: v for k, v in record.items() if k != "evidence_id"}))
256
+ eid = compute_id(body)
257
+ ordered = {"schema": body["schema"], "evidence_id": eid}
258
+ ordered.update({k: v for k, v in body.items() if k != "schema"})
259
+ return ordered
260
+
261
+
262
+ def schema_path(schema_id: str = SCHEMA_ID) -> Path:
263
+ """The schema file for ``schema_id``: the copy shipped in the wheel, or the repo copy in a checkout."""
264
+ name = SCHEMA_FILES.get(schema_id)
265
+ if name is None:
266
+ raise ValueError(f"unknown evidence schema {schema_id!r}; known: {sorted(SCHEMA_FILES)}")
267
+ try:
268
+ packaged = resources.files("inferencekit").joinpath(f"schemas/{name}")
269
+ if packaged.is_file():
270
+ return Path(str(packaged))
271
+ except (ModuleNotFoundError, FileNotFoundError):
272
+ pass
273
+ return Path(__file__).resolve().parents[2] / "schemas" / name
274
+
275
+
276
+ _schema_cache: dict[str, dict] = {}
277
+
278
+
279
+ def load_schema(schema_id: str = SCHEMA_ID) -> dict:
280
+ if schema_id not in _schema_cache:
281
+ _schema_cache[schema_id] = json.loads(schema_path(schema_id).read_text())
282
+ return _schema_cache[schema_id]
283
+
284
+
285
+ def validate(record: dict) -> list[str]:
286
+ """Schema errors as readable strings (empty list = valid). The record's own ``schema`` picks v1 or v2."""
287
+ try:
288
+ import jsonschema
289
+ except ImportError: # pragma: no cover - jsonschema is a declared dependency
290
+ return ["jsonschema not installed: record not validated"]
291
+ schema_id = record.get("schema") if isinstance(record, dict) else None
292
+ if schema_id not in SCHEMA_FILES:
293
+ return [f"schema: {schema_id!r} is not a known evidence schema ({', '.join(sorted(SCHEMA_FILES))})"]
294
+ validator = jsonschema.Draft202012Validator(load_schema(schema_id))
295
+ errors = []
296
+ for err in sorted(validator.iter_errors(record), key=lambda e: list(e.path)):
297
+ where = "/".join(str(p) for p in err.path) or "(root)"
298
+ errors.append(f"{where}: {err.message}")
299
+ return errors
300
+
301
+
302
+ def verify(record: dict) -> dict:
303
+ """Integrity check: does the id match the content, and does the record fit the schema?"""
304
+ errors = validate(record)
305
+ return {"id_ok": compute_id(record) == record.get("evidence_id"), "schema_ok": not errors,
306
+ "schema_errors": errors[:20]}
307
+
308
+
309
+ def integrity_problem(record: dict) -> str | None:
310
+ """None when a record's id matches its content; otherwise a one-line reason.
311
+
312
+ Operations that build a new record from stored ones (compare, correctness, roofline
313
+ with a measured ceiling...) call this on each input. Without it, a hand-edited input
314
+ would be laundered into a fresh record whose own id verifies.
315
+ """
316
+ if compute_id(record) != record.get("evidence_id"):
317
+ return f"{record.get('evidence_id')} does not match its content (edited after capture?)"
318
+ return None
319
+
320
+
321
+ # --------------------------------------------------------------------------- the store
322
+
323
+ def _copy_to_run_dir(rec: dict, text: str) -> None:
324
+ """Also write the record to ``<run_dir>/evidence/<id>.json`` when it was made with a ``run_dir``
325
+ argument (bench serve, bench rounds, gates, quality...), so the run directory alone carries its
326
+ evidence: review build and ledger trailers work on a synced copy of ``runs/<run-id>/`` with no store.
327
+ Best effort: the store copy is the one that counts."""
328
+ args = ((rec.get("data") or {}).get("request") or {}).get("args")
329
+ rd = args.get("run_dir") if isinstance(args, dict) else None
330
+ if not isinstance(rd, str) or not rd:
331
+ return
332
+ try:
333
+ run = Path(rd).expanduser()
334
+ if not run.is_dir():
335
+ return
336
+ d = run / "evidence"
337
+ d.mkdir(exist_ok=True)
338
+ (d / f"{rec['evidence_id']}.json").write_text(text)
339
+ except OSError:
340
+ return
341
+
342
+
343
+ class Store:
344
+ """Files under ``<root>/<evidence_id>/``. Staging dirs hold artifacts until the id is known."""
345
+
346
+ def __init__(self, root: str | os.PathLike | None = None):
347
+ self.root = Path(root) if root else default_root()
348
+
349
+ def staging(self) -> Path:
350
+ """A scratch dir for an operation's artifacts; ``save`` moves it to the run dir."""
351
+ d = self.root / ".staging" / uuid.uuid4().hex
352
+ d.mkdir(parents=True, exist_ok=True)
353
+ return d
354
+
355
+ @contextlib.contextmanager
356
+ def staging_dir(self):
357
+ """``with store.staging_dir() as staging:`` -- a staging dir that is deleted if the block fails.
358
+
359
+ On success ``save`` has already moved its files into the run dir. On any error
360
+ (including Ctrl-C) the directory and everything in it is removed, so raw program
361
+ output that was never redacted does not stay behind in ``runs/.staging``.
362
+ """
363
+ d = self.staging()
364
+ try:
365
+ yield d
366
+ except BaseException:
367
+ shutil.rmtree(d, ignore_errors=True)
368
+ raise
369
+
370
+ def run_dir(self, evidence_id: str) -> Path:
371
+ return self.root / evidence_id
372
+
373
+ def save(self, record: dict, staging: Path | None = None) -> dict:
374
+ rec = finalize(record)
375
+ errors = validate(rec)
376
+ if errors:
377
+ raise OpError("evidence record failed schema validation: " + "; ".join(errors[:5]))
378
+ target = self.run_dir(rec["evidence_id"])
379
+ target.mkdir(parents=True, exist_ok=True)
380
+ if staging is not None and staging.exists():
381
+ for item in staging.iterdir():
382
+ dest = target / item.name
383
+ if dest.exists():
384
+ if dest.is_dir():
385
+ shutil.rmtree(dest)
386
+ else:
387
+ dest.unlink()
388
+ shutil.move(str(item), str(dest))
389
+ shutil.rmtree(staging, ignore_errors=True)
390
+ text = json.dumps(rec, indent=2, ensure_ascii=False) + "\n"
391
+ (target / "evidence.json").write_text(text)
392
+ _copy_to_run_dir(rec, text)
393
+ return rec
394
+
395
+ def paths(self) -> list[Path]:
396
+ if not self.root.exists():
397
+ return []
398
+ return sorted(self.root.glob("ev_*/evidence.json"))
399
+
400
+ def load(self, ref: str) -> dict:
401
+ """Load by full id, unique id prefix (like git), or a path to an evidence.json."""
402
+ p = Path(ref)
403
+ if p.suffix == ".json" and p.is_file():
404
+ return json.loads(p.read_text())
405
+ if p.is_dir() and (p / "evidence.json").is_file():
406
+ return json.loads((p / "evidence.json").read_text())
407
+ ref = ref if ref.startswith("ev_") else "ev_" + ref
408
+ exact = self.run_dir(ref) / "evidence.json"
409
+ if exact.is_file():
410
+ return json.loads(exact.read_text())
411
+ if len(ref) < 3 + 6:
412
+ raise OpError(f"evidence prefix '{ref}' is too short (use at least 6 hex characters)")
413
+ matches = [q for q in self.paths() if q.parent.name.startswith(ref)]
414
+ if not matches:
415
+ raise OpError(f"no evidence matching '{ref}' under {self.root}")
416
+ if len(matches) > 1:
417
+ raise OpError(f"evidence prefix '{ref}' is ambiguous ({len(matches)} matches)")
418
+ return json.loads(matches[0].read_text())
419
+
420
+ def records(self) -> list[dict]:
421
+ out = []
422
+ for q in self.paths():
423
+ try:
424
+ out.append(json.loads(q.read_text()))
425
+ except (OSError, ValueError):
426
+ continue
427
+ return out
@@ -0,0 +1,93 @@
1
+ """GPU facts from nvidia-smi, shared by doctor and by every GPU-touching operation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from inferencekit.proc import run, which
8
+
9
+ # Asked for one by one if the combined query fails (older drivers lack some fields).
10
+ QUERY_FIELDS = [
11
+ "index", "name", "uuid", "driver_version", "memory.total", "compute_cap", "pstate", "persistence_mode",
12
+ "clocks.sm", "clocks.mem", "clocks.max.sm", "clocks.max.mem", "clocks.applications.graphics",
13
+ "clocks.default_applications.graphics", "clocks.applications.memory", "clocks.default_applications.memory",
14
+ "power.limit", "temperature.gpu",
15
+ ]
16
+
17
+
18
+ def _query(fields: list[str]) -> list[dict] | None:
19
+ smi = which("nvidia-smi")
20
+ if not smi:
21
+ return None
22
+ r = run([smi, f"--query-gpu={','.join(fields)}", "--format=csv,noheader,nounits"], timeout=30)
23
+ if not r.ok:
24
+ return None
25
+ rows = []
26
+ for line in r.stdout.strip().splitlines():
27
+ parts = [p.strip() for p in line.split(",")]
28
+ if len(parts) == len(fields):
29
+ rows.append({f: (None if v in ("[N/A]", "N/A", "[Not Supported]", "") else v) for f, v in zip(fields, parts)})
30
+ return rows
31
+
32
+
33
+ def query_gpus() -> list[dict]:
34
+ """One dict per visible GPU (empty list without nvidia-smi or a driver)."""
35
+ rows = _query(QUERY_FIELDS)
36
+ if rows is not None:
37
+ return rows
38
+ # Field-by-field fallback: keep whatever this driver supports.
39
+ base = _query(["index", "name", "driver_version"])
40
+ if not base:
41
+ return []
42
+ for f in QUERY_FIELDS:
43
+ if f in ("index", "name", "driver_version"):
44
+ continue
45
+ extra = _query(["index", f])
46
+ if extra:
47
+ by_index = {e["index"]: e[f] for e in extra}
48
+ for row in base:
49
+ row[f] = by_index.get(row["index"])
50
+ return base
51
+
52
+
53
+ def driver_cuda_version() -> str | None:
54
+ """Highest CUDA version the driver supports, from the nvidia-smi banner."""
55
+ smi = which("nvidia-smi")
56
+ if not smi:
57
+ return None
58
+ r = run([smi], timeout=30)
59
+ m = re.search(r"CUDA Version:\s*([\d.]+)", r.stdout)
60
+ return m.group(1) if m else None
61
+
62
+
63
+ def clocks_state(gpu: dict) -> str | None:
64
+ """'locked' when application clocks differ from their defaults, else 'default'.
65
+
66
+ Clock locks matter because a benchmark at boost clocks and one at locked base
67
+ clocks are different experiments. Locks set with ``nvidia-smi -lgc`` are not
68
+ exposed by every driver; doctor records that caveat.
69
+ """
70
+ pairs = [("clocks.applications.graphics", "clocks.default_applications.graphics"),
71
+ ("clocks.applications.memory", "clocks.default_applications.memory")]
72
+ known = False
73
+ for cur, default in pairs:
74
+ if gpu.get(cur) and gpu.get(default):
75
+ known = True
76
+ if gpu[cur] != gpu[default]:
77
+ return "locked"
78
+ return "default" if known else None
79
+
80
+
81
+ def gpu_identity(gpus: list[dict] | None = None) -> dict:
82
+ """The ``identity.gpu`` block of an evidence record (first visible GPU, plus count)."""
83
+ gpus = query_gpus() if gpus is None else gpus
84
+ if not gpus:
85
+ from inferencekit.hardware import discover_non_nvidia
86
+ devices = discover_non_nvidia()["devices"]
87
+ if not devices:
88
+ return {}
89
+ return {"name": devices[0]["name"], "count": len(devices), "driver": None,
90
+ "cuda": None, "clocks": None}
91
+ g = gpus[0]
92
+ return {"name": g.get("name"), "count": len(gpus), "driver": g.get("driver_version"),
93
+ "cuda": driver_cuda_version(), "clocks": clocks_state(g)}
@@ -0,0 +1,135 @@
1
+ """Read-only accelerator discovery; no framework import or test kernel is launched.
2
+
3
+ NVIDIA discovery remains in gpuinfo. AMD reads Linux DRM sysfs, Apple reads
4
+ selected system_profiler/sysctl fields. Missing fields stay unknown. No host,
5
+ serial, UUID, or raw vendor output is retained.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import shlex
14
+ from pathlib import Path
15
+
16
+ from inferencekit.proc import run, which
17
+
18
+
19
+ def _read(path: Path) -> str | None:
20
+ try:
21
+ return path.read_text().strip() or None
22
+ except OSError:
23
+ return None
24
+
25
+
26
+ def _sysctl(key: str) -> str | None:
27
+ result = run(["sysctl", "-n", key], timeout=15)
28
+ return result.stdout.strip() if result.ok else None
29
+
30
+
31
+ def amd_devices(root: Path = Path("/sys/class/drm")) -> list[dict]:
32
+ devices = []
33
+ seen = set()
34
+ for card in sorted(root.glob("card*")):
35
+ if not re.fullmatch(r"card\d+", card.name):
36
+ continue
37
+ device = card / "device"
38
+ if _read(device / "vendor") != "0x1002":
39
+ continue
40
+ cls = _read(device / "class")
41
+ if cls and not cls.startswith("0x03"):
42
+ continue
43
+ resolved = device.resolve()
44
+ if resolved in seen:
45
+ continue
46
+ seen.add(resolved)
47
+ name = _read(device / "product_name")
48
+ name_source = "sysfs product_name" if name else None
49
+ pci_id = _read(device / "device")
50
+ if not name and which("lspci"):
51
+ result = run(["lspci", "-s", resolved.name, "-mm"], timeout=15)
52
+ try:
53
+ fields = shlex.split(result.stdout.strip()) if result.ok else []
54
+ except ValueError:
55
+ fields = []
56
+ if len(fields) >= 4:
57
+ name, name_source = fields[3], "lspci PCI database"
58
+ memory = _read(device / "mem_info_vram_total")
59
+ devices.append({"name": name or f"AMD GPU ({pci_id or 'unknown PCI device'})",
60
+ "name_source": name_source or "PCI id only; product unverified",
61
+ "backend": "rocm", "memory_bytes": int(memory) if memory and memory.isdigit() else None,
62
+ "memory_kind": "vram", "pci_device_id": pci_id,
63
+ "source": "Linux DRM sysfs"})
64
+ return devices
65
+
66
+
67
+ def rocm_devices() -> list[dict]:
68
+ """Fallback for WSL /dev/dxg and hosts without DRM sysfs; CPU agents do not count.
69
+
70
+ rocminfo enumerates agents but launches no kernels. Its heap sizes are not
71
+ labelled dedicated VRAM here: under WSL they can describe shared memory.
72
+ """
73
+ executable = which("rocminfo")
74
+ if not executable:
75
+ return []
76
+ result = run([executable], timeout=30)
77
+ if not result.ok:
78
+ return []
79
+ devices = []
80
+ for block in re.split(r"(?m)^\s*Agent\s+\d+\s*$", result.stdout):
81
+ name = re.search(r"(?m)^\s*Name:\s*(gfx[0-9a-f]+)\s*$", block)
82
+ kind = re.search(r"(?m)^\s*Device Type:\s*(\w+)", block)
83
+ if not name or not kind or kind.group(1) != "GPU":
84
+ continue
85
+ marketing = re.search(r"(?m)^\s*Marketing Name:\s*(.+)$", block)
86
+ product = marketing.group(1).strip() if marketing else None
87
+ devices.append({"name": product or f"AMD GPU ({name.group(1)})", "architecture": name.group(1),
88
+ "name_source": "rocminfo Marketing Name" if product else "rocminfo gfx target only",
89
+ "backend": "rocm", "memory_bytes": None, "memory_kind": "vram",
90
+ "source": "rocminfo GPU agent; dedicated VRAM not verified"})
91
+ return devices
92
+
93
+
94
+ def apple_devices() -> list[dict]:
95
+ if platform.system() != "Darwin" or platform.machine() not in ("arm64", "aarch64"):
96
+ return []
97
+ brand, memory = _sysctl("machdep.cpu.brand_string"), _sysctl("hw.memsize")
98
+ if not brand or not brand.startswith("Apple"):
99
+ return []
100
+ result = run(["system_profiler", "SPDisplaysDataType", "-json"], timeout=30)
101
+ try:
102
+ rows = json.loads(result.stdout).get("SPDisplaysDataType", []) if result.ok else []
103
+ except (ValueError, AttributeError):
104
+ rows = []
105
+ # On Apple Silicon this is one GPU sharing system memory, not dedicated VRAM.
106
+ gpu = next((row for row in rows if str(row.get("sppci_vendor", "")).lower().startswith("sppci_vendor_apple")), {})
107
+ return [{"name": gpu.get("sppci_model") or brand, "name_source": "system_profiler/sysctl",
108
+ "backend": "metal", "memory_bytes": int(memory) if memory and memory.isdigit() else None,
109
+ "memory_kind": "unified", "source": "macOS system_profiler/sysctl"}]
110
+
111
+
112
+ def discover_non_nvidia() -> dict:
113
+ devices = apple_devices() or amd_devices() or rocm_devices()
114
+ accelerator_interface = any(Path(p).exists() for p in ("/dev/dxg", "/dev/kfd", "/dev/dri"))
115
+ backend = devices[0]["backend"] if devices else "unknown" if accelerator_interface else "cpu"
116
+ return {"platform": backend, "devices": devices,
117
+ "cpu": {"architecture": platform.machine(), "logical_cores": os.cpu_count()},
118
+ "limitations": (["Device identification does not prove runtime or profiler support; verify the exact "
119
+ "GPU, OS and installed runtime against current official docs and a smoke request."]
120
+ if devices else ["Accelerator identity is unknown; a device interface exists but vendor "
121
+ "discovery failed." if accelerator_interface else
122
+ "No supported accelerator detected; a missing driver or unsupported "
123
+ "device can also produce this result."])}
124
+
125
+
126
+ def profiler_route(backend: str) -> dict:
127
+ return {
128
+ "nvidia": {"timeline": "nsys", "kernel": "ncu", "documentation": "https://docs.nvidia.com/nsight-systems/"},
129
+ "rocm": {"timeline": "rocprofv3", "kernel": "rocprofv3 counters (verify GPU support)",
130
+ "documentation": "https://rocm.docs.amd.com/projects/rocprofiler-sdk/en/latest/how-to/using-rocprofv3.html"},
131
+ "metal": {"timeline": "Instruments Metal System Trace", "kernel": "Xcode Metal debugger / MLX capture",
132
+ "documentation": "https://developer.apple.com/documentation/xcode/optimizing-gpu-performance"},
133
+ "cpu": {"timeline": "perf / Instruments Time Profiler", "kernel": "platform CPU profiler",
134
+ "documentation": "https://www.kernel.org/doc/html/latest/admin-guide/perf-security.html"},
135
+ }.get(backend, {"timeline": None, "kernel": None, "documentation": None})