ffbpe 0.1.8__tar.gz

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 (90) hide show
  1. ffbpe-0.1.8/.gitattributes +1 -0
  2. ffbpe-0.1.8/.github/scripts/run-benchmark-suite.sh +27 -0
  3. ffbpe-0.1.8/.github/scripts/update-benchmark-comment.cjs +633 -0
  4. ffbpe-0.1.8/.github/scripts/update-benchmark-comment.test.cjs +559 -0
  5. ffbpe-0.1.8/.github/scripts/validate-benchmark-reports.sh +81 -0
  6. ffbpe-0.1.8/.github/scripts/validate-benchmark-reports.test.sh +122 -0
  7. ffbpe-0.1.8/.github/workflows/bench-comment.yml +43 -0
  8. ffbpe-0.1.8/.github/workflows/bench.yml +115 -0
  9. ffbpe-0.1.8/.github/workflows/ci.yml +77 -0
  10. ffbpe-0.1.8/.github/workflows/release.yml +56 -0
  11. ffbpe-0.1.8/.github/workflows/wheels.yml +111 -0
  12. ffbpe-0.1.8/.gitignore +15 -0
  13. ffbpe-0.1.8/BENCHMARKS.md +148 -0
  14. ffbpe-0.1.8/Cargo.lock +2885 -0
  15. ffbpe-0.1.8/Cargo.toml +101 -0
  16. ffbpe-0.1.8/LICENSE +21 -0
  17. ffbpe-0.1.8/PKG-INFO +358 -0
  18. ffbpe-0.1.8/README.md +346 -0
  19. ffbpe-0.1.8/benches/bpe.rs +63 -0
  20. ffbpe-0.1.8/benches/regression/codec.rs +1155 -0
  21. ffbpe-0.1.8/benches/regression/common/config.rs +44 -0
  22. ffbpe-0.1.8/benches/regression/common/environment.rs +184 -0
  23. ffbpe-0.1.8/benches/regression/common/fingerprint.rs +284 -0
  24. ffbpe-0.1.8/benches/regression/common/mod.rs +7 -0
  25. ffbpe-0.1.8/benches/regression/common/process.rs +178 -0
  26. ffbpe-0.1.8/benches/regression/common/report.rs +30 -0
  27. ffbpe-0.1.8/benches/regression/common/rss.rs +183 -0
  28. ffbpe-0.1.8/benches/regression/common/util.rs +144 -0
  29. ffbpe-0.1.8/benches/regression/config/1gib.yml +29 -0
  30. ffbpe-0.1.8/benches/regression/config/64mib.yml +29 -0
  31. ffbpe-0.1.8/benches/regression/config/smoke.yml +102 -0
  32. ffbpe-0.1.8/benches/regression/main.rs +86 -0
  33. ffbpe-0.1.8/benches/regression/pretokenizer.rs +931 -0
  34. ffbpe-0.1.8/benches/regression/suite.rs +922 -0
  35. ffbpe-0.1.8/benches/regression/trainer.rs +1379 -0
  36. ffbpe-0.1.8/benchmarks/README.md +268 -0
  37. ffbpe-0.1.8/benchmarks/common.py +290 -0
  38. ffbpe-0.1.8/benchmarks/compare_hf_training.py +455 -0
  39. ffbpe-0.1.8/benchmarks/compare_tiktoken.py +150 -0
  40. ffbpe-0.1.8/benchmarks/count_parquet_source.py +245 -0
  41. ffbpe-0.1.8/benchmarks/create_fineweb2_sample.py +116 -0
  42. ffbpe-0.1.8/benchmarks/create_golden_model.py +121 -0
  43. ffbpe-0.1.8/benchmarks/profile_pretokenizer.py +139 -0
  44. ffbpe-0.1.8/benchmarks/profile_trainer.py +132 -0
  45. ffbpe-0.1.8/benchmarks/profile_training_core.py +215 -0
  46. ffbpe-0.1.8/benchmarks/unicode_bigram_split.py +270 -0
  47. ffbpe-0.1.8/download.py +67 -0
  48. ffbpe-0.1.8/examples/profile_perf.rs +148 -0
  49. ffbpe-0.1.8/examples/quickstart.py +22 -0
  50. ffbpe-0.1.8/examples/quickstart.rs +20 -0
  51. ffbpe-0.1.8/fixtures/default_special_tokens.txt +1 -0
  52. ffbpe-0.1.8/pyproject.toml +35 -0
  53. ffbpe-0.1.8/python/ffbpe/__init__.py +40 -0
  54. ffbpe-0.1.8/python/ffbpe/_lib.pyi +115 -0
  55. ffbpe-0.1.8/python/ffbpe/_serialization.py +86 -0
  56. ffbpe-0.1.8/python/ffbpe/encoder.py +184 -0
  57. ffbpe-0.1.8/python/ffbpe/model.py +163 -0
  58. ffbpe-0.1.8/python/ffbpe/pretokenizer.py +120 -0
  59. ffbpe-0.1.8/python/ffbpe/py.typed +0 -0
  60. ffbpe-0.1.8/python/ffbpe/tiktoken/__init__.py +16 -0
  61. ffbpe-0.1.8/python/ffbpe/tiktoken/core.py +13 -0
  62. ffbpe-0.1.8/python/ffbpe/tiktoken/load.py +57 -0
  63. ffbpe-0.1.8/python/ffbpe/tiktoken/model.py +35 -0
  64. ffbpe-0.1.8/python/ffbpe/tiktoken/registry.py +31 -0
  65. ffbpe-0.1.8/python/ffbpe/tiktoken_compat.py +418 -0
  66. ffbpe-0.1.8/python/ffbpe/trainer.py +207 -0
  67. ffbpe-0.1.8/python/ffbpe/training.py +43 -0
  68. ffbpe-0.1.8/src/_metrics.rs +217 -0
  69. ffbpe-0.1.8/src/bigram.rs +169 -0
  70. ffbpe-0.1.8/src/bpe/encoder.rs +1509 -0
  71. ffbpe-0.1.8/src/bpe/mod.rs +518 -0
  72. ffbpe-0.1.8/src/bpe/model.rs +212 -0
  73. ffbpe-0.1.8/src/bpe/pair.rs +548 -0
  74. ffbpe-0.1.8/src/bpe/trainer.rs +3073 -0
  75. ffbpe-0.1.8/src/bpe/utils.rs +355 -0
  76. ffbpe-0.1.8/src/counter.rs +425 -0
  77. ffbpe-0.1.8/src/lib.rs +53 -0
  78. ffbpe-0.1.8/src/main.rs +630 -0
  79. ffbpe-0.1.8/src/pretokenizer.rs +1321 -0
  80. ffbpe-0.1.8/src/py.rs +1201 -0
  81. ffbpe-0.1.8/src/spec/gpt2.rs +143 -0
  82. ffbpe-0.1.8/src/spec/mod.rs +24 -0
  83. ffbpe-0.1.8/src/spec/unitoken.rs +244 -0
  84. ffbpe-0.1.8/src/traits.rs +126 -0
  85. ffbpe-0.1.8/test.py +51 -0
  86. ffbpe-0.1.8/tests/regression_report.rs +342 -0
  87. ffbpe-0.1.8/tests/test_benchmark_manifest.py +66 -0
  88. ffbpe-0.1.8/tests/test_hf_training_parity.py +100 -0
  89. ffbpe-0.1.8/tests/test_python_api.py +798 -0
  90. ffbpe-0.1.8/tests/test_tiktoken_compat.py +186 -0
@@ -0,0 +1 @@
1
+ fixtures/*.txt binary
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ if [[ "$#" -ne 3 ]]; then
6
+ echo "usage: $0 <checkout> <output-directory> <suite-config>" >&2
7
+ exit 2
8
+ fi
9
+
10
+ checkout=$1
11
+ output_dir=$2
12
+ suite_config=$3
13
+
14
+ mkdir -p "$output_dir"
15
+ cd "$checkout"
16
+
17
+ # Keep base/head ordering from turning fixture page-cache state into a PR delta.
18
+ find fixtures -maxdepth 1 -type f -exec sha256sum {} + >/dev/null
19
+
20
+ cargo bench --bench regression --no-run
21
+
22
+ # Each revision consumes its own config. The report renderer treats cases that
23
+ # exist on only one side as missing, which lets benchmark coverage evolve
24
+ # without requiring an older revision to understand a newer schema.
25
+ cargo bench --bench regression -- suite \
26
+ --config "$suite_config" \
27
+ --output-dir "$output_dir"
@@ -0,0 +1,633 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const MARKER = '<!-- unitoken-benchmark-report -->';
5
+ const MAX_REPORT_BYTES = 2 * 1024 * 1024;
6
+ const TRAINER_LABELS = new Map([
7
+ ['smoke_en_byte_v300', 'English byte, vocab 300'],
8
+ ['smoke_en_byte_v1000', 'English byte, vocab 1k'],
9
+ ['smoke_zh_unicode_v300', 'Chinese Unicode, vocab 300'],
10
+ ['smoke_zh_unicode_v1000', 'Chinese Unicode, vocab 1k'],
11
+ ['smoke_zh_unicode_bbpe_r90_v1000', 'Chinese Unicode BBPE, vocab 1k'],
12
+ ]);
13
+
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+
18
+ function finiteNumber(value) {
19
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
20
+ ? value
21
+ : null;
22
+ }
23
+
24
+ function average(values) {
25
+ const valid = values.map(finiteNumber).filter((value) => value !== null);
26
+ if (valid.length === 0) {
27
+ return null;
28
+ }
29
+ return valid.reduce((sum, value) => sum + value, 0) / valid.length;
30
+ }
31
+
32
+ function loadReport(resultsDir, relativePath, contract, errors, optional) {
33
+ const reportPath = path.join(resultsDir, relativePath);
34
+ try {
35
+ const stat = fs.lstatSync(reportPath);
36
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_REPORT_BYTES) {
37
+ throw new Error('invalid report file');
38
+ }
39
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
40
+ if (
41
+ !isRecord(report)
42
+ || report.schema_version !== 1
43
+ || report.contract !== contract
44
+ || !isRecord(report.gates)
45
+ || typeof report.gates.passed !== 'boolean'
46
+ || !Array.isArray(report.samples)
47
+ ) {
48
+ throw new Error('invalid report contract');
49
+ }
50
+ return { status: 'present', report };
51
+ } catch (error) {
52
+ if (optional && error?.code === 'ENOENT') {
53
+ return { status: 'absent', report: null };
54
+ }
55
+ errors.push(relativePath);
56
+ return { status: 'invalid', report: null };
57
+ }
58
+ }
59
+
60
+ function readReport(resultsDir, relativePath, contract, errors) {
61
+ return loadReport(resultsDir, relativePath, contract, errors, false).report;
62
+ }
63
+
64
+ function readOptionalReport(resultsDir, relativePath, contract, errors) {
65
+ return loadReport(resultsDir, relativePath, contract, errors, true);
66
+ }
67
+
68
+ function readMetadata(resultsDir) {
69
+ const metadataPath = path.join(resultsDir, 'metadata.json');
70
+ const stat = fs.lstatSync(metadataPath);
71
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) {
72
+ throw new Error('invalid benchmark metadata file');
73
+ }
74
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
75
+ if (
76
+ !isRecord(metadata)
77
+ || metadata.schema_version !== 1
78
+ || !Number.isSafeInteger(metadata.pull_request_number)
79
+ || metadata.pull_request_number <= 0
80
+ || !/^[0-9a-f]{40}$/.test(metadata.base_sha)
81
+ || !/^[0-9a-f]{40}$/.test(metadata.head_sha)
82
+ ) {
83
+ throw new Error('invalid benchmark metadata contract');
84
+ }
85
+ return metadata;
86
+ }
87
+
88
+ function variantLabel(variant) {
89
+ if (variant?.occurrence_mode === 'exact') {
90
+ return 'exact';
91
+ }
92
+ if (
93
+ variant?.occurrence_mode === 'bounded'
94
+ && Number.isSafeInteger(variant.hot_pair_window_size)
95
+ && variant.hot_pair_window_size > 0
96
+ ) {
97
+ return `k${variant.hot_pair_window_size}`;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function stableValue(value) {
103
+ if (Array.isArray(value)) {
104
+ return value.map(stableValue);
105
+ }
106
+ if (!isRecord(value)) {
107
+ return value;
108
+ }
109
+ return Object.fromEntries(
110
+ Object.keys(value)
111
+ .sort()
112
+ .map((key) => [key, stableValue(value[key])]),
113
+ );
114
+ }
115
+
116
+ function trainerWorkload(sample) {
117
+ const requestCase = isRecord(sample?.request?.case) ? sample.request.case : {};
118
+ const expectedInputSha256 = requestCase.expected_input_sha256;
119
+ const normalizedCase = {
120
+ ...requestCase,
121
+ bbpe_fallback: requestCase.bbpe_fallback ?? false,
122
+ primary_vocab_ratio: requestCase.primary_vocab_ratio ?? 0.9,
123
+ };
124
+
125
+ // Checkout-local paths differ between the base and candidate worktrees. The
126
+ // expected and measured input fingerprints identify the corpus instead.
127
+ delete normalizedCase.words_path;
128
+ // Golden inputs and outputs are assertions about the work, not its settings.
129
+ delete normalizedCase.expected_input_sha256;
130
+ delete normalizedCase.expected_model_sha256;
131
+
132
+ return JSON.stringify(stableValue({
133
+ case: normalizedCase,
134
+ variant: sample?.request?.variant,
135
+ input_sha256: sample?.measurement?.input?.sha256
136
+ ?? expectedInputSha256
137
+ ?? null,
138
+ }));
139
+ }
140
+
141
+ function trainerRows(report) {
142
+ const rows = new Map();
143
+ for (const sample of report?.samples ?? []) {
144
+ const caseName = sample?.request?.case?.name;
145
+ const variant = variantLabel(sample?.request?.variant);
146
+ if (typeof caseName !== 'string' || caseName.length === 0 || variant === null) {
147
+ continue;
148
+ }
149
+ const key = JSON.stringify([caseName, variant]);
150
+ let row = rows.get(key);
151
+ if (!row) {
152
+ row = {
153
+ caseName,
154
+ variant,
155
+ times: [],
156
+ rssValues: [],
157
+ workloads: new Set(),
158
+ failed: report.gates.passed === false,
159
+ };
160
+ rows.set(key, row);
161
+ }
162
+ row.failed ||= sample?.status === 'failed' || sample?.error != null;
163
+ row.times.push(sample?.measurement?.timing?.core_training_ns);
164
+ row.rssValues.push(
165
+ sample?.measurement?.memory?.process_peak_rss_through_training_bytes,
166
+ );
167
+ row.workloads.add(trainerWorkload(sample));
168
+ }
169
+ for (const row of rows.values()) {
170
+ row.time = average(row.times);
171
+ row.rss = average(row.rssValues);
172
+ }
173
+ return rows;
174
+ }
175
+
176
+ function unionTrainerRows(
177
+ baseline,
178
+ candidate,
179
+ baselineAvailable,
180
+ candidateAvailable,
181
+ ) {
182
+ const rows = new Map();
183
+ for (const [key, row] of baseline) {
184
+ rows.set(key, {
185
+ caseName: row.caseName,
186
+ variant: row.variant,
187
+ baseline: row,
188
+ candidate: null,
189
+ baselineAvailable,
190
+ candidateAvailable,
191
+ });
192
+ }
193
+ for (const [key, row] of candidate) {
194
+ const existing = rows.get(key);
195
+ if (existing) {
196
+ existing.candidate = row;
197
+ } else {
198
+ rows.set(key, {
199
+ caseName: row.caseName,
200
+ variant: row.variant,
201
+ baseline: null,
202
+ candidate: row,
203
+ baselineAvailable,
204
+ candidateAvailable,
205
+ });
206
+ }
207
+ }
208
+ return [...rows.values()];
209
+ }
210
+
211
+ function sameWorkloads(baseline, candidate) {
212
+ if (baseline.workloads.size !== candidate.workloads.size) {
213
+ return false;
214
+ }
215
+ return [...baseline.workloads].every((workload) => candidate.workloads.has(workload));
216
+ }
217
+
218
+ function pretokenizerValues(report) {
219
+ const values = new Map();
220
+ const samples = report?.samples ?? [];
221
+ values.set('bigram', average(
222
+ samples.map((sample) => sample?.measurement?.timing?.bigram_pass_ns),
223
+ ));
224
+ values.set('word', average(
225
+ samples.map((sample) => sample?.measurement?.timing?.word_pass_ns),
226
+ ));
227
+ values.set('total', average(
228
+ samples.map((sample) => sample?.measurement?.timing?.core_pretokenizer_ns),
229
+ ));
230
+ values.set('rss', average(
231
+ samples.map(
232
+ (sample) => sample?.measurement?.memory?.process_peak_rss_through_core_bytes,
233
+ ),
234
+ ));
235
+ return values;
236
+ }
237
+
238
+ function codecValues(report) {
239
+ const values = new Map();
240
+ const samples = report?.samples ?? [];
241
+ values.set('encode', average(
242
+ samples.map((sample) => sample?.encode?.timing?.encode_ns),
243
+ ));
244
+ values.set('decode', average(
245
+ samples.map((sample) => sample?.decode?.timing?.decode_ns),
246
+ ));
247
+ values.set('encode_rss', average(
248
+ samples.map(
249
+ (sample) => sample?.encode?.memory?.process_peak_rss_through_phase_bytes,
250
+ ),
251
+ ));
252
+ values.set('decode_rss', average(
253
+ samples.map(
254
+ (sample) => sample?.decode?.memory?.process_peak_rss_through_phase_bytes,
255
+ ),
256
+ ));
257
+ return values;
258
+ }
259
+
260
+ function codecWorkload(report) {
261
+ const config = { ...(report?.config ?? {}) };
262
+ for (const key of Object.keys(config)) {
263
+ if (key === 'name' || key.endsWith('_path') || key.startsWith('expected_')) {
264
+ delete config[key];
265
+ }
266
+ }
267
+ const measurements = [...new Set(
268
+ (report?.samples ?? []).map((sample) => JSON.stringify(stableValue({
269
+ input: sample?.encode?.input ?? null,
270
+ model: sample?.encode?.model ?? null,
271
+ }))),
272
+ )].sort();
273
+ return JSON.stringify(stableValue({ config, measurements }));
274
+ }
275
+
276
+ function formatMilliseconds(value) {
277
+ return value === null ? 'n/a' : `${(value / 1_000_000).toFixed(2)} ms`;
278
+ }
279
+
280
+ function formatMebibytes(value) {
281
+ return value === null ? 'n/a' : `${(value / 1024 / 1024).toFixed(1)} MiB`;
282
+ }
283
+
284
+ function formatDelta(baseline, candidate) {
285
+ if (baseline === null || candidate === null || baseline === 0) {
286
+ return 'n/a';
287
+ }
288
+ const delta = ((candidate - baseline) / baseline) * 100;
289
+ return `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}%`;
290
+ }
291
+
292
+ function tableRow(label, baseline, candidate, formatter) {
293
+ return `| ${label} | ${formatter(baseline)} | ${formatter(candidate)} | ${formatDelta(baseline, candidate)} |`;
294
+ }
295
+
296
+ function escapeTableCell(value) {
297
+ return value.replaceAll('|', '\\|').replace(/[\r\n]+/g, ' ');
298
+ }
299
+
300
+ function trainerCellValue(row, reportAvailable, metric, formatter) {
301
+ if (!reportAvailable) {
302
+ return 'unavailable';
303
+ }
304
+ if (!row) {
305
+ return 'missing';
306
+ }
307
+ return row.failed ? 'failed' : formatter(row[metric]);
308
+ }
309
+
310
+ function trainerTableRow(row, metric, formatter) {
311
+ const baseline = row.baseline;
312
+ const candidate = row.candidate;
313
+ const baselineValue = trainerCellValue(
314
+ baseline,
315
+ row.baselineAvailable,
316
+ metric,
317
+ formatter,
318
+ );
319
+ const candidateValue = trainerCellValue(
320
+ candidate,
321
+ row.candidateAvailable,
322
+ metric,
323
+ formatter,
324
+ );
325
+ let delta = 'n/a';
326
+ if (baseline && candidate && !baseline.failed && !candidate.failed) {
327
+ delta = sameWorkloads(baseline, candidate)
328
+ ? formatDelta(baseline[metric], candidate[metric])
329
+ : 'changed';
330
+ }
331
+ const caseLabel = TRAINER_LABELS.get(row.caseName) ?? row.caseName;
332
+ const label = escapeTableCell(`Trainer — ${caseLabel} (${row.variant})`);
333
+ return `| ${label} | ${baselineValue} | ${candidateValue} | ${delta} |`;
334
+ }
335
+
336
+ function optionalReportCell(state, values, metric, formatter) {
337
+ if (state.status === 'invalid') {
338
+ return 'unavailable';
339
+ }
340
+ if (state.status === 'absent') {
341
+ return 'missing';
342
+ }
343
+ if (state.report.gates.passed !== true) {
344
+ return 'failed';
345
+ }
346
+ return formatter(values.get(metric) ?? null);
347
+ }
348
+
349
+ function optionalCodecTableRow(
350
+ label,
351
+ baseline,
352
+ candidate,
353
+ baselineValues,
354
+ candidateValues,
355
+ metric,
356
+ formatter,
357
+ ) {
358
+ const baselineValue = baselineValues.get(metric) ?? null;
359
+ const candidateValue = candidateValues.get(metric) ?? null;
360
+ let delta = 'n/a';
361
+ if (
362
+ baseline.status === 'present'
363
+ && baseline.report.gates.passed === true
364
+ && candidate.status === 'present'
365
+ && candidate.report.gates.passed === true
366
+ ) {
367
+ delta = codecWorkload(baseline.report) === codecWorkload(candidate.report)
368
+ ? formatDelta(baselineValue, candidateValue)
369
+ : 'changed';
370
+ }
371
+ return `| ${label} | ${optionalReportCell(baseline, baselineValues, metric, formatter)} | ${optionalReportCell(candidate, candidateValues, metric, formatter)} | ${delta} |`;
372
+ }
373
+
374
+ function reportSet(resultsDir, side, errors) {
375
+ const prefix = `${side}/`;
376
+ return {
377
+ trainer: readReport(
378
+ resultsDir,
379
+ `${prefix}trainer.json`,
380
+ 'unitoken_trainer_regression_v1',
381
+ errors,
382
+ ),
383
+ pretokenizer: readReport(
384
+ resultsDir,
385
+ `${prefix}pretokenizer.json`,
386
+ 'unitoken_pretokenizer_regression_v1',
387
+ errors,
388
+ ),
389
+ byteCodec: readReport(
390
+ resultsDir,
391
+ `${prefix}codec-byte.json`,
392
+ 'unitoken_codec_regression_v1',
393
+ errors,
394
+ ),
395
+ unicodeCodec: readReport(
396
+ resultsDir,
397
+ `${prefix}codec-unicode.json`,
398
+ 'unitoken_codec_regression_v1',
399
+ errors,
400
+ ),
401
+ bbpeUnicodeCodec: readOptionalReport(
402
+ resultsDir,
403
+ `${prefix}codec-unicode-bbpe.json`,
404
+ 'unitoken_codec_regression_v1',
405
+ errors,
406
+ ),
407
+ };
408
+ }
409
+
410
+ function buildComment({ resultsDir, conclusion, baseSha, headSha, runUrl }) {
411
+ const errors = [];
412
+ const baseline = reportSet(resultsDir, 'baseline', errors);
413
+ const candidate = reportSet(resultsDir, 'candidate', errors);
414
+ const trainerRowsToRender = unionTrainerRows(
415
+ trainerRows(baseline.trainer),
416
+ trainerRows(candidate.trainer),
417
+ baseline.trainer !== null,
418
+ candidate.trainer !== null,
419
+ );
420
+ const reports = [
421
+ baseline.trainer,
422
+ baseline.pretokenizer,
423
+ baseline.byteCodec,
424
+ baseline.unicodeCodec,
425
+ baseline.bbpeUnicodeCodec.report,
426
+ candidate.trainer,
427
+ candidate.pretokenizer,
428
+ candidate.byteCodec,
429
+ candidate.unicodeCodec,
430
+ candidate.bbpeUnicodeCodec.report,
431
+ ].filter((report) => report !== null);
432
+ const bbpeCodecRegression = baseline.bbpeUnicodeCodec.status === 'present'
433
+ && candidate.bbpeUnicodeCodec.status === 'absent';
434
+ const passed = conclusion === 'success'
435
+ && errors.length === 0
436
+ && !bbpeCodecRegression
437
+ && reports.every((report) => report?.gates?.passed === true)
438
+ && trainerRowsToRender.every(
439
+ (row) => !row.baseline?.failed && !row.candidate?.failed,
440
+ );
441
+ const basePretokenizer = pretokenizerValues(baseline.pretokenizer);
442
+ const headPretokenizer = pretokenizerValues(candidate.pretokenizer);
443
+ const baseByteCodec = codecValues(baseline.byteCodec);
444
+ const headByteCodec = codecValues(candidate.byteCodec);
445
+ const baseUnicodeCodec = codecValues(baseline.unicodeCodec);
446
+ const headUnicodeCodec = codecValues(candidate.unicodeCodec);
447
+ const baseBbpeUnicodeCodec = codecValues(baseline.bbpeUnicodeCodec.report);
448
+ const headBbpeUnicodeCodec = codecValues(candidate.bbpeUnicodeCodec.report);
449
+ const renderBbpeUnicodeCodec = baseline.bbpeUnicodeCodec.status !== 'absent'
450
+ || candidate.bbpeUnicodeCodec.status !== 'absent';
451
+ const lines = [
452
+ MARKER,
453
+ '## Benchmark report',
454
+ '',
455
+ passed
456
+ ? '✅ All base and PR correctness gates passed.'
457
+ : '❌ The benchmark run or at least one correctness gate failed.',
458
+ '',
459
+ `Compared \`${baseSha.slice(0, 7)}\` → \`${headSha.slice(0, 7)}\` sequentially on the same runner. Timing deltas are informational.`,
460
+ '',
461
+ 'Trainer and optional-codec cells marked `missing` are absent cases or reports; `unavailable` means a required report is missing or a report is invalid; `failed` means that revision failed its report or case; `changed` means the workloads are not comparable.',
462
+ '',
463
+ '| Benchmark | Base | PR | Δ |',
464
+ '| --- | ---: | ---: | ---: |',
465
+ ];
466
+
467
+ for (const row of trainerRowsToRender) {
468
+ lines.push(trainerTableRow(row, 'time', formatMilliseconds));
469
+ }
470
+ for (const [label, key] of [
471
+ ['Pretokenizer — bigram pass', 'bigram'],
472
+ ['Pretokenizer — word pass', 'word'],
473
+ ['Pretokenizer — total', 'total'],
474
+ ]) {
475
+ lines.push(tableRow(
476
+ label,
477
+ basePretokenizer.get(key) ?? null,
478
+ headPretokenizer.get(key) ?? null,
479
+ formatMilliseconds,
480
+ ));
481
+ }
482
+ for (const [label, values, key] of [
483
+ ['Codec — byte encode', [baseByteCodec, headByteCodec], 'encode'],
484
+ ['Codec — byte decode', [baseByteCodec, headByteCodec], 'decode'],
485
+ ['Codec — Unicode encode', [baseUnicodeCodec, headUnicodeCodec], 'encode'],
486
+ ['Codec — Unicode decode', [baseUnicodeCodec, headUnicodeCodec], 'decode'],
487
+ ]) {
488
+ lines.push(tableRow(
489
+ label,
490
+ values[0].get(key) ?? null,
491
+ values[1].get(key) ?? null,
492
+ formatMilliseconds,
493
+ ));
494
+ }
495
+ if (renderBbpeUnicodeCodec) {
496
+ for (const [label, key] of [
497
+ ['Codec — Unicode BBPE encode, vocab 1k', 'encode'],
498
+ ['Codec — Unicode BBPE decode, vocab 1k', 'decode'],
499
+ ]) {
500
+ lines.push(optionalCodecTableRow(
501
+ label,
502
+ baseline.bbpeUnicodeCodec,
503
+ candidate.bbpeUnicodeCodec,
504
+ baseBbpeUnicodeCodec,
505
+ headBbpeUnicodeCodec,
506
+ key,
507
+ formatMilliseconds,
508
+ ));
509
+ }
510
+ }
511
+
512
+ lines.push('', '<details>', '<summary>Peak RSS</summary>', '');
513
+ lines.push('| Benchmark | Base | PR | Δ |');
514
+ lines.push('| --- | ---: | ---: | ---: |');
515
+ for (const row of trainerRowsToRender) {
516
+ lines.push(trainerTableRow(row, 'rss', formatMebibytes));
517
+ }
518
+ lines.push(tableRow(
519
+ 'Pretokenizer',
520
+ basePretokenizer.get('rss') ?? null,
521
+ headPretokenizer.get('rss') ?? null,
522
+ formatMebibytes,
523
+ ));
524
+ for (const [label, values, key] of [
525
+ ['Codec — byte encode', [baseByteCodec, headByteCodec], 'encode_rss'],
526
+ ['Codec — byte decode', [baseByteCodec, headByteCodec], 'decode_rss'],
527
+ ['Codec — Unicode encode', [baseUnicodeCodec, headUnicodeCodec], 'encode_rss'],
528
+ ['Codec — Unicode decode', [baseUnicodeCodec, headUnicodeCodec], 'decode_rss'],
529
+ ]) {
530
+ lines.push(tableRow(
531
+ label,
532
+ values[0].get(key) ?? null,
533
+ values[1].get(key) ?? null,
534
+ formatMebibytes,
535
+ ));
536
+ }
537
+ if (renderBbpeUnicodeCodec) {
538
+ for (const [label, key] of [
539
+ ['Codec — Unicode BBPE encode, vocab 1k', 'encode_rss'],
540
+ ['Codec — Unicode BBPE decode, vocab 1k', 'decode_rss'],
541
+ ]) {
542
+ lines.push(optionalCodecTableRow(
543
+ label,
544
+ baseline.bbpeUnicodeCodec,
545
+ candidate.bbpeUnicodeCodec,
546
+ baseBbpeUnicodeCodec,
547
+ headBbpeUnicodeCodec,
548
+ key,
549
+ formatMebibytes,
550
+ ));
551
+ }
552
+ }
553
+ lines.push('', '</details>', '');
554
+ if (bbpeCodecRegression) {
555
+ lines.push(
556
+ 'Optional benchmark regression: `candidate/codec-unicode-bbpe.json` is absent while the base report is present.',
557
+ '',
558
+ );
559
+ }
560
+ if (errors.length > 0) {
561
+ lines.push(`Missing or invalid reports: ${errors.map((name) => `\`${name}\``).join(', ')}.`, '');
562
+ }
563
+ lines.push(`[Open benchmark run](${runUrl})`);
564
+ return lines.join('\n');
565
+ }
566
+
567
+ async function updateBenchmarkComment({ github, context, core }) {
568
+ const workflowRun = context.payload.workflow_run;
569
+ const metadata = readMetadata(process.env.BENCHMARK_RESULTS_DIR);
570
+ const pullRequests = workflowRun?.pull_requests ?? [];
571
+ if (
572
+ pullRequests.length > 1
573
+ || (pullRequests.length === 1 && pullRequests[0].number !== metadata.pull_request_number)
574
+ ) {
575
+ core.info('Benchmark metadata does not match the triggering pull request; skipping comment.');
576
+ return;
577
+ }
578
+
579
+ const pullNumber = metadata.pull_request_number;
580
+ const owner = context.repo.owner;
581
+ const repo = context.repo.repo;
582
+ const { data: pullRequest } = await github.rest.pulls.get({
583
+ owner,
584
+ repo,
585
+ pull_number: pullNumber,
586
+ });
587
+ const headRepository = workflowRun?.head_repository?.full_name;
588
+ if (
589
+ pullRequest.head.sha !== metadata.head_sha
590
+ || pullRequest.head.ref !== workflowRun?.head_branch
591
+ || pullRequest.head.repo?.full_name !== headRepository
592
+ ) {
593
+ core.info('The benchmark origin or PR head no longer matches; skipping stale results.');
594
+ return;
595
+ }
596
+
597
+ const runId = process.env.WORKFLOW_RUN_ID;
598
+ const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${runId}`;
599
+ const body = buildComment({
600
+ resultsDir: process.env.BENCHMARK_RESULTS_DIR,
601
+ conclusion: process.env.WORKFLOW_CONCLUSION,
602
+ baseSha: metadata.base_sha,
603
+ headSha: metadata.head_sha,
604
+ runUrl,
605
+ });
606
+ const comments = await github.paginate(github.rest.issues.listComments, {
607
+ owner,
608
+ repo,
609
+ issue_number: pullNumber,
610
+ per_page: 100,
611
+ });
612
+ const existing = comments.find(
613
+ (comment) => comment.user?.type === 'Bot' && comment.body?.includes(MARKER),
614
+ );
615
+ if (existing) {
616
+ await github.rest.issues.updateComment({
617
+ owner,
618
+ repo,
619
+ comment_id: existing.id,
620
+ body,
621
+ });
622
+ } else {
623
+ await github.rest.issues.createComment({
624
+ owner,
625
+ repo,
626
+ issue_number: pullNumber,
627
+ body,
628
+ });
629
+ }
630
+ }
631
+
632
+ module.exports = updateBenchmarkComment;
633
+ module.exports.buildComment = buildComment;