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,749 @@
1
+ """ledger trailers / history: every kept commit carries its numbers as git trailers.
2
+
3
+ ``ledger trailers <ev...>`` turns evidence records into a trailer block for a commit message:
4
+
5
+ Perf-Workload: Qwen/Qwen3-8B FP8, RTX 5090, vllm 0.11.0, batch 1, prompt 1024, gen 256
6
+ Perf-Decode-Tok-S: 185.3 (95% CI 185.2-185.3) [+2.1% vs 1a2b3c4]
7
+ Perf-TTFT-Ms: 60.7 (95% CI 60.1-61.0)
8
+ Perf-P95-Latency-Ms: 1452
9
+ Perf-Quality: greedy prefix agreement 100.00% (5/5 identical) PASS
10
+ Perf-Cost: $0.93 per 1M output tokens at $0.62/GPU-h (1 stream)
11
+ Perf-Capacity-N: 24 streams (SLO p95 TTFT <= 300 ms, p95 TPOT <= 50 ms, failures <= 0)
12
+ Perf-Goodput: 18.2 req/s at N*=24
13
+ Perf-Evidence: ev_3f2a..., ev_9c1d...
14
+ Perf-Baseline: 1a2b3c4
15
+
16
+ Rules: a metric the evidence does not contain is omitted (listed under ``omitted`` with the
17
+ reason), never written as zero. Values are read by field presence, so v1 ``benchmark`` and
18
+ v2 ``serve_benchmark`` / ``capacity`` / ``quality_*`` records both work. The delta against a
19
+ baseline comes from a ``comparison`` record when one is given (ratio of medians with its
20
+ CI and verdict); otherwise from ``--baseline-ev`` as a plain ratio of medians, labelled
21
+ "ratio of medians, no CI" inside the trailer itself. Values never contain a newline, so
22
+ evidence text cannot forge extra trailers.
23
+
24
+ One commit, one engine: speed and capacity records of more than one engine label (or more
25
+ than one single-user speed record) are refused unless ``--label`` picks one; the baseline's
26
+ record goes in ``--baseline-ev``. Prices are per GPU-hour, as ``bench serve`` takes them: a
27
+ capacity record's own cost is printed at its own price, and a different ``--gpu-price-usd-h``
28
+ reprices it from the record's per-GPU throughput at N* (derived). In text mode the trailer
29
+ block goes to stdout (for ``git commit -m "$(...)"``) and what was omitted or limited goes to
30
+ stderr, so it is seen but never lands in the commit message.
31
+
32
+ ``history`` reads the trailers back with ``git log``, prints a table and a sparkline per
33
+ metric (oldest to newest), and with ``--tag-best`` points local tags ``perf/best-<metric>``
34
+ at the best commit. Tags are never pushed.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ import statistics
42
+ import sys
43
+ from pathlib import Path
44
+
45
+ from inferencekit import evidence as ev
46
+ from inferencekit.canonical import sha256_hex
47
+ from inferencekit.proc import run as run_cmd
48
+ from inferencekit.registry import Arg, OpError, operation
49
+
50
+ ORDER = ("Perf-Workload", "Perf-Decode-Tok-S", "Perf-TTFT-Ms", "Perf-P95-Latency-Ms", "Perf-Quality", "Perf-Cost",
51
+ "Perf-Capacity-N", "Perf-Goodput", "Perf-Evidence", "Perf-Baseline")
52
+ SPEED_KINDS = ("benchmark", "serve_benchmark")
53
+ # Metric names accepted for each headline (v1 names first; v2 aliases after).
54
+ DECODE = ("decode_tok_s", "decode_tok_s_median")
55
+ TTFT = ("ttft_ms", "ttft_p50_ms", "ttft_median_ms")
56
+ P95_DIRECT = ("e2e_p95_ms", "p95_e2e_ms", "latency_p95_ms", "e2e_latency_p95_ms", "total_p95_ms")
57
+ P95_BY_STAT = ("e2e_ms", "e2e_latency_ms", "latency_ms", "total_ms")
58
+ N_STAR = ("n_star", "capacity_n", "n_star_streams")
59
+ GOODPUT = ("goodput_rps", "goodput_req_s", "goodput")
60
+ THROUGHPUT_PER_GPU = ("output_tok_s_per_gpu", "output_tok_s", "throughput_tok_s")
61
+ COST = ("usd_per_1m_output_tokens", "cost_usd_per_1m_output_tokens", "usd_per_1m_tokens", "cost_per_1m_tokens_usd")
62
+ CENTRAL = (None, "median", "p50", "mean")
63
+ SPARK = "▁▂▃▄▅▆▇█"
64
+ MAX_SUBJECTS = 6
65
+
66
+
67
+ def short(eid: str | None) -> str:
68
+ return (eid or "?")[:15]
69
+
70
+
71
+ def one_line(text) -> str:
72
+ """Trailer values are one line: newlines (and other control characters) become spaces."""
73
+ return re.sub(r"[\x00-\x1f\x7f]+", " ", str(text)).strip()
74
+
75
+
76
+ def num(x: float | None, digits: int = 4) -> str:
77
+ if x is None:
78
+ return "n/a"
79
+ if float(x).is_integer() and abs(x) < 1e15:
80
+ return str(int(x))
81
+ if abs(x) >= 1000:
82
+ return f"{x:.0f}"
83
+ if abs(x) >= 100:
84
+ return f"{x:.1f}"
85
+ return f"{x:.{digits}g}"
86
+
87
+
88
+ # --------------------------------------------------------------------------- reading records
89
+
90
+ def metrics_named(rec: dict, names, stats=CENTRAL) -> list[dict]:
91
+ out = []
92
+ for name in names:
93
+ for m in rec.get("metrics") or []:
94
+ if not isinstance(m, dict) or m.get("name") != name or m.get("value") is None:
95
+ continue
96
+ if stats is not None and m.get("stat") not in stats:
97
+ continue
98
+ out.append(m)
99
+ if out:
100
+ return out
101
+ return out
102
+
103
+
104
+ def pick(recs: list[dict], names, stats=CENTRAL, kinds=None) -> tuple[list[dict], dict] | None:
105
+ """The first record (in the order given) holding metric ``names``: (metrics, record).
106
+
107
+ A metric without a subject/bucket is the overall value and wins; otherwise every
108
+ per-subject value is returned (e.g. one per input bucket).
109
+ """
110
+ for rec in recs:
111
+ if kinds and rec.get("kind") not in kinds:
112
+ continue
113
+ found = metrics_named(rec, names, stats)
114
+ if not found:
115
+ continue
116
+ overall = [m for m in found if m.get("subject") in (None, "", "all") and not m.get("bucket")]
117
+ return (overall[:1] if overall else found[:MAX_SUBJECTS]), rec
118
+ return None
119
+
120
+
121
+ def kind_of(rec: dict) -> str | None:
122
+ """The record's kind; v2 kinds written as a v1 kind plus data.v2_kind (before the v2 schema merged) count."""
123
+ data = rec.get("data") or {}
124
+ return data.get("v2_kind") or ("gates" if data.get("gates_format") else rec.get("kind"))
125
+
126
+
127
+ def gpu_count(rec: dict) -> int:
128
+ g = (rec.get("identity") or {}).get("gpu") or {}
129
+ n = g.get("count") or (rec.get("data") or {}).get("gpu_count") or 1
130
+ return int(n) if isinstance(n, (int, float)) and n >= 1 else 1
131
+
132
+
133
+ def is_load(rec: dict) -> bool:
134
+ return ((rec.get("identity") or {}).get("workload") or {}).get("mode") == "load"
135
+
136
+
137
+ def label_of(rec: dict) -> str | None:
138
+ label = (rec.get("data") or {}).get("label") or ((rec.get("identity") or {}).get("engine") or {}).get("name")
139
+ return str(label) if label else None
140
+
141
+
142
+ def with_ci(m: dict) -> str:
143
+ text = num(m["value"])
144
+ ci = m.get("ci95")
145
+ if isinstance(ci, list) and len(ci) == 2 and None not in ci:
146
+ text += f" (95% CI {num(ci[0])}-{num(ci[1])})"
147
+ return text
148
+
149
+
150
+ def values_text(ms: list[dict]) -> str:
151
+ if len(ms) == 1:
152
+ return with_ci(ms[0])
153
+ # Several subjects (e.g. buckets) and no overall value: the first number is the first subject's.
154
+ return "; ".join(f"{with_ci(m)} {one_line(m.get('subject') or m.get('bucket'))}" for m in ms)
155
+
156
+
157
+ def slo_text(slo: dict | None) -> str:
158
+ if not isinstance(slo, dict):
159
+ return ""
160
+ parts = []
161
+ if slo.get("ttft_p95_ms") is not None:
162
+ parts.append(f"p95 TTFT <= {num(slo['ttft_p95_ms'])} ms")
163
+ if slo.get("tpot_p95_ms") is not None:
164
+ parts.append(f"p95 TPOT <= {num(slo['tpot_p95_ms'])} ms")
165
+ if slo.get("max_fail") is not None:
166
+ parts.append(f"failures <= {slo['max_fail']}")
167
+ return ", ".join(parts)
168
+
169
+
170
+ def workload_text(rec: dict) -> str:
171
+ ident = rec.get("identity") or {}
172
+ m, g, e, w = (ident.get(k) or {} for k in ("model", "gpu", "engine", "workload"))
173
+ parts = []
174
+ model = " ".join(str(x) for x in (m.get("id"), (m.get("quant") or m.get("dtype") or "").upper() or None) if x)
175
+ if model:
176
+ parts.append(model)
177
+ if g.get("name"):
178
+ parts.append(f"{g['count']}x {g['name']}" if (g.get("count") or 1) > 1 else str(g["name"]))
179
+ if e.get("name"):
180
+ parts.append(" ".join(str(x) for x in (e.get("name"), e.get("version")) if x))
181
+ if w.get("mode"):
182
+ parts.append({"single": "single-user", "load": "open-loop load"}.get(w["mode"], str(w["mode"])))
183
+ for key, label in (("batch", "batch"), ("concurrency", "concurrency"), ("rate_rps", "rate"),
184
+ ("prompt_len", "prompt"), ("gen_len", "gen"), ("bucket", "bucket")):
185
+ v = w.get(key)
186
+ if v is not None:
187
+ shown = ",".join(num(x) if isinstance(x, (int, float)) else str(x) for x in v) if isinstance(v, list) \
188
+ else (num(v) if isinstance(v, float) else v)
189
+ parts.append(f"{label} {shown}" + (" req/s" if key == "rate_rps" else ""))
190
+ ps = w.get("prompt_set")
191
+ if isinstance(ps, dict) and ps.get("name"):
192
+ parts.append(f"prompts {ps['name']}" + (f" ({str(ps['sha256'])[:12]})" if ps.get("sha256") else ""))
193
+ return one_line(", ".join(parts))
194
+
195
+
196
+ # --------------------------------------------------------------------------- building trailers
197
+
198
+ class Builder:
199
+ def __init__(self):
200
+ self.trailers: list[dict] = []
201
+ self.omitted: list[dict] = []
202
+ self.limitations: list[str] = []
203
+
204
+ def add(self, key: str, value: str, claim_class: str, sources: list[str]):
205
+ self.trailers.append({"key": key, "value": one_line(value), "claim_class": claim_class,
206
+ "sources": [s for s in sources if s]})
207
+
208
+ def omit(self, key: str, reason: str):
209
+ self.omitted.append({"key": key, "reason": reason})
210
+
211
+ def text(self) -> str:
212
+ rank = {k: i for i, k in enumerate(ORDER)}
213
+ rows = sorted(self.trailers, key=lambda t: rank.get(t["key"], len(ORDER)))
214
+ return "\n".join(f"{t['key']}: {t['value']}" for t in rows)
215
+
216
+
217
+ def delta(metric: str, rec: dict, value: float, comparisons: list[dict], base: dict | None, label: str | None,
218
+ higher_is_better: bool, b: Builder) -> str:
219
+ """'[+2.1% (95% CI ...) improved vs 1a2b3c4]' from a comparison record, else a plain ratio of medians."""
220
+ for c in comparisons:
221
+ d = c.get("data") or {}
222
+ if d.get("metric") != metric or (c is not rec and d.get("candidate") not in (None, rec.get("evidence_id"))):
223
+ continue
224
+ ratio = next((m for m in c.get("metrics") or [] if m.get("name") == f"{metric}_ratio"), None)
225
+ if not ratio or ratio.get("value") is None:
226
+ continue
227
+ text = f"{(ratio['value'] - 1) * 100:+.1f}%"
228
+ ci = ratio.get("ci95")
229
+ if isinstance(ci, list) and len(ci) == 2 and None not in ci:
230
+ text += f" (95% CI {(ci[0] - 1) * 100:+.1f}% to {(ci[1] - 1) * 100:+.1f}%)"
231
+ if d.get("verdict"):
232
+ text += f" {d['verdict']}"
233
+ return f" [{text} vs {label or short(d.get('baseline'))}]"
234
+ if base is None:
235
+ return ""
236
+ got = pick([base], DECODE if metric == "decode_tok_s" else TTFT if metric == "ttft_ms" else (metric,))
237
+ if not got or len(got[0]) != 1 or not got[0][0].get("value"):
238
+ b.limitations.append(f"baseline {short(base.get('evidence_id'))} has no overall {metric}: no delta")
239
+ return ""
240
+ ratio = value / got[0][0]["value"]
241
+ b.limitations.append(f"{metric} delta is a ratio of medians without a CI or verdict; run `inferencekit compare` "
242
+ "on interleaved rounds for that")
243
+ return f" [{(ratio - 1) * 100:+.1f}% vs {label or short(base.get('evidence_id'))}, ratio of medians, no CI]"
244
+
245
+
246
+ def build_trailers(recs: list[dict], base: dict | None, baseline_commit: str | None,
247
+ gpu_price_usd_h: float | None) -> Builder:
248
+ b = Builder()
249
+ comparisons = [r for r in recs if kind_of(r) == "comparison"]
250
+ label = baseline_commit[:7] if baseline_commit else (short(base["evidence_id"]) if base else None)
251
+ # Single-user headlines come from single-user records; load levels feed capacity, not TTFT.
252
+ speed_all = [r for r in recs if kind_of(r) in SPEED_KINDS]
253
+ speed_recs = [r for r in speed_all if not is_load(r)]
254
+ engine = next((label_of(r) for r in speed_all if label_of(r)), None)
255
+ capacity = [r for r in recs if kind_of(r) == "capacity"]
256
+ capacity.sort(key=lambda r: label_of(r) != engine) # the measured engine's capacity record first
257
+
258
+ first = (speed_recs or speed_all or capacity or recs or [None])[0]
259
+ wl = workload_text(first) if first else ""
260
+ if wl:
261
+ b.add("Perf-Workload", wl, "observed", [first.get("evidence_id")])
262
+ else:
263
+ b.omit("Perf-Workload", "no identity in the evidence")
264
+
265
+ decode_value = None
266
+ for key, names, metric, higher in (("Perf-Decode-Tok-S", DECODE, "decode_tok_s", True),
267
+ ("Perf-TTFT-Ms", TTFT, "ttft_ms", False)):
268
+ got = pick(speed_recs, names)
269
+ if not got: # a comparison record alone still holds the candidate's median
270
+ got = next(((ms, c) for c in comparisons for ms in [[m for m in c.get("metrics") or []
271
+ if m.get("name") == metric and m.get("subject") == "candidate" and m.get("value") is not None]]
272
+ if ms), None)
273
+ if not got:
274
+ b.omit(key, f"no {metric} median in single-user benchmark evidence"
275
+ + (" (load-mode records feed Perf-Capacity-N, not this)" if speed_all else ""))
276
+ continue
277
+ ms, rec = got
278
+ text, change = values_text(ms), ""
279
+ if len(ms) == 1:
280
+ change = delta(metric, rec, ms[0]["value"], comparisons, base, label, higher, b)
281
+ if metric == "decode_tok_s":
282
+ decode_value = (ms[0]["value"], rec)
283
+ cls = "derived" if change or rec.get("kind") == "comparison" else rec.get("claim_class", "observed")
284
+ b.add(key, text + change, cls, [rec.get("evidence_id")])
285
+
286
+ got = pick(speed_recs, P95_DIRECT, stats=None) or pick(speed_recs, P95_BY_STAT, stats=("p95",))
287
+ if got:
288
+ b.add("Perf-P95-Latency-Ms", values_text(got[0]), got[1].get("claim_class", "observed"),
289
+ [got[1].get("evidence_id")])
290
+ else:
291
+ b.omit("Perf-P95-Latency-Ms", "no single-user p95 end-to-end latency in the evidence (bench decode does "
292
+ "not measure it; bench serve does)")
293
+
294
+ quality, qsrc = quality_parts(recs, engine)
295
+ if quality:
296
+ b.add("Perf-Quality", "; ".join(quality), "derived", qsrc)
297
+ else:
298
+ b.omit("Perf-Quality", "no correctness / quality evidence")
299
+
300
+ cost_trailer(b, capacity, decode_value, gpu_price_usd_h)
301
+
302
+ n_star = None
303
+ got = pick(capacity, N_STAR, stats=None) or pick(capacity, ("rate_star_rps",), stats=None)
304
+ if got:
305
+ m, rec = got[0][0], got[1]
306
+ data = rec.get("data") or {}
307
+ slo = data.get("slo") or ((rec.get("identity") or {}).get("workload") or {}).get("slo")
308
+ s = slo_text(slo)
309
+ # N* is server-wide (concurrent streams or arrival rate at the server), not per GPU.
310
+ # The SLO held at the top of the grid: N* is a lower bound.
311
+ lower = "at least " if str(data.get("reason") or "").startswith("SLO holds at every level") else ""
312
+ if m["name"] == "rate_star_rps" or data.get("level_kind") == "rate":
313
+ value = f"max rate {lower}{num(m['value'])} req/s"
314
+ else:
315
+ n_star = m["value"]
316
+ value = f"{lower}{num(n_star)} streams"
317
+ if gpu_count(rec) > 1:
318
+ value += f" across {gpu_count(rec)} GPUs"
319
+ b.add("Perf-Capacity-N", value + (f" (SLO {s})" if s else " (SLO not recorded)"),
320
+ rec.get("claim_class", "observed"), [rec.get("evidence_id")])
321
+ if not s:
322
+ b.limitations.append("capacity evidence carries no SLO: N* cannot be interpreted without it")
323
+ elif capacity:
324
+ why = (capacity[0].get("data") or {}).get("reason") or "N* undefined"
325
+ b.omit("Perf-Capacity-N", f"capacity evidence has no N*: {why}")
326
+ else:
327
+ b.omit("Perf-Capacity-N", "no capacity evidence with N*")
328
+ got = pick(capacity, GOODPUT, stats=None)
329
+ if got:
330
+ m, rec = got[0][0], got[1]
331
+ unit = m.get("unit") or "req/s"
332
+ b.add("Perf-Goodput", f"{num(m['value'])} {unit}" + (f" at N*={num(n_star)}" if n_star is not None else ""),
333
+ rec.get("claim_class", "observed"), [rec.get("evidence_id")])
334
+ else:
335
+ b.omit("Perf-Goodput", "no goodput in capacity evidence")
336
+
337
+ ids = [r.get("evidence_id") for r in recs] + ([base.get("evidence_id")] if base else [])
338
+ b.add("Perf-Evidence", ", ".join(short(i) for i in dict.fromkeys(ids) if i), "observed", [])
339
+ baseline = baseline_commit or (short(base["evidence_id"]) if base else None)
340
+ if baseline is None:
341
+ baseline = next((short((c.get("data") or {}).get("baseline")) for c in comparisons
342
+ if (c.get("data") or {}).get("baseline")), None)
343
+ if baseline:
344
+ b.add("Perf-Baseline", baseline, "observed", [])
345
+ else:
346
+ b.omit("Perf-Baseline", "no --baseline-commit, --baseline-ev or comparison record")
347
+ return b
348
+
349
+
350
+ def _subject_metric(rec: dict, names, subject: str | None, others=()) -> dict | None:
351
+ """The metric named in ``names`` for ``subject`` (the engine label), else the first engine-level one.
352
+ Metrics whose subject is in ``others`` (another server's, e.g. the sampler check's baseline) never stand in."""
353
+ for name in names:
354
+ ms = [m for m in rec.get("metrics") or [] if isinstance(m, dict) and m.get("name") == name
355
+ and m.get("value") is not None and m.get("subject") not in others]
356
+ if not ms:
357
+ continue
358
+ exact = [m for m in ms if subject is not None and m.get("subject") == subject]
359
+ floor = (rec.get("data") or {}).get("noise_floor")
360
+ plain = [m for m in ms if "@" not in str(m.get("subject") or "") and m.get("subject") != floor]
361
+ found = (exact or plain or ms)[0]
362
+ return found
363
+ return None
364
+
365
+
366
+ def quality_parts(recs: list[dict], engine: str | None) -> tuple[list[str], list[str]]:
367
+ """One phrase per quality record. The engine label goes last, so the leading number stays the value."""
368
+ parts, sources = [], []
369
+ for r in recs:
370
+ kind, data = kind_of(r), r.get("data") or {}
371
+ if kind == "correctness":
372
+ pa = metrics_named(r, ("prefix_agreement_pct",), stats=None)
373
+ em = metrics_named(r, ("exact_match_pct",), stats=None)
374
+ if pa:
375
+ text = f"greedy prefix agreement {pa[0]['value']:.2f}%"
376
+ if em and em[0].get("n"):
377
+ text += f" ({round(em[0]['value'] * em[0]['n'] / 100)}/{em[0]['n']} identical)"
378
+ if isinstance(data.get("verdict"), str):
379
+ text += f" {data['verdict']}"
380
+ parts.append(text)
381
+ sources.append(r.get("evidence_id"))
382
+ elif kind == "quality_trajectory":
383
+ m = _subject_metric(r, ("top1_agreement_pct", "tf_agreement_pct", "token_agreement_pct"), engine)
384
+ if not m:
385
+ continue
386
+ subject = m.get("subject")
387
+ text = f"{m['name'].replace('_pct', '').replace('_', '-')} {m['value']:.2f}%"
388
+ floor_label = data.get("noise_floor")
389
+ floor = next((x for x in r.get("metrics") or [] if x.get("name") == m["name"] and floor_label
390
+ and x.get("subject") == floor_label and x.get("value") is not None), None)
391
+ if floor:
392
+ text += f" (noise floor {floor['value']:.2f}%)"
393
+ verdict = (data.get("verdicts") or {}).get(subject) or data.get("verdict")
394
+ if isinstance(verdict, str):
395
+ text += f" {verdict}"
396
+ parts.append(text + (f" [{one_line(subject)}]" if subject else ""))
397
+ sources.append(r.get("evidence_id"))
398
+ elif kind in ("quality_task", "quality"):
399
+ task = str(data.get("task") or "task")
400
+ if task == "sampling":
401
+ # only this server's own number: with --baseline-url the record also carries the baseline's
402
+ m = _subject_metric(r, ("first_token_tv_excess",), engine, others=("baseline",))
403
+ if not m:
404
+ who = data.get("label") or engine
405
+ parts.append("first token tv excess n/a" + (f" {data['verdict']}" if isinstance(
406
+ data.get("verdict"), str) else "") + (f" [{one_line(who)}]" if who else ""))
407
+ sources.append(r.get("evidence_id"))
408
+ continue
409
+ else:
410
+ m = _subject_metric(r, (f"{task}_accuracy_pct", "accuracy_pct", "score"), engine)
411
+ if not m:
412
+ m = next((x for x in r.get("metrics") or [] if x.get("value") is not None
413
+ and any(w in x.get("name", "") for w in ("accuracy", "wer", "score", "agreement"))), None)
414
+ if not m:
415
+ continue
416
+ name = m["name"].replace("_pct", "").replace("_", " ")
417
+ unit = "%" if m.get("unit") == "%" or m["name"].endswith("_pct") else ""
418
+ text = f"{name} {num(m['value'])}{unit}"
419
+ ci = m.get("ci95")
420
+ if isinstance(ci, list) and len(ci) == 2 and None not in ci:
421
+ sep = " to " if min(ci) < 0 else "-" # a signed number (TV excess): "-0.0085-0.0116" misreads
422
+ text += f" (95% CI {num(ci[0])}{sep}{num(ci[1])}{unit})"
423
+ if m.get("n"):
424
+ text += f" (n={m['n']})"
425
+ if isinstance(data.get("verdict"), str):
426
+ text += f" {data['verdict']}"
427
+ parts.append(text + (f" [{one_line(m['subject'])}]" if m.get("subject") else ""))
428
+ sources.append(r.get("evidence_id"))
429
+ return parts, sources
430
+
431
+
432
+ def cost_trailer(b: Builder, capacity: list[dict], decode_value, price: float | None) -> None:
433
+ """Perf-Cost. Prices are $ per GPU-hour (as bench serve takes them).
434
+
435
+ A capacity record's own cost is printed at the price it was computed with. A different
436
+ ``--gpu-price-usd-h`` reprices it from the record's per-GPU throughput at N* (derived);
437
+ the record's number is never shown next to a price it was not computed at.
438
+ """
439
+ got = pick(capacity, COST, stats=None)
440
+ if got:
441
+ m, rec = got[0][0], got[1]
442
+ own = (rec.get("data") or {}).get("gpu_price_usd_h")
443
+ own = float(own) if isinstance(own, (int, float)) and not isinstance(own, bool) and own > 0 else None
444
+ if price is None or (own is not None and abs(price - own) < 1e-9):
445
+ b.add("Perf-Cost", f"${m['value']:.2f} per 1M output tokens" + (f" at ${own:.2f}/GPU-h" if own else "")
446
+ + " (at N*)", rec.get("claim_class", "observed"), [rec.get("evidence_id")])
447
+ if own is None:
448
+ b.limitations.append("Perf-Cost: the capacity record does not say which GPU price it used")
449
+ return
450
+ tput = pick([rec], ("output_tok_s_per_gpu",), stats=None)
451
+ if tput and len(tput[0]) == 1 and tput[0][0]["value"]:
452
+ usd = price / (tput[0][0]["value"] * 3600) * 1e6
453
+ b.add("Perf-Cost", f"${usd:.2f} per 1M output tokens at ${price:.2f}/GPU-h (at N*, repriced)", "derived",
454
+ [rec.get("evidence_id")])
455
+ b.limitations.append(f"Perf-Cost repriced at ${price:.2f}/GPU-h from output_tok_s_per_gpu at N*; capacity "
456
+ f"record {short(rec.get('evidence_id'))} priced it at "
457
+ + (f"${own:.2f}/GPU-h (${m['value']:.2f})" if own else "an unrecorded price"))
458
+ return
459
+ b.add("Perf-Cost", f"${m['value']:.2f} per 1M output tokens" + (f" at ${own:.2f}/GPU-h" if own else "")
460
+ + " (at N*)", rec.get("claim_class", "observed"), [rec.get("evidence_id")])
461
+ b.limitations.append(f"--gpu-price-usd-h {price:g} ignored: capacity record {short(rec.get('evidence_id'))} "
462
+ "has no per-GPU throughput at N* to reprice from, so its own cost and price are shown")
463
+ return
464
+ if price is None:
465
+ b.omit("Perf-Cost", "no --gpu-price-usd-h and no cost in capacity evidence")
466
+ return
467
+ got = pick(capacity, THROUGHPUT_PER_GPU, stats=None)
468
+ if got and got[0][0]["value"]:
469
+ tps, rec, note = got[0][0]["value"], got[1], "at N*"
470
+ # Server-wide throughput (not per GPU) is paid for by every GPU of the server.
471
+ gpus = 1 if got[0][0]["name"] == "output_tok_s_per_gpu" else gpu_count(rec)
472
+ elif decode_value and decode_value[0]:
473
+ (tps, rec), note = decode_value, "1 stream"
474
+ gpus = gpu_count(rec) # one decode stream occupies every GPU of a tensor-parallel engine
475
+ b.limitations.append("Perf-Cost uses single-stream decode speed: serving many users per GPU costs less; "
476
+ "add capacity evidence (bench serve load mode) for the cost at N*")
477
+ else:
478
+ b.omit("Perf-Cost", "no throughput to price")
479
+ return
480
+ usd = price * gpus / (tps * 3600) * 1e6
481
+ at = f"${price:.2f}/GPU-h" + (f" x {gpus} GPUs" if gpus > 1 else "")
482
+ b.add("Perf-Cost", f"${usd:.2f} per 1M output tokens at {at} ({note})", "derived", [rec.get("evidence_id")])
483
+
484
+
485
+ def select_engine(recs: list[dict], label: str | None) -> list[dict]:
486
+ """The records of the one engine a commit measures; refuses anything ambiguous.
487
+
488
+ Two engines' speed records (or a baseline's and a candidate's passed together) would
489
+ otherwise put the first one's number in the commit as its headline.
490
+ """
491
+ measured = SPEED_KINDS + ("capacity",)
492
+ labels = sorted({label_of(r) for r in recs if kind_of(r) in measured and label_of(r)})
493
+ if label is not None:
494
+ if label not in labels:
495
+ raise OpError(f"--label {label!r}: no benchmark or capacity record has that engine label; found: "
496
+ f"{', '.join(labels) or 'none'}")
497
+ recs = [r for r in recs if kind_of(r) not in measured or label_of(r) == label]
498
+ elif len(labels) > 1:
499
+ raise OpError(f"benchmark/capacity records of {len(labels)} engine labels ({', '.join(labels)}): pass --label "
500
+ "for the engine this commit measures, and the baseline's record as --baseline-ev (or a "
501
+ "`compare` record for a delta with a CI)")
502
+ for what, group in (("single-user benchmark", [r for r in recs if kind_of(r) in SPEED_KINDS and not is_load(r)]),
503
+ ("capacity", [r for r in recs if kind_of(r) == "capacity"])):
504
+ if len(group) > 1:
505
+ raise OpError(f"{len(group)} {what} records for one engine "
506
+ f"({', '.join(short(r.get('evidence_id')) for r in group)}): pass only the one this commit "
507
+ "measures; a baseline goes in --baseline-ev")
508
+ return recs
509
+
510
+
511
+ def _load(store: ev.Store, ref: str, what: str) -> dict:
512
+ rec = store.load(ref)
513
+ problem = ev.integrity_problem(rec)
514
+ if problem: # a ledger entry is permanent: never write numbers from an edited record into it
515
+ raise OpError(f"{what} {problem}; refusing to write trailers from it")
516
+ return rec
517
+
518
+
519
+ @operation("ledger_trailers", ("ledger", "trailers"), "Git trailers (Perf-Decode-Tok-S, Perf-TTFT-Ms, Perf-Quality, "
520
+ "Perf-Cost, Perf-Capacity-N...) for a commit message, from evidence ids; missing metrics are omitted, "
521
+ "never zero.",
522
+ [Arg("ids", "array", "Evidence ids (or unique prefixes, or paths): benchmark, comparison, correctness, "
523
+ "quality and capacity records.", required=True, positional=True),
524
+ Arg("baseline_commit", "string", "Commit the change is measured against (shown in the delta and "
525
+ "Perf-Baseline)."),
526
+ Arg("baseline_ev", "string", "Baseline benchmark evidence id, for the delta when no comparison record "
527
+ "is given."),
528
+ Arg("label", "string", "Engine label the trailers describe, when the evidence holds several engines."),
529
+ Arg("gpu_price_usd_h", "number", "GPU price in $ per GPU-hour (as bench serve takes it), for Perf-Cost; "
530
+ "multiplied by the GPU count where the throughput is the whole server's."),
531
+ Arg("format", "string", "text: the trailer block on stdout, what was omitted or limited on stderr; "
532
+ "json: trailers with claim classes, sources and what was omitted.", default="text",
533
+ choices=("text", "json"))])
534
+ def ledger_trailers(call, ids, baseline_commit, label, baseline_ev, gpu_price_usd_h, format): # noqa: A002
535
+ store = ev.Store()
536
+ recs, seen = [], set()
537
+ for ref in ids:
538
+ rec = _load(store, ref, "evidence")
539
+ if rec.get("evidence_id") not in seen:
540
+ seen.add(rec.get("evidence_id"))
541
+ recs.append(rec)
542
+ base = _load(store, baseline_ev, "baseline") if baseline_ev else None
543
+ if baseline_commit is not None and not re.fullmatch(r"[0-9A-Za-z._/-]{4,64}", baseline_commit):
544
+ raise OpError(f"--baseline-commit {baseline_commit!r} does not look like a commit or ref")
545
+ if gpu_price_usd_h is not None and gpu_price_usd_h <= 0:
546
+ raise OpError("--gpu-price-usd-h must be > 0")
547
+ recs = select_engine(recs, label)
548
+ b = build_trailers(recs, base, baseline_commit, gpu_price_usd_h)
549
+ text = b.text()
550
+ out = {"trailers": b.trailers, "omitted": b.omitted, "limitations": b.limitations, "text": text,
551
+ "evidence": [r.get("evidence_id") for r in recs] + ([base.get("evidence_id")] if base else [])}
552
+ if format == "json":
553
+ return out
554
+ out_dir = store.root / "ledger"
555
+ out_dir.mkdir(parents=True, exist_ok=True)
556
+ path = out_dir / f"trailers-{sha256_hex(text)[:12]}.txt"
557
+ path.write_text(text + "\n")
558
+ # Provenance for the derived numbers (deltas, repriced costs): claim class and source record per trailer.
559
+ path.with_suffix(".json").write_text(json.dumps({"request": call.request(), "generated": call.started, **out},
560
+ indent=2, ensure_ascii=False) + "\n")
561
+ # stdout is the commit message ($(...) captures it); the caveats go to stderr so they are seen, not committed.
562
+ for o in b.omitted:
563
+ print(f"inferencekit ledger: omitted {o['key']}: {o['reason']}", file=sys.stderr)
564
+ for lim in b.limitations:
565
+ print(f"inferencekit ledger: limitation: {lim}", file=sys.stderr)
566
+ return {"format": "md", "markdown": text, "path": str(path), **out}
567
+
568
+
569
+ # --------------------------------------------------------------------------- history
570
+
571
+ HIGHER_IS_BETTER = {"Perf-Decode-Tok-S": True, "Perf-Quality": True, "Perf-Capacity-N": True,
572
+ "Perf-Goodput": True, "Perf-TTFT-Ms": False, "Perf-P95-Latency-Ms": False, "Perf-Cost": False}
573
+ NOT_METRICS = ("Perf-Workload", "Perf-Evidence", "Perf-Baseline")
574
+ _NUMBER = re.compile(r"(?<![A-Za-z0-9_.])[-+]?\d+(?:\.\d+)?")
575
+
576
+
577
+ def leading_number(value: str) -> float | None:
578
+ """The first free-standing number of a trailer value ('$0.91 per 1M' -> 0.91; 'gsm8k 85%' -> 85)."""
579
+ m = _NUMBER.search(value or "")
580
+ return float(m.group(0)) if m else None
581
+
582
+
583
+ def direction(key: str, sample: str = "") -> bool | None:
584
+ """True if higher is better, False if lower is, None if unknown. ``sample``: a value of that trailer."""
585
+ if key == "Perf-Quality" and re.search(r"\b(c|w)er\b|\bkl\b|error", sample, re.IGNORECASE):
586
+ return False # an error rate or a divergence, e.g. "asr-wer 2.1%"
587
+ if key in HIGHER_IS_BETTER:
588
+ return HIGHER_IS_BETTER[key]
589
+ low = key.lower()
590
+ if low.endswith(("-ms", "-s-latency")) or "cost" in low or "latency" in low:
591
+ return False
592
+ if low.endswith(("-tok-s", "-rps")) or "goodput" in low or "throughput" in low:
593
+ return True
594
+ return None
595
+
596
+
597
+ def metric_key(name: str) -> str:
598
+ """'decode_tok_s', 'decode-tok-s' or 'Perf-Decode-Tok-S' -> 'Perf-Decode-Tok-S' (case-insensitive match later)."""
599
+ n = name.strip().replace("_", "-")
600
+ return n if n.lower().startswith("perf-") else "Perf-" + n
601
+
602
+
603
+ def sparkline(values: list[float | None]) -> str:
604
+ """One character per commit; '·' where a commit has no value (never drawn as zero)."""
605
+ present = [v for v in values if v is not None]
606
+ if not present:
607
+ return ""
608
+ lo, hi = min(present), max(present)
609
+ out = []
610
+ for v in values:
611
+ if v is None:
612
+ out.append("·")
613
+ elif hi == lo:
614
+ out.append(SPARK[len(SPARK) // 2])
615
+ else:
616
+ out.append(SPARK[min(len(SPARK) - 1, int((v - lo) / (hi - lo) * (len(SPARK) - 1) + 0.5))])
617
+ return "".join(out)
618
+
619
+
620
+ def git_log(repo: str, rev: str, limit: int) -> list[dict]:
621
+ fmt = "%H%x1f%h%x1f%cI%x1f%s%x1f%(trailers:only,unfold)%x1e"
622
+ res = run_cmd(["git", "-C", repo, "log", f"--max-count={limit}", f"--format={fmt}", rev, "--"], timeout=60)
623
+ if res.returncode == 127:
624
+ raise OpError("git not found on PATH")
625
+ if not res.ok:
626
+ raise OpError(f"git log failed in {repo}: {res.stderr.strip()[:300]}")
627
+ commits = []
628
+ for chunk in res.stdout.split("\x1e"):
629
+ chunk = chunk.strip("\n")
630
+ if not chunk:
631
+ continue
632
+ fields = chunk.split("\x1f")
633
+ if len(fields) < 5:
634
+ continue
635
+ sha, short_sha, date, subject, trailer_text = fields[:5]
636
+ trailers: dict[str, str] = {}
637
+ for line in trailer_text.splitlines():
638
+ key, sep, value = line.partition(":")
639
+ if sep and key.strip().lower().startswith("perf-"):
640
+ trailers.setdefault(key.strip(), value.strip()) # first occurrence wins
641
+ commits.append({"commit": sha, "short": short_sha, "date": date, "subject": subject, "trailers": trailers})
642
+ return commits
643
+
644
+
645
+ def _cell(text: str) -> str:
646
+ return str(text).replace("|", "\\|")
647
+
648
+
649
+ @operation("history", ("history",), "Commits with Perf-* trailers from git log: a table, a sparkline per metric, "
650
+ "the best commit per metric, and optional local perf/best-<metric> tags.",
651
+ [Arg("repo", "string", "Git repository.", default="."),
652
+ Arg("metric", "string", "Only this metric (e.g. decode-tok-s or Perf-TTFT-Ms)."),
653
+ Arg("rev", "string", "Revision or range to read.", default="HEAD"),
654
+ Arg("limit", "integer", "Most recent commits to read.", default=200),
655
+ Arg("tag_best", "boolean", "Point local tags perf/best-<metric> at the best commit per metric (never "
656
+ "pushed).", default=False),
657
+ Arg("format", "string", "md: table and sparklines; json: parsed commits.", default="md",
658
+ choices=("md", "json"))])
659
+ def history(call, repo, metric, rev, limit, tag_best, format): # noqa: A002
660
+ if not Path(repo).is_dir():
661
+ raise OpError(f"repository not found: {repo}")
662
+ if rev.startswith("-"):
663
+ raise OpError(f"rev {rev!r} must not start with '-'")
664
+ commits = [c for c in git_log(repo, rev, limit) if c["trailers"]]
665
+ commits.reverse() # oldest first: sparklines read left to right in time
666
+ keys: list[str] = []
667
+ for c in commits:
668
+ for k in c["trailers"]:
669
+ if k not in NOT_METRICS and k not in keys:
670
+ keys.append(k)
671
+ keys.sort(key=lambda k: (ORDER.index(k) if k in ORDER else len(ORDER), k))
672
+ if metric:
673
+ want = metric_key(metric).lower()
674
+ keys = [k for k in keys if k.lower() == want]
675
+ if not keys:
676
+ raise OpError(f"no commits carry trailer {metric_key(metric)} in {repo} ({rev})")
677
+ for c in commits:
678
+ c["values"] = {k: leading_number(c["trailers"][k]) for k in keys if k in c["trailers"]}
679
+
680
+ # Only numbers measured on the same workload are comparable: the newest commit's workload decides.
681
+ latest_wl = next((c["trailers"].get("Perf-Workload") for c in reversed(commits)
682
+ if c["trailers"].get("Perf-Workload")), None)
683
+ comparable = [c for c in commits if latest_wl is None or c["trailers"].get("Perf-Workload") in (None, latest_wl)]
684
+ notes = []
685
+ if len(comparable) < len(commits):
686
+ notes.append(f"{len(commits) - len(comparable)} commit(s) measured another Perf-Workload: listed, but left out "
687
+ f"of sparklines and best-of (workload: {latest_wl})")
688
+
689
+ summary = {}
690
+ for k in keys:
691
+ series = [c["values"].get(k) for c in comparable]
692
+ present = [(c, c["values"][k]) for c in comparable if c["values"].get(k) is not None]
693
+ d = direction(k, next((c["trailers"][k] for c in reversed(comparable) if k in c["trailers"]), ""))
694
+ best = None
695
+ if present and d is not None:
696
+ target = max(v for _, v in present) if d else min(v for _, v in present)
697
+ best_c = [c for c, v in present if v == target][-1] # newest commit on a tie
698
+ best = {"commit": best_c["commit"], "short": best_c["short"], "value": target}
699
+ summary[k] = {"sparkline": sparkline(series), "n": len(present), "higher_is_better": d, "best": best,
700
+ "first": present[0][1] if present else None, "last": present[-1][1] if present else None,
701
+ "median": statistics.median([v for _, v in present]) if present else None}
702
+ if d is None and present:
703
+ notes.append(f"{k}: direction unknown (not a standard trailer), no best commit chosen")
704
+
705
+ tags = []
706
+ if tag_best:
707
+ for k, s in summary.items():
708
+ if not s["best"]:
709
+ continue
710
+ tag = "perf/best-" + re.sub(r"[^a-z0-9-]+", "-", k[len("Perf-"):].lower()).strip("-")
711
+ # A lightweight local bookmark, moved when a better commit appears; never signed or pushed.
712
+ res = run_cmd(["git", "-C", repo, "-c", "tag.gpgSign=false", "tag", "-f", tag, s["best"]["commit"]],
713
+ timeout=30)
714
+ if not res.ok:
715
+ raise OpError(f"git tag {tag} failed: {res.stderr.strip()[:300]}")
716
+ tags.append({"tag": tag, "commit": s["best"]["short"], "value": s["best"]["value"]})
717
+
718
+ out = {"repo": str(repo), "rev": rev, "commits": commits, "metrics": summary, "tags": tags, "notes": notes,
719
+ "claim_class": "as recorded in each commit's trailers (see its Perf-Evidence)"}
720
+ if format == "json":
721
+ return out
722
+ lines = [f"# Perf history: {Path(repo).resolve().name} ({rev}, {len(commits)} commits with Perf-* trailers)", ""]
723
+ if not commits:
724
+ lines.append("No commits with Perf-* trailers. Add them with `inferencekit ledger trailers <ev...>`.")
725
+ else:
726
+ short_keys = [k[len("Perf-"):] for k in keys]
727
+ lines += ["| commit | date | subject | " + " | ".join(short_keys) + " |",
728
+ "|---|---|---|" + "---|" * len(keys)]
729
+ for c in reversed(commits): # newest first in the table, like git log
730
+ vals = [num(c["values"][k]) if c["values"].get(k) is not None else "·" for k in keys]
731
+ lines.append(f"| {c['short']} | {c['date'][:10]} | {_cell(c['subject'][:60])} | " + " | ".join(vals) + " |")
732
+ lines += ["", "Sparklines, oldest to newest (· = no value on that commit):", ""]
733
+ width = max((len(k) for k in short_keys), default=0)
734
+ for k, sk in zip(keys, short_keys):
735
+ s = summary[k]
736
+ best = (f", best {num(s['best']['value'])} at {s['best']['short']}"
737
+ f" ({'higher' if s['higher_is_better'] else 'lower'} is better)") if s["best"] else ""
738
+ lines.append(f" {sk.ljust(width)} {s['sparkline']} first {num(s['first'])}, last {num(s['last'])}"
739
+ f"{best}")
740
+ if tags:
741
+ lines += ["", "Local tags (not pushed): " + ", ".join(f"{t['tag']} -> {t['commit']}" for t in tags)]
742
+ if notes:
743
+ lines += [""] + [f"Note: {n}" for n in notes]
744
+ text = "\n".join(lines) + "\n"
745
+ out_dir = ev.Store().root / "ledger"
746
+ out_dir.mkdir(parents=True, exist_ok=True)
747
+ path = out_dir / f"history-{sha256_hex(json.dumps([repo, rev, metric, text]))[:12]}.md"
748
+ path.write_text(text)
749
+ return {"format": "md", "markdown": text, "path": str(path), **out}