@hackerrank/astra-cli 0.1.0 → 0.1.1
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.
- package/README.md +36 -2
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/bench.js +478 -0
- package/src/cli.js +322 -33
- package/src/config.js +78 -0
- package/src/model.js +9 -0
- package/src/models.js +31 -0
- package/src/prompts.js +7 -3
- package/src/repl.js +213 -16
- package/src/report.js +459 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bench report: scan run folders into a tidy `astra-bench-1` summary and a
|
|
3
|
+
* self-contained HTML dashboard (charts + tables).
|
|
4
|
+
*
|
|
5
|
+
* Artifacts written under the bench root:
|
|
6
|
+
* summary.json data contract (kpis, leaderboard, matrix, runs, step_series)
|
|
7
|
+
* report.html standalone dashboard (embeds summary.json)
|
|
8
|
+
* <slug>/run-NN/run.json per-attempt record with command timeline
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const SCHEMA = "astra-bench-1";
|
|
15
|
+
const RUN_SCHEMA = "astra-run-1";
|
|
16
|
+
|
|
17
|
+
/** Rebuild summary.json + report.html from whatever is already on disk. */
|
|
18
|
+
export function refreshReport(root) {
|
|
19
|
+
const rootDir = path.resolve(root || path.join(process.cwd(), "bench"));
|
|
20
|
+
if (!fs.existsSync(rootDir)) {
|
|
21
|
+
throw new Error(`bench root not found: ${rootDir}`);
|
|
22
|
+
}
|
|
23
|
+
const runs = scanRuns(rootDir);
|
|
24
|
+
const summary = buildSummary(runs);
|
|
25
|
+
|
|
26
|
+
for (const run of runs) {
|
|
27
|
+
const dir = path.join(rootDir, run.paths.dir);
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(path.join(dir, "run.json"), JSON.stringify(toRunDoc(run), null, 2) + "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const summaryPath = path.join(rootDir, "summary.json");
|
|
33
|
+
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + "\n");
|
|
34
|
+
|
|
35
|
+
const htmlPath = path.join(rootDir, "report.html");
|
|
36
|
+
fs.writeFileSync(htmlPath, renderReportHtml(summary));
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
root: rootDir,
|
|
40
|
+
summary: summaryPath,
|
|
41
|
+
html: htmlPath,
|
|
42
|
+
runs: runs.length,
|
|
43
|
+
models: summary.leaderboard.length,
|
|
44
|
+
tasks: summary.kpis.tasks,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inferTaskTitle(text) {
|
|
49
|
+
const heading = String(text || "").match(/^#\s+(.+)$/m);
|
|
50
|
+
if (heading) return heading[1].replace(/`/g, "").trim();
|
|
51
|
+
const first = String(text || "").trim().split("\n")[0] || "";
|
|
52
|
+
return first.slice(0, 80) || "untitled";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function inferTaskId({ taskPath, taskFile, taskText, taskMd } = {}) {
|
|
56
|
+
if (taskPath) {
|
|
57
|
+
try {
|
|
58
|
+
const resolved = path.resolve(taskPath);
|
|
59
|
+
return path.basename(fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved));
|
|
60
|
+
} catch {
|
|
61
|
+
return path.basename(String(taskPath).replace(/\/+$/, ""));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (taskFile) return path.basename(path.dirname(path.resolve(taskFile)));
|
|
65
|
+
return slugify(inferTaskTitle(taskMd || taskText)) || "inline";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Walk bench/<slug>/run-NN/ and reconstruct one record per attempt. */
|
|
69
|
+
export function scanRuns(rootDir) {
|
|
70
|
+
const runs = [];
|
|
71
|
+
for (const slug of safeReaddir(rootDir)) {
|
|
72
|
+
const slugDir = path.join(rootDir, slug);
|
|
73
|
+
if (!isDir(slugDir)) continue;
|
|
74
|
+
for (const runId of safeReaddir(slugDir).sort()) {
|
|
75
|
+
if (!/^run-\d+$/.test(runId)) continue;
|
|
76
|
+
const dir = path.join(slugDir, runId);
|
|
77
|
+
const rec = readRunDir({ rootDir, slug, runId, dir });
|
|
78
|
+
if (rec) runs.push(rec);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
runs.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
|
|
82
|
+
return runs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildSummary(runs) {
|
|
86
|
+
const leaderboard = groupBy(runs, (r) => r.slug).map(([slug, rs]) => {
|
|
87
|
+
const costRuns = rs.filter((r) => r.cost_source);
|
|
88
|
+
const sources = new Set(costRuns.map((r) => r.cost_source));
|
|
89
|
+
const outcomes = {};
|
|
90
|
+
for (const r of rs) {
|
|
91
|
+
const k = r.exit_status || "Unknown";
|
|
92
|
+
outcomes[k] = (outcomes[k] || 0) + 1;
|
|
93
|
+
}
|
|
94
|
+
const solved = rs.filter((r) => r.resolved).length;
|
|
95
|
+
return {
|
|
96
|
+
model: rs[0].model,
|
|
97
|
+
reasoning: rs[0].reasoning,
|
|
98
|
+
slug,
|
|
99
|
+
runs: rs.length,
|
|
100
|
+
solved,
|
|
101
|
+
solved_rate: rs.length ? solved / rs.length : 0,
|
|
102
|
+
outcomes,
|
|
103
|
+
avg_steps: avg(rs, (r) => r.steps),
|
|
104
|
+
avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
|
|
105
|
+
avg_tokens: avg(rs, (r) => r.tokens.total),
|
|
106
|
+
sum_tokens: sum(rs, (r) => r.tokens.total),
|
|
107
|
+
tokens: {
|
|
108
|
+
prompt: sum(rs, (r) => r.tokens.prompt),
|
|
109
|
+
completion: sum(rs, (r) => r.tokens.completion),
|
|
110
|
+
reasoning: sum(rs, (r) => r.tokens.reasoning),
|
|
111
|
+
cached: sum(rs, (r) => r.tokens.cached),
|
|
112
|
+
cache_write: sum(rs, (r) => r.tokens.cache_write),
|
|
113
|
+
},
|
|
114
|
+
cost_usd: costRuns.length ? sum(costRuns, (r) => r.cost_usd) : null,
|
|
115
|
+
cost_source: sources.size === 0 ? "unknown" : sources.size === 1 ? [...sources][0] : "mixed",
|
|
116
|
+
avg_n_failed_commands: avg(rs, (r) => r.n_failed_commands),
|
|
117
|
+
avg_n_format_errors: avg(rs, (r) => r.n_format_errors),
|
|
118
|
+
avg_n_retries: avg(rs, (r) => r.n_retries),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
leaderboard.sort((a, b) => b.solved_rate - a.solved_rate || a.avg_steps - b.avg_steps);
|
|
122
|
+
|
|
123
|
+
const matrix = groupBy(runs, (r) => `${r.slug}\u0000${r.task_id}`).map(([, rs]) => {
|
|
124
|
+
const passed = rs.filter((r) => r.resolved).length;
|
|
125
|
+
const costRuns = rs.filter((r) => r.cost_source);
|
|
126
|
+
return {
|
|
127
|
+
model: rs[0].model,
|
|
128
|
+
reasoning: rs[0].reasoning,
|
|
129
|
+
slug: rs[0].slug,
|
|
130
|
+
task_id: rs[0].task_id,
|
|
131
|
+
task_title: rs[0].task_title,
|
|
132
|
+
k: rs.length,
|
|
133
|
+
passed,
|
|
134
|
+
pass_at_k: rs.length ? passed / rs.length : 0,
|
|
135
|
+
avg_steps: avg(rs, (r) => r.steps),
|
|
136
|
+
avg_tokens: avg(rs, (r) => r.tokens.total),
|
|
137
|
+
avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
|
|
138
|
+
cost_usd: costRuns.length ? sum(costRuns, (r) => r.cost_usd) : null,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const buckets = new Map();
|
|
143
|
+
for (const run of runs) {
|
|
144
|
+
let cum = 0;
|
|
145
|
+
for (const step of run.timeline || []) {
|
|
146
|
+
cum += Number(step.cost_usd) || 0;
|
|
147
|
+
const key = `${run.slug}\u0000${step.step}`;
|
|
148
|
+
if (!buckets.has(key)) {
|
|
149
|
+
buckets.set(key, {
|
|
150
|
+
model: run.model,
|
|
151
|
+
reasoning: run.reasoning,
|
|
152
|
+
slug: run.slug,
|
|
153
|
+
step: step.step,
|
|
154
|
+
prompt: [],
|
|
155
|
+
completion: [],
|
|
156
|
+
reasoningTok: [],
|
|
157
|
+
cached: [],
|
|
158
|
+
cost: [],
|
|
159
|
+
cum: [],
|
|
160
|
+
fails: 0,
|
|
161
|
+
n: 0,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const b = buckets.get(key);
|
|
165
|
+
b.n++;
|
|
166
|
+
b.prompt.push(step.tokens?.prompt || 0);
|
|
167
|
+
b.completion.push(step.tokens?.completion || 0);
|
|
168
|
+
b.reasoningTok.push(step.tokens?.reasoning || 0);
|
|
169
|
+
b.cached.push(step.tokens?.cached || 0);
|
|
170
|
+
b.cost.push(Number(step.cost_usd) || 0);
|
|
171
|
+
b.cum.push(cum);
|
|
172
|
+
if (step.returncode != null && Number(step.returncode) !== 0) b.fails++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const step_series = [...buckets.values()]
|
|
176
|
+
.map((b) => ({
|
|
177
|
+
model: b.model,
|
|
178
|
+
reasoning: b.reasoning,
|
|
179
|
+
slug: b.slug,
|
|
180
|
+
step: b.step,
|
|
181
|
+
n: b.n,
|
|
182
|
+
prompt_tokens: dist(b.prompt),
|
|
183
|
+
completion_tokens: dist(b.completion),
|
|
184
|
+
reasoning_tokens: dist(b.reasoningTok),
|
|
185
|
+
cached_tokens: dist(b.cached),
|
|
186
|
+
cost_usd: dist(b.cost),
|
|
187
|
+
cum_cost_usd: dist(b.cum),
|
|
188
|
+
fail_rate: b.n ? b.fails / b.n : 0,
|
|
189
|
+
}))
|
|
190
|
+
.sort((a, b) => a.slug.localeCompare(b.slug) || a.step - b.step);
|
|
191
|
+
|
|
192
|
+
const costRuns = runs.filter((r) => r.cost_source);
|
|
193
|
+
const solved = runs.filter((r) => r.resolved).length;
|
|
194
|
+
const sources = new Set(costRuns.map((r) => r.cost_source));
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
schema: SCHEMA,
|
|
198
|
+
generated_at: new Date().toISOString(),
|
|
199
|
+
kpis: {
|
|
200
|
+
models: new Set(runs.map((r) => r.slug)).size,
|
|
201
|
+
tasks: new Set(runs.map((r) => r.task_id)).size,
|
|
202
|
+
runs: runs.length,
|
|
203
|
+
solved,
|
|
204
|
+
solved_rate: runs.length ? solved / runs.length : 0,
|
|
205
|
+
cost_usd: costRuns.length ? round(sum(costRuns, (r) => r.cost_usd), 6) : null,
|
|
206
|
+
cost_source: sources.size === 0 ? "unknown" : sources.size === 1 ? [...sources][0] : "mixed",
|
|
207
|
+
tokens: sum(runs, (r) => r.tokens.total),
|
|
208
|
+
elapsed_seconds: sum(runs, (r) => r.elapsed_seconds),
|
|
209
|
+
},
|
|
210
|
+
leaderboard,
|
|
211
|
+
matrix,
|
|
212
|
+
runs,
|
|
213
|
+
step_series,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function renderReportHtml(summary) {
|
|
218
|
+
const tplPath = new URL("./report.html", import.meta.url);
|
|
219
|
+
const tpl = fs.readFileSync(tplPath, "utf8");
|
|
220
|
+
const data = JSON.stringify(summary).replace(/</g, "\\u003c");
|
|
221
|
+
if (!tpl.includes("/*__ASTRA_DATA__*/")) {
|
|
222
|
+
throw new Error("src/report.html is missing the /*__ASTRA_DATA__*/ injection marker");
|
|
223
|
+
}
|
|
224
|
+
return tpl.replace("/*__ASTRA_DATA__*/", `window.DATA = ${data};`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function toRunDoc(run) {
|
|
228
|
+
return { schema: RUN_SCHEMA, ...run };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readRunDir({ rootDir, slug, runId, dir }) {
|
|
232
|
+
const trajPath = path.join(dir, "trajectory.json");
|
|
233
|
+
const metricsPath = path.join(dir, "metrics.csv");
|
|
234
|
+
const taskPath = path.join(dir, "task.md");
|
|
235
|
+
if (!fs.existsSync(trajPath) && !fs.existsSync(metricsPath)) return null;
|
|
236
|
+
|
|
237
|
+
let traj = null;
|
|
238
|
+
if (fs.existsSync(trajPath)) {
|
|
239
|
+
try {
|
|
240
|
+
traj = JSON.parse(fs.readFileSync(trajPath, "utf8"));
|
|
241
|
+
} catch {
|
|
242
|
+
traj = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const metricsRow = fs.existsSync(metricsPath) ? parseCsv(fs.readFileSync(metricsPath, "utf8"))[0] : null;
|
|
246
|
+
const taskMd = fs.existsSync(taskPath) ? fs.readFileSync(taskPath, "utf8") : traj?.info?.task || "";
|
|
247
|
+
const parsed = parseSlug(slug);
|
|
248
|
+
const model = metricsRow?.model || traj?.info?.model || parsed.model;
|
|
249
|
+
const reasoning = metricsRow?.reasoning || parsed.reasoning;
|
|
250
|
+
const runNum = Number((/^run-(\d+)$/.exec(runId) || [])[1] || 0);
|
|
251
|
+
const timeline = extractTimeline(traj);
|
|
252
|
+
const tokens = {
|
|
253
|
+
prompt: num(metricsRow?.prompt_tokens ?? traj?.info?.tokens?.prompt),
|
|
254
|
+
completion: num(metricsRow?.completion_tokens ?? traj?.info?.tokens?.completion),
|
|
255
|
+
reasoning: num(metricsRow?.reasoning_tokens),
|
|
256
|
+
cached: num(metricsRow?.cached_tokens),
|
|
257
|
+
cache_write: num(metricsRow?.cache_write_tokens),
|
|
258
|
+
total: num(metricsRow?.total_tokens ?? traj?.info?.tokens?.total),
|
|
259
|
+
last_context: num(metricsRow?.last_context_tokens ?? traj?.info?.tokens?.last_context),
|
|
260
|
+
};
|
|
261
|
+
const costSource = metricsRow?.cost_source || traj?.info?.cost?.source || "";
|
|
262
|
+
const resolvedRaw = metricsRow?.resolved;
|
|
263
|
+
const resolved =
|
|
264
|
+
resolvedRaw == null
|
|
265
|
+
? traj?.info?.exit_status === "Submitted"
|
|
266
|
+
: resolvedRaw === true || resolvedRaw === "1" || resolvedRaw === 1;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
id: `${slug}/${runId}`,
|
|
270
|
+
timestamp: metricsRow?.timestamp || traj?.created || "",
|
|
271
|
+
model,
|
|
272
|
+
reasoning,
|
|
273
|
+
slug,
|
|
274
|
+
task_id: inferTaskId({ taskMd }),
|
|
275
|
+
task_title: inferTaskTitle(taskMd),
|
|
276
|
+
run: runNum,
|
|
277
|
+
run_id: runId,
|
|
278
|
+
exit_status: metricsRow?.exit_status || traj?.info?.exit_status || "",
|
|
279
|
+
resolved: !!resolved,
|
|
280
|
+
error: null,
|
|
281
|
+
steps: num(metricsRow?.steps ?? traj?.info?.n_steps),
|
|
282
|
+
n_calls: num(metricsRow?.n_calls ?? traj?.info?.n_calls),
|
|
283
|
+
n_commands: num(metricsRow?.n_commands),
|
|
284
|
+
n_failed_commands: num(metricsRow?.n_failed_commands),
|
|
285
|
+
n_format_errors: num(metricsRow?.n_format_errors),
|
|
286
|
+
n_retries: num(metricsRow?.n_retries),
|
|
287
|
+
elapsed_seconds: num(metricsRow?.elapsed_seconds ?? traj?.info?.elapsed_seconds),
|
|
288
|
+
tokens,
|
|
289
|
+
cost_usd: costSource ? num(metricsRow?.cost_usd ?? traj?.info?.cost?.usd) : null,
|
|
290
|
+
cost_source: costSource || "",
|
|
291
|
+
timeline,
|
|
292
|
+
paths: {
|
|
293
|
+
dir: path.relative(rootDir, dir),
|
|
294
|
+
trajectory: path.relative(rootDir, trajPath),
|
|
295
|
+
workspace: path.relative(rootDir, path.join(dir, "workspace")),
|
|
296
|
+
task: path.relative(rootDir, taskPath),
|
|
297
|
+
run: path.relative(rootDir, path.join(dir, "run.json")),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function extractTimeline(doc) {
|
|
303
|
+
const messages = doc?.messages || [];
|
|
304
|
+
const steps = [];
|
|
305
|
+
let step = 0;
|
|
306
|
+
for (let i = 0; i < messages.length; i++) {
|
|
307
|
+
const m = messages[i];
|
|
308
|
+
if (m.role !== "assistant") continue;
|
|
309
|
+
step++;
|
|
310
|
+
const next = messages[i + 1];
|
|
311
|
+
const command = commandFrom(m, next);
|
|
312
|
+
const usage = m.extra?.usage || {};
|
|
313
|
+
steps.push({
|
|
314
|
+
step,
|
|
315
|
+
thought: thoughtFrom(m.content),
|
|
316
|
+
command,
|
|
317
|
+
returncode: next?.extra?.returncode ?? null,
|
|
318
|
+
output_preview: preview(stripObs(next?.content), 360),
|
|
319
|
+
tokens: {
|
|
320
|
+
prompt: num(usage.prompt_tokens),
|
|
321
|
+
completion: num(usage.completion_tokens),
|
|
322
|
+
total: num(usage.total_tokens),
|
|
323
|
+
cached: num(usage.cached_tokens),
|
|
324
|
+
cache_write: num(usage.cache_write_tokens),
|
|
325
|
+
reasoning: num(usage.reasoning_tokens),
|
|
326
|
+
},
|
|
327
|
+
cost_usd: usage.cost_usd == null ? null : num(usage.cost_usd),
|
|
328
|
+
cost_kind: usage.cost_kind || "",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return steps;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function commandFrom(assistant, next) {
|
|
335
|
+
if (next?.extra?.command) return String(next.extra.command);
|
|
336
|
+
const m = /```(?:bash|sh)?\s*\n([\s\S]*?)```/.exec(assistant?.content || "");
|
|
337
|
+
return m ? m[1].trim() : "";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function thoughtFrom(content) {
|
|
341
|
+
return String(content || "")
|
|
342
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
343
|
+
.trim()
|
|
344
|
+
.replace(/\s+/g, " ")
|
|
345
|
+
.slice(0, 240);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function stripObs(content) {
|
|
349
|
+
return String(content || "")
|
|
350
|
+
.replace(/<\/?returncode>/g, "")
|
|
351
|
+
.replace(/<\/?output>/g, "")
|
|
352
|
+
.trim();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function preview(s, n) {
|
|
356
|
+
const t = String(s || "");
|
|
357
|
+
return t.length > n ? t.slice(0, n) + "…" : t;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parseSlug(slug) {
|
|
361
|
+
const m = /^(.*)-(off|none|low|medium|high|xhigh|disabled)$/.exec(slug || "");
|
|
362
|
+
if (m) return { model: m[1], reasoning: m[2] };
|
|
363
|
+
return { model: slug, reasoning: "none" };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseCsv(text) {
|
|
367
|
+
const lines = String(text || "").trim().split(/\r?\n/);
|
|
368
|
+
if (lines.length < 2) return [];
|
|
369
|
+
const headers = splitCsvLine(lines[0]);
|
|
370
|
+
return lines.slice(1).filter(Boolean).map((line) => {
|
|
371
|
+
const cells = splitCsvLine(line);
|
|
372
|
+
const o = {};
|
|
373
|
+
headers.forEach((h, i) => {
|
|
374
|
+
o[h] = cells[i] ?? "";
|
|
375
|
+
});
|
|
376
|
+
return o;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function splitCsvLine(line) {
|
|
381
|
+
const out = [];
|
|
382
|
+
let cur = "";
|
|
383
|
+
let q = false;
|
|
384
|
+
for (let i = 0; i < line.length; i++) {
|
|
385
|
+
const c = line[i];
|
|
386
|
+
if (q) {
|
|
387
|
+
if (c === '"' && line[i + 1] === '"') {
|
|
388
|
+
cur += '"';
|
|
389
|
+
i++;
|
|
390
|
+
} else if (c === '"') q = false;
|
|
391
|
+
else cur += c;
|
|
392
|
+
} else if (c === '"') q = true;
|
|
393
|
+
else if (c === ",") {
|
|
394
|
+
out.push(cur);
|
|
395
|
+
cur = "";
|
|
396
|
+
} else cur += c;
|
|
397
|
+
}
|
|
398
|
+
out.push(cur);
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function slugify(s) {
|
|
403
|
+
return String(s || "")
|
|
404
|
+
.toLowerCase()
|
|
405
|
+
.replace(/[`'"]/g, "")
|
|
406
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
407
|
+
.replace(/^-+|-+$/g, "");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function groupBy(arr, keyFn) {
|
|
411
|
+
const m = new Map();
|
|
412
|
+
for (const x of arr) {
|
|
413
|
+
const k = keyFn(x);
|
|
414
|
+
if (!m.has(k)) m.set(k, []);
|
|
415
|
+
m.get(k).push(x);
|
|
416
|
+
}
|
|
417
|
+
return [...m.entries()];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function sum(arr, fn) {
|
|
421
|
+
return arr.reduce((a, x) => a + (Number(fn(x)) || 0), 0);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function avg(arr, fn) {
|
|
425
|
+
return arr.length ? sum(arr, fn) / arr.length : 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function dist(values) {
|
|
429
|
+
const xs = values.map((v) => Number(v) || 0).sort((a, b) => a - b);
|
|
430
|
+
const mean = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
|
|
431
|
+
const p50 = xs.length ? xs[Math.floor((xs.length - 1) / 2)] : 0;
|
|
432
|
+
return { mean: round(mean, 6), p50: round(p50, 6) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function num(v) {
|
|
436
|
+
const n = Number(v);
|
|
437
|
+
return Number.isFinite(n) ? n : 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function round(n, dp) {
|
|
441
|
+
const f = 10 ** dp;
|
|
442
|
+
return Math.round((Number(n) || 0) * f) / f;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function safeReaddir(dir) {
|
|
446
|
+
try {
|
|
447
|
+
return fs.readdirSync(dir);
|
|
448
|
+
} catch {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isDir(p) {
|
|
454
|
+
try {
|
|
455
|
+
return fs.statSync(p).isDirectory();
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|