@crewhaus/hangar-ui 0.5.0

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 (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * Evals — the M3 half of the Evals tab: the typed launcher, matrix runs, CI
3
+ * suites, trends, the sample-size planner, judge calibration, grader
4
+ * quality, redteam, coverage, the drift sentinel, voice replays, the
5
+ * optimizer, the flywheel, experiments, and the annotation → distill join.
6
+ *
7
+ * The M1/M2 surface (`views/evals.js`: run history, drill-down, baselines)
8
+ * stays where it is and is rendered above this.
9
+ *
10
+ * Rules this screen must not break, because each one turns a measurement
11
+ * into a false alarm:
12
+ * - a PARTIAL run renders deflated and is never charted as a regression;
13
+ * - a REPLAYED run is badged not-live;
14
+ * - trends split agent spend from judge spend — the judge frequently costs
15
+ * more than the thing it is judging;
16
+ * - a matrix cell's crash class decides whether Retry is even offered: a
17
+ * billing failure is NOT retryable, however much it looks like a 429;
18
+ * - spending the locked test split is a typed, visible gesture.
19
+ *
20
+ * Every node is built with el()/text() — markup strings are banned app-wide,
21
+ * so nothing a grader, a model or a provider wrote can inject into this page.
22
+ */
23
+
24
+ import { api } from "../api.js";
25
+ import { asOf, clear, collapsible, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
26
+ import { fmtCount, fmtPct, fmtRelativeTime, fmtUsd } from "../util.js";
27
+
28
+ export async function renderEvalsLab(root, ctx) {
29
+ clear(root).appendChild(skeleton(4));
30
+ const [
31
+ trends,
32
+ matrix,
33
+ suites,
34
+ judge,
35
+ graders,
36
+ redteam,
37
+ coverage,
38
+ sentinel,
39
+ voice,
40
+ optimize,
41
+ flywheel,
42
+ experiments,
43
+ annotations,
44
+ ] = await Promise.all(
45
+ [
46
+ api.evalTrends,
47
+ api.evalMatrix,
48
+ api.evalSuites,
49
+ api.judgeCalibration,
50
+ api.graderCards,
51
+ api.redteam,
52
+ api.evalCoverage,
53
+ api.sentinel,
54
+ api.voiceEvals,
55
+ api.optimizer,
56
+ api.flywheel,
57
+ api.experiments,
58
+ api.annotations,
59
+ ].map((fn) => fn({ id: ctx.id }).catch((err) => ({ ok: false, status: 0, body: { err } }))),
60
+ );
61
+ clear(root);
62
+ const reload = () => renderEvalsLab(root, ctx);
63
+ root.appendChild(launcherCard(ctx, flywheel, reload));
64
+ root.appendChild(trendsCard(trends));
65
+ root.appendChild(matrixCard(ctx, matrix));
66
+ root.appendChild(suitesCard(ctx, suites, reload));
67
+ root.appendChild(plannerCard(ctx));
68
+ root.appendChild(judgeCard(ctx, judge, reload));
69
+ root.appendChild(gradersCard(ctx, graders, reload));
70
+ root.appendChild(redteamCard(ctx, redteam, reload));
71
+ root.appendChild(coverageCard(ctx, coverage, reload));
72
+ root.appendChild(sentinelCard(ctx, sentinel, reload));
73
+ if (payload(voice).applicable === true) root.appendChild(voiceCard(voice));
74
+ root.appendChild(optimizerCard(ctx, optimize, reload));
75
+ root.appendChild(flywheelCard(ctx, flywheel, reload));
76
+ root.appendChild(experimentsCard(ctx, experiments, reload));
77
+ root.appendChild(annotationsCard(annotations));
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // shared bits
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** The `{ok, status, body}` envelope's payload, tolerant of a failed call. */
85
+ function payload(res) {
86
+ return res && typeof res === "object" && res.body && typeof res.body === "object" ? res.body : {};
87
+ }
88
+
89
+ /** A card whose body is only drawn when the server said something IS there;
90
+ * otherwise the standard empty state, which always names the verb. */
91
+ function panel(title, res, draw, extraHead) {
92
+ const data = payload(res);
93
+ const card = el("div", { class: "card" }, [
94
+ el("h3", { class: "card-title" }, [el("span", { text: title }), extraHead ?? null]),
95
+ ]);
96
+ if (res && res.ok === false && res.status !== 0 && data.present === undefined) {
97
+ card.appendChild(el("p", { class: "muted", text: `unavailable (HTTP ${String(res.status)})` }));
98
+ return card;
99
+ }
100
+ if (data.present === false) {
101
+ card.appendChild(emptyState(String(data.note ?? "Nothing yet"), data.verb ?? null));
102
+ return card;
103
+ }
104
+ if (typeof data.note === "string" && data.note !== "") {
105
+ card.appendChild(el("p", { class: "muted", text: data.note }));
106
+ }
107
+ draw(card, data);
108
+ return card;
109
+ }
110
+
111
+ function table(headers, rows) {
112
+ return el("div", { class: "table-scroll" }, [
113
+ el("table", { class: "fleet" }, [
114
+ el(
115
+ "thead",
116
+ null,
117
+ el(
118
+ "tr",
119
+ null,
120
+ headers.map((h) => el("th", { text: h })),
121
+ ),
122
+ ),
123
+ el("tbody", null, rows),
124
+ ]),
125
+ ]);
126
+ }
127
+
128
+ function stat(label, value) {
129
+ return el("div", { class: "mini" }, [
130
+ el("div", { class: "mini-num", text: value }),
131
+ el("div", { class: "mini-label", text: label }),
132
+ ]);
133
+ }
134
+
135
+ function field(label, node) {
136
+ return el("label", { class: "field" }, [el("span", { class: "kv-k", text: label }), node]);
137
+ }
138
+
139
+ function input(name, attrs) {
140
+ return el("input", { class: "input", name, ...(attrs ?? {}) });
141
+ }
142
+
143
+ function readNumber(form, name) {
144
+ const raw = form.elements[name]?.value?.trim();
145
+ if (!raw) return undefined;
146
+ const n = Number(raw);
147
+ return Number.isFinite(n) ? n : undefined;
148
+ }
149
+
150
+ function readText(form, name) {
151
+ const raw = form.elements[name]?.value?.trim();
152
+ return raw ? raw : undefined;
153
+ }
154
+
155
+ /** Post an M3 write and report BOTH outcomes — no write ever fails silently,
156
+ * and a refusal is rendered as the payload it is, not swallowed. */
157
+ async function submit(fn, params, body, okMessage, reload) {
158
+ const res = await fn(params, body).catch((err) => ({
159
+ ok: false,
160
+ status: 0,
161
+ body: { error: String(err) },
162
+ }));
163
+ if (!res.ok) {
164
+ toast(refusalText(res), "error");
165
+ return res;
166
+ }
167
+ toast(okMessage, "info");
168
+ if (reload) reload();
169
+ return res;
170
+ }
171
+
172
+ function refusalText(res) {
173
+ const body = payload(res);
174
+ const raw = typeof body.error === "string" ? body.error : "";
175
+ try {
176
+ const parsed = JSON.parse(raw);
177
+ if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
178
+ return `${parsed.error}${parsed.route ? ` — use ${parsed.route}` : ""}`;
179
+ }
180
+ } catch {
181
+ // not a structured refusal — fall through to the raw message
182
+ }
183
+ return raw || `HTTP ${String(res.status)}`;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // the launcher
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Conventional paths default from the harness dir, so the common launch is
192
+ * one click. `--allow-test-split` is deliberately NOT a checkbox beside the
193
+ * others: it spends a held-out split, the spend is counted as a burn, and the
194
+ * server refuses it unless the release confirmation rides along.
195
+ */
196
+ function launcherCard(ctx, flywheelRes, reload) {
197
+ const form = el("form", { class: "row-editor" }, [
198
+ field("dataset", input("dataset", { placeholder: "eval/dataset.jsonl (default)" })),
199
+ field("graders", input("graders", { placeholder: "eval/graders.yaml (default)" })),
200
+ field("repeats", input("repeats", { type: "number", min: "1", placeholder: "1" })),
201
+ field("seed", input("seed", { type: "number", min: "1", placeholder: "—" })),
202
+ field("budget $", input("budgetUsd", { type: "number", step: "0.01", placeholder: "—" })),
203
+ field("models (comma separated)", input("models", { placeholder: "matrix run" })),
204
+ ]);
205
+ const gate = el("input", { type: "checkbox", name: "gate" });
206
+ const holdout = el("input", { type: "checkbox", name: "allowTestSplit" });
207
+ const confirmField = el("div", { class: "gated" }, [
208
+ el("span", {
209
+ class: "gated-why",
210
+ text: 'spending the held-out split is a release gesture — type "release" to confirm',
211
+ }),
212
+ input("releaseConfirm", { placeholder: "release" }),
213
+ ]);
214
+ confirmField.hidden = true;
215
+ holdout.addEventListener("change", () => {
216
+ confirmField.hidden = !holdout.checked;
217
+ });
218
+ form.appendChild(el("label", { class: "check" }, [gate, el("span", { text: " gate this run" })]));
219
+ form.appendChild(
220
+ el("label", { class: "check" }, [
221
+ holdout,
222
+ el("span", { text: " spend the locked test split (recorded as a burn)" }),
223
+ ]),
224
+ );
225
+ form.appendChild(confirmField);
226
+
227
+ const run = el("button", { class: "btn btn-primary", type: "submit", text: "Run eval" });
228
+ form.appendChild(el("div", { class: "editor-actions" }, [run]));
229
+ form.addEventListener("submit", async (event) => {
230
+ event.preventDefault();
231
+ const models = readText(form, "models");
232
+ const body = {
233
+ ...(readText(form, "dataset") ? { dataset: readText(form, "dataset") } : {}),
234
+ ...(readText(form, "graders") ? { graders: readText(form, "graders") } : {}),
235
+ ...(readNumber(form, "repeats") ? { repeats: readNumber(form, "repeats") } : {}),
236
+ ...(readNumber(form, "seed") ? { seed: readNumber(form, "seed") } : {}),
237
+ ...(readNumber(form, "budgetUsd") ? { budgetUsd: readNumber(form, "budgetUsd") } : {}),
238
+ ...(models
239
+ ? {
240
+ models: models
241
+ .split(",")
242
+ .map((m) => m.trim())
243
+ .filter(Boolean),
244
+ }
245
+ : {}),
246
+ ...(gate.checked ? { gate: true } : {}),
247
+ ...(holdout.checked ? { allowTestSplit: true } : {}),
248
+ ...(holdout.checked && readText(form, "releaseConfirm") === "release"
249
+ ? { releaseConfirm: true }
250
+ : {}),
251
+ };
252
+ run.disabled = true;
253
+ const res = await submit(api.evalLaunch, { id: ctx.id }, body, "Eval queued", null);
254
+ run.disabled = false;
255
+ if (res.ok) {
256
+ const warnings = Array.isArray(payload(res).warnings) ? payload(res).warnings : [];
257
+ for (const w of warnings) toast(String(w), "info");
258
+ if (reload) reload();
259
+ }
260
+ });
261
+
262
+ const card = el("div", { class: "card" }, [
263
+ el("h3", { class: "card-title" }, [el("span", { text: "Launch an eval" })]),
264
+ el("p", {
265
+ class: "muted",
266
+ text: "Conventional paths default from the harness directory; progress streams on the run feed.",
267
+ }),
268
+ form,
269
+ ]);
270
+ // The dataset-precedence trap belongs here, where a launch is composed: a
271
+ // conventional eval/dataset.jsonl SILENTLY wins over registry:<spec>-ratings.
272
+ const precedence = payload(flywheelRes).datasetPrecedence;
273
+ if (precedence && precedence.shadowing === true) {
274
+ card.appendChild(
275
+ el("p", { class: "gated-why" }, [dot("warn", "dataset precedence"), text(precedence.note)]),
276
+ );
277
+ }
278
+ return card;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // trends
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function trendsCard(res) {
286
+ return panel("Trends", res, (card, data) => {
287
+ const spend = data.spend ?? {};
288
+ card.appendChild(
289
+ el("div", { class: "mini-row" }, [
290
+ stat("agent spend", fmtUsd(spend.agentUsd ?? null)),
291
+ stat("judge spend", fmtUsd(spend.judgeUsd ?? null)),
292
+ stat("judge share", typeof spend.judgeShare === "number" ? fmtPct(spend.judgeShare) : "—"),
293
+ ]),
294
+ );
295
+ card.appendChild(
296
+ el("p", {
297
+ class: "muted",
298
+ text: "Agent and judge spend are reported apart — one blended number hides which of the two is worth cutting.",
299
+ }),
300
+ );
301
+ for (const series of data.series ?? []) {
302
+ const rows = (series.points ?? []).map((p) =>
303
+ el("tr", { class: p.partial === true ? "folded" : null }, [
304
+ el("td", { class: "mono", text: String(p.runId ?? "") }),
305
+ el("td", { text: fmtRelativeTime(p.ts, Date.now()) }),
306
+ el("td", null, [dot(p.partial === true ? "unknown" : "ok", fmtPct(p.passRate))]),
307
+ el("td", { class: "num", text: fmtScore(p.meanScore) }),
308
+ el("td", { class: "num", text: fmtUsd(p.agentCostUsd) }),
309
+ el("td", { class: "num", text: fmtUsd(p.judgeCostUsd) }),
310
+ el("td", { class: "cell-caps" }, [
311
+ p.partial === true
312
+ ? el("span", {
313
+ class: "chip chip-warn",
314
+ title: "ended early — its figures read deflated and never move the trend",
315
+ text: "partial",
316
+ })
317
+ : null,
318
+ p.replayed === true
319
+ ? el("span", {
320
+ class: "chip",
321
+ title:
322
+ "every tool result came from a cassette — not a measurement of the live system",
323
+ text: "not live",
324
+ })
325
+ : null,
326
+ p.pinned === true ? el("span", { class: "chip chip-group", text: "baseline" }) : null,
327
+ ]),
328
+ ]),
329
+ );
330
+ card.appendChild(
331
+ collapsible(
332
+ [
333
+ el("span", { class: "mono", text: `${series.specName} / ${series.datasetName}` }),
334
+ el("span", {
335
+ class: "muted",
336
+ text: ` ${fmtCount(series.measuredCount)} measured${
337
+ series.partialCount ? ` · ${String(series.partialCount)} partial` : ""
338
+ }${
339
+ typeof series.deltaPp === "number"
340
+ ? ` · ${series.deltaPp >= 0 ? "+" : ""}${series.deltaPp.toFixed(1)}pp`
341
+ : " · no trend yet"
342
+ }`,
343
+ }),
344
+ ],
345
+ [table(["Run", "When", "Pass", "Mean", "Agent $", "Judge $", "Flags"], rows)],
346
+ true,
347
+ ),
348
+ );
349
+ }
350
+ });
351
+ }
352
+
353
+ function fmtScore(v) {
354
+ return typeof v === "number" && Number.isFinite(v) ? v.toFixed(2) : "—";
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // matrix
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /**
362
+ * Matrix cells live outside run history. The crash CLASS is the actionable
363
+ * part: billing and systemic failures get their remedy, never a Retry button
364
+ * — re-running a quota exhaustion just spends another minute proving it.
365
+ */
366
+ function matrixCard(ctx, res) {
367
+ return panel("Model matrix", res, (card, data) => {
368
+ const rows = (data.cells ?? []).map((cell) => {
369
+ const crash = cell.crash;
370
+ return el("tr", null, [
371
+ el("td", { class: "mono", text: String(cell.model ?? "") }),
372
+ el("td", null, [
373
+ crash
374
+ ? dot(crash.retryable ? "warn" : "bad", crash.kind)
375
+ : dot("ok", fmtPct(cell.passRate)),
376
+ ]),
377
+ el("td", { class: "num", text: fmtScore(cell.meanScore) }),
378
+ el("td", { class: "num", text: fmtCount(cell.sampleCount) }),
379
+ el("td", { class: "num", text: fmtUsd(cell.costPer1kSamplesUsd) }),
380
+ el("td", { class: "muted", text: crash ? String(crash.remedy) : "" }),
381
+ el("td", { class: "cell-actions" }, [
382
+ crash?.retryable
383
+ ? retryCellBtn(ctx, cell)
384
+ : crash
385
+ ? el("span", {
386
+ class: "chip chip-warn",
387
+ title: "re-running cannot change this outcome",
388
+ text: "not retryable",
389
+ })
390
+ : null,
391
+ ]),
392
+ ]);
393
+ });
394
+ card.appendChild(
395
+ table(["Model", "Verdict", "Mean", "Samples", "Est $/1k", "Remedy", ""], rows),
396
+ );
397
+ for (const root of data.roots ?? []) {
398
+ card.appendChild(
399
+ el("p", { class: "muted" }, [
400
+ el("span", { class: "mono", text: String(root.root) }),
401
+ asOf(root.generatedAt) ?? null,
402
+ ]),
403
+ );
404
+ }
405
+ });
406
+ }
407
+
408
+ function retryCellBtn(ctx, cell) {
409
+ const btn = el("button", { class: "btn btn-ghost", type: "button", text: "Re-run cell" });
410
+ btn.addEventListener("click", async () => {
411
+ btn.disabled = true;
412
+ await submit(
413
+ api.evalLaunch,
414
+ { id: ctx.id },
415
+ { models: [String(cell.model)] },
416
+ "Cell re-queued",
417
+ null,
418
+ );
419
+ btn.disabled = false;
420
+ });
421
+ return btn;
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // CI suites
426
+ // ---------------------------------------------------------------------------
427
+
428
+ function suitesCard(ctx, res, reload) {
429
+ const runner = el("form", { class: "row-editor" }, [
430
+ field("suite", input("suite", { placeholder: "eval/suite.yaml", required: true })),
431
+ field(
432
+ "tier",
433
+ el(
434
+ "select",
435
+ { class: "input", name: "tier" },
436
+ ["fast", "nightly", "release"].map((t) => el("option", { value: t, text: t })),
437
+ ),
438
+ ),
439
+ ]);
440
+ const go = el("button", { class: "btn", type: "submit", text: "Run suite" });
441
+ runner.appendChild(el("div", { class: "editor-actions" }, [go]));
442
+ runner.addEventListener("submit", async (event) => {
443
+ event.preventDefault();
444
+ go.disabled = true;
445
+ await submit(
446
+ api.evalSuiteRun,
447
+ { id: ctx.id },
448
+ { suite: readText(runner, "suite"), tier: runner.elements["tier"].value },
449
+ "Suite queued",
450
+ reload,
451
+ );
452
+ go.disabled = false;
453
+ });
454
+
455
+ const card = panel("CI suites", res, (node, data) => {
456
+ for (const suite of data.suites ?? []) {
457
+ const rows = (suite.entries ?? []).map((entry) =>
458
+ el("tr", null, [
459
+ el("td", { class: "mono", text: String(entry.name) }),
460
+ el("td", null, [dot(entry.passed ? "ok" : "bad", entry.passed ? "pass" : "fail")]),
461
+ el("td", { class: "num", text: fmtPct(entry.passRate) }),
462
+ el("td", { class: "num", text: fmtScore(entry.meanScore) }),
463
+ el("td", {
464
+ class: "muted",
465
+ text: [
466
+ entry.minPassRate != null ? `min pass ${fmtPct(entry.minPassRate)}` : "",
467
+ entry.minMeanScore != null ? `min score ${fmtScore(entry.minMeanScore)}` : "",
468
+ ]
469
+ .filter(Boolean)
470
+ .join(" · "),
471
+ }),
472
+ el("td", { class: "cell-caps" }, [
473
+ entry.partial === true
474
+ ? el("span", {
475
+ class: "chip chip-warn",
476
+ title: "a partial entry always fails — its aborted samples counted as errors",
477
+ text: "partial ⇒ fail",
478
+ })
479
+ : null,
480
+ ]),
481
+ ]),
482
+ );
483
+ node.appendChild(
484
+ collapsible(
485
+ [
486
+ dot(suite.passed ? "ok" : "bad", `${suite.tier} tier`),
487
+ el("span", { class: "muted", text: ` ${String(suite.dir)}` }),
488
+ ],
489
+ [table(["Entry", "Verdict", "Pass", "Mean", "Floors", ""], rows)],
490
+ suite.passed !== true,
491
+ ),
492
+ );
493
+ }
494
+ });
495
+ card.appendChild(runner);
496
+ return card;
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // the sample-size planner
501
+ // ---------------------------------------------------------------------------
502
+
503
+ /** Pure offline arithmetic — nothing runs and nothing is written, which is
504
+ * exactly why it can be asked BEFORE spending anything. */
505
+ function plannerCard(ctx) {
506
+ const out = el("div", { class: "mini-row" });
507
+ const form = el("form", { class: "row-editor" }, [
508
+ field(
509
+ "delta to detect",
510
+ input("targetDelta", { type: "number", step: "0.01", value: "0.05", required: true }),
511
+ ),
512
+ field(
513
+ "measured base rate",
514
+ input("baseRate", { type: "number", step: "0.01", placeholder: "0.5" }),
515
+ ),
516
+ ]);
517
+ const go = el("button", { class: "btn", type: "submit", text: "Plan" });
518
+ form.appendChild(el("div", { class: "editor-actions" }, [go]));
519
+ form.addEventListener("submit", async (event) => {
520
+ event.preventDefault();
521
+ const body = {
522
+ targetDelta: readNumber(form, "targetDelta"),
523
+ ...(readNumber(form, "baseRate") !== undefined
524
+ ? { baseRate: readNumber(form, "baseRate") }
525
+ : {}),
526
+ };
527
+ const res = await api
528
+ .evalPlan({ id: ctx.id }, body)
529
+ .catch((err) => ({ ok: false, status: 0, body: { error: String(err) } }));
530
+ clear(out);
531
+ if (!res.ok) {
532
+ toast(refusalText(res), "error");
533
+ return;
534
+ }
535
+ const data = payload(res);
536
+ out.appendChild(stat("samples needed", fmtCount(data.samples)));
537
+ out.appendChild(stat("confidence", fmtPct(data.confidence)));
538
+ out.appendChild(stat("formula", String(data.formula ?? "")));
539
+ if (data.caveat) out.appendChild(el("p", { class: "muted", text: String(data.caveat) }));
540
+ });
541
+ return el("div", { class: "card" }, [
542
+ el("h3", { class: "card-title" }, [el("span", { text: "Sample-size planner" })]),
543
+ el("p", {
544
+ class: "muted",
545
+ text: "How many samples it takes to detect a delta. Offline arithmetic — no run, no cost.",
546
+ }),
547
+ form,
548
+ out,
549
+ ]);
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // judge calibration + graders
554
+ // ---------------------------------------------------------------------------
555
+
556
+ function judgeCard(ctx, res, reload) {
557
+ const card = panel("Judge calibration", res, (node, data) => {
558
+ node.appendChild(
559
+ table(
560
+ ["Spec", "Cut", "Judge model", "Calibrated"],
561
+ (data.specs ?? []).map((s) =>
562
+ el("tr", null, [
563
+ el("td", { class: "mono", text: String(s.spec) }),
564
+ el("td", { class: "num", text: fmtScore(s.cut) }),
565
+ el("td", { class: "mono", text: String(s.judgeModel ?? "—") }),
566
+ el("td", { text: fmtRelativeTime(s.calibratedAt, Date.now()) }),
567
+ ]),
568
+ ),
569
+ ),
570
+ );
571
+ });
572
+ const preview = el("button", { class: "btn btn-ghost", type: "button", text: "Preview ROC cut" });
573
+ preview.addEventListener("click", async () => {
574
+ preview.disabled = true;
575
+ await submit(api.judgeCalibrate, { id: ctx.id }, {}, "Calibration preview queued", reload);
576
+ preview.disabled = false;
577
+ });
578
+ const apply = el("button", {
579
+ class: "btn btn-danger",
580
+ type: "button",
581
+ text: "Calibrate + apply",
582
+ });
583
+ apply.addEventListener("click", async () => {
584
+ if (
585
+ !window.confirm("Applying a calibration changes how every LATER run is scored. Continue?")
586
+ ) {
587
+ return;
588
+ }
589
+ apply.disabled = true;
590
+ await submit(
591
+ api.judgeCalibrate,
592
+ { id: ctx.id },
593
+ { apply: true, confirm: true },
594
+ "Calibration queued",
595
+ reload,
596
+ );
597
+ apply.disabled = false;
598
+ });
599
+ card.appendChild(el("div", { class: "editor-actions" }, [preview, apply]));
600
+ return card;
601
+ }
602
+
603
+ function gradersCard(ctx, res, reload) {
604
+ const card = panel("Grader quality", res, (node, data) => {
605
+ node.appendChild(
606
+ table(
607
+ ["Kind", "Name", "Judge model", "Runs", "Datasets"],
608
+ (data.graders ?? []).map((g) =>
609
+ el("tr", null, [
610
+ el("td", { text: String(g.kind) }),
611
+ el("td", { class: "mono", text: String(g.name ?? g.gradersHash ?? "") }),
612
+ el("td", { class: "mono", text: String(g.judgeModel ?? "—") }),
613
+ el("td", { class: "num", text: fmtCount(g.runs) }),
614
+ el("td", { class: "muted", text: (g.datasets ?? []).join(", ") }),
615
+ ]),
616
+ ),
617
+ ),
618
+ );
619
+ if (data.gradersFile) {
620
+ node.appendChild(el("p", { class: "muted mono", text: String(data.gradersFile) }));
621
+ }
622
+ });
623
+ const suggest = el("button", { class: "btn btn-ghost", type: "button", text: "Suggest graders" });
624
+ suggest.addEventListener("click", async () => {
625
+ suggest.disabled = true;
626
+ await submit(api.gradersSuggest, { id: ctx.id }, {}, "Grader draft queued (advisory)", reload);
627
+ suggest.disabled = false;
628
+ });
629
+ const meta = el("button", { class: "btn btn-ghost", type: "button", text: "Meta-eval graders" });
630
+ meta.addEventListener("click", async () => {
631
+ meta.disabled = true;
632
+ await submit(api.gradersTest, { id: ctx.id }, { golden: true }, "Meta-eval queued", reload);
633
+ meta.disabled = false;
634
+ });
635
+ card.appendChild(el("div", { class: "editor-actions" }, [suggest, meta]));
636
+ return card;
637
+ }
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // redteam, coverage, sentinel, voice
641
+ // ---------------------------------------------------------------------------
642
+
643
+ function redteamCard(ctx, res, reload) {
644
+ const card = panel("Redteam", res, (node, data) => {
645
+ node.appendChild(
646
+ el("div", { class: "mini-row" }, [
647
+ stat(
648
+ "attack success",
649
+ typeof data.latestAttackSuccessRate === "number"
650
+ ? fmtPct(data.latestAttackSuccessRate)
651
+ : "—",
652
+ ),
653
+ stat("attack dataset", data.dataset ? String(data.dataset.name) : "—"),
654
+ ]),
655
+ );
656
+ node.appendChild(
657
+ table(
658
+ ["Run", "When", "Attack success", "Samples"],
659
+ (data.runs ?? []).map((r) =>
660
+ el("tr", { class: r.partial ? "folded" : null }, [
661
+ el("td", { class: "mono", text: String(r.runId) }),
662
+ el("td", { text: fmtRelativeTime(r.ts, Date.now()) }),
663
+ el("td", null, [
664
+ dot(r.attackSuccessRate > 0 ? "bad" : "ok", fmtPct(r.attackSuccessRate)),
665
+ ]),
666
+ el("td", { class: "num", text: fmtCount(r.sampleCount) }),
667
+ ]),
668
+ ),
669
+ ),
670
+ );
671
+ });
672
+ const generate = el("button", {
673
+ class: "btn btn-ghost",
674
+ type: "button",
675
+ text: "Generate attacks",
676
+ });
677
+ generate.addEventListener("click", async () => {
678
+ generate.disabled = true;
679
+ await submit(api.redteamGenerate, { id: ctx.id }, {}, "Attack generation queued", reload);
680
+ generate.disabled = false;
681
+ });
682
+ card.appendChild(el("div", { class: "editor-actions" }, [generate]));
683
+ return card;
684
+ }
685
+
686
+ /** Coverage gaps hand off to `dataset mine` — the sessions that DEMONSTRATE
687
+ * a gap are what make "draft samples from these" a real hand-off. */
688
+ function coverageCard(ctx, res, reload) {
689
+ return panel("Coverage gaps", res, (node, data) => {
690
+ node.appendChild(
691
+ table(
692
+ ["Behavior", "Kind", "Seen", "Sessions"],
693
+ (data.gaps ?? []).map((gap) =>
694
+ el("tr", null, [
695
+ el("td", { text: String(gap.behavior) }),
696
+ el("td", { class: "muted", text: String(gap.kind ?? "") }),
697
+ el("td", { class: "num", text: fmtCount(gap.frequency) }),
698
+ el("td", { class: "mono muted", text: (gap.sessions ?? []).slice(0, 3).join(" ") }),
699
+ ]),
700
+ ),
701
+ ),
702
+ );
703
+ const mine = el("button", {
704
+ class: "btn btn-ghost",
705
+ type: "button",
706
+ text: "Draft samples from these sessions",
707
+ });
708
+ mine.addEventListener("click", async () => {
709
+ mine.disabled = true;
710
+ await submit(
711
+ api.datasetMine,
712
+ { id: ctx.id },
713
+ { name: `${String(ctx.detail?.entry?.specName ?? "mined")}-mined`, dryRun: true },
714
+ "Mining preview ready — open the Datasets tab",
715
+ reload,
716
+ );
717
+ mine.disabled = false;
718
+ });
719
+ node.appendChild(el("div", { class: "editor-actions" }, [mine]));
720
+ });
721
+ }
722
+
723
+ /**
724
+ * Sentinel attribution is CONDITIONAL: a flip is provider drift only when
725
+ * spec, dataset, graders and judge all still match the frozen baseline. When
726
+ * one moved, the panel names it instead of blaming the provider.
727
+ */
728
+ function sentinelCard(ctx, res, reload) {
729
+ const card = panel("Provider-drift sentinel", res, (node, data) => {
730
+ const attribution = data.attribution ?? {};
731
+ node.appendChild(
732
+ el("p", null, [
733
+ dot(
734
+ attribution.providerDrift ? "warn" : "unknown",
735
+ attribution.providerDrift ? "attributable to provider drift" : "not attributable",
736
+ ),
737
+ ]),
738
+ );
739
+ node.appendChild(el("p", { class: "muted", text: String(attribution.reason ?? "") }));
740
+ if ((attribution.mismatches ?? []).length > 0) {
741
+ node.appendChild(
742
+ el(
743
+ "ul",
744
+ { class: "check-list" },
745
+ attribution.mismatches.map((m) => el("li", { text: String(m) })),
746
+ ),
747
+ );
748
+ }
749
+ if (data.baseline) node.appendChild(jsonPre(data.baseline));
750
+ });
751
+ const run = el("button", { class: "btn btn-ghost", type: "button", text: "Run sentinel" });
752
+ run.addEventListener("click", async () => {
753
+ run.disabled = true;
754
+ await submit(api.sentinelRun, { id: ctx.id }, {}, "Sentinel queued", reload);
755
+ run.disabled = false;
756
+ });
757
+ card.appendChild(el("div", { class: "editor-actions" }, [run]));
758
+ return card;
759
+ }
760
+
761
+ function voiceCard(res) {
762
+ return panel("Voice replays", res, (node, data) => {
763
+ node.appendChild(
764
+ el("div", { class: "mini-row" }, [stat("replays on disk", fmtCount(data.replays))]),
765
+ );
766
+ if (data.report) node.appendChild(jsonPre(data.report));
767
+ });
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // optimizer, flywheel, experiments, annotations
772
+ // ---------------------------------------------------------------------------
773
+
774
+ function optimizerCard(ctx, res, reload) {
775
+ const card = panel("Optimizer", res, (node, data) => {
776
+ node.appendChild(el("p", { class: "muted", text: String(data.acceptance ?? "") }));
777
+ node.appendChild(
778
+ table(
779
+ ["Run", "When", "Artifacts", ""],
780
+ (data.runs ?? []).map((r) =>
781
+ el("tr", null, [
782
+ el("td", { class: "mono", text: String(r.optRunId) }),
783
+ el("td", { text: fmtRelativeTime(r.at, Date.now()) }),
784
+ el("td", { class: "cell-caps" }, [
785
+ r.hasDecisions ? el("span", { class: "chip", text: "decisions" }) : null,
786
+ r.hasPatchedYaml ? el("span", { class: "chip", text: "patched.yaml" }) : null,
787
+ ]),
788
+ el("td", { class: "cell-actions" }, [artifactsBtn(ctx, r.optRunId)]),
789
+ ]),
790
+ ),
791
+ ),
792
+ );
793
+ });
794
+ const form = el("form", { class: "row-editor" }, [
795
+ field(
796
+ "mutator",
797
+ el(
798
+ "select",
799
+ { class: "input", name: "mutator" },
800
+ ["rule-based", "claude", "meta-harness"].map((m) => el("option", { value: m, text: m })),
801
+ ),
802
+ ),
803
+ field("budget $", input("budgetUsd", { type: "number", step: "0.01", placeholder: "—" })),
804
+ ]);
805
+ const go = el("button", { class: "btn", type: "submit", text: "Optimize" });
806
+ form.appendChild(el("div", { class: "editor-actions" }, [go]));
807
+ form.addEventListener("submit", async (event) => {
808
+ event.preventDefault();
809
+ go.disabled = true;
810
+ await submit(
811
+ api.optimizerRun,
812
+ { id: ctx.id },
813
+ {
814
+ mutator: form.elements["mutator"].value,
815
+ ...(readNumber(form, "budgetUsd") ? { budgetUsd: readNumber(form, "budgetUsd") } : {}),
816
+ },
817
+ "Optimizer queued",
818
+ reload,
819
+ );
820
+ go.disabled = false;
821
+ });
822
+ card.appendChild(form);
823
+ return card;
824
+ }
825
+
826
+ function artifactsBtn(ctx, optRunId) {
827
+ const btn = el("button", { class: "btn btn-ghost", type: "button", text: "Artifacts" });
828
+ const holder = el("div");
829
+ btn.addEventListener("click", async () => {
830
+ btn.disabled = true;
831
+ const res = await api
832
+ .optimizerArtifacts({ id: ctx.id, optRunId })
833
+ .catch((err) => ({ ok: false, status: 0, body: { error: String(err) } }));
834
+ btn.disabled = false;
835
+ clear(holder);
836
+ if (!res.ok) {
837
+ toast(refusalText(res), "error");
838
+ return;
839
+ }
840
+ const data = payload(res);
841
+ holder.appendChild(
842
+ table(
843
+ ["File", "Kind", "Bytes"],
844
+ (data.files ?? []).map((f) =>
845
+ el("tr", null, [
846
+ el("td", { class: "mono", text: String(f.name) }),
847
+ el("td", { text: String(f.kind) }),
848
+ el("td", { class: "num", text: fmtCount(f.size) }),
849
+ ]),
850
+ ),
851
+ ),
852
+ );
853
+ if (data.decisions) holder.appendChild(jsonPre(data.decisions));
854
+ });
855
+ return el("div", { class: "job-action" }, [btn, holder]);
856
+ }
857
+
858
+ function flywheelCard(ctx, res, reload) {
859
+ const card = panel("Flywheel", res, (node, data) => {
860
+ const precedence = data.datasetPrecedence ?? {};
861
+ node.appendChild(
862
+ el("p", null, [
863
+ dot(
864
+ precedence.shadowing ? "warn" : "ok",
865
+ precedence.shadowing ? "dataset shadowed" : "dataset precedence clear",
866
+ ),
867
+ ]),
868
+ );
869
+ node.appendChild(el("p", { class: "muted", text: String(precedence.note ?? "") }));
870
+ if ((data.workflows ?? []).length > 0) {
871
+ node.appendChild(
872
+ el(
873
+ "ul",
874
+ { class: "check-list" },
875
+ data.workflows.map((w) => el("li", { class: "mono", text: String(w) })),
876
+ ),
877
+ );
878
+ }
879
+ });
880
+ for (const action of ["run", "init"]) {
881
+ const btn = el("button", {
882
+ class: action === "run" ? "btn" : "btn btn-ghost",
883
+ type: "button",
884
+ text: action === "run" ? "Run the loop" : "Scaffold workflows",
885
+ });
886
+ btn.addEventListener("click", async () => {
887
+ btn.disabled = true;
888
+ await submit(
889
+ api.flywheelRun,
890
+ { id: ctx.id },
891
+ { action },
892
+ action === "run" ? "Flywheel queued" : "Scaffolding queued",
893
+ reload,
894
+ );
895
+ btn.disabled = false;
896
+ });
897
+ card.appendChild(btn);
898
+ }
899
+ return card;
900
+ }
901
+
902
+ function experimentsCard(ctx, res, reload) {
903
+ const card = panel("Experiments", res, (node, data) => {
904
+ node.appendChild(el("p", { class: "muted", text: String(data.boundary ?? "") }));
905
+ for (const experiment of data.experiments ?? []) {
906
+ const rows = (experiment.variants ?? []).map((v) =>
907
+ el("tr", null, [
908
+ el("td", { class: "mono", text: String(v.version) }),
909
+ el("td", { class: "num", text: fmtCount(v.n) }),
910
+ el("td", null, [dot(v.n > 0 ? "ok" : "unknown", fmtPct(v.successRate))]),
911
+ el("td", {
912
+ class: "num",
913
+ text: Array.isArray(v.ci95) ? `${fmtPct(v.ci95[0])}–${fmtPct(v.ci95[1])}` : "—",
914
+ }),
915
+ el("td", {
916
+ class: "num",
917
+ text:
918
+ typeof v.successRateDelta === "number"
919
+ ? `${v.successRateDelta >= 0 ? "+" : ""}${fmtPct(v.successRateDelta)}`
920
+ : "—",
921
+ }),
922
+ el("td", { class: "muted", text: (v.sources ?? []).join(", ") }),
923
+ ]),
924
+ );
925
+ node.appendChild(
926
+ collapsible(
927
+ [
928
+ el("span", { class: "mono", text: String(experiment.name) }),
929
+ el("span", {
930
+ class: "muted",
931
+ text: ` ${fmtCount(experiment.totalObservations)} observations`,
932
+ }),
933
+ ],
934
+ [table(["Version", "n", "Success", "Wilson 95%", "Δ vs control", "Sources"], rows)],
935
+ true,
936
+ ),
937
+ );
938
+ }
939
+ });
940
+ const form = el("form", { class: "row-editor" }, [
941
+ field("experiment", input("name", { placeholder: "name", required: true })),
942
+ field("version", input("version", { placeholder: "1.2.3" })),
943
+ field(
944
+ "outcome",
945
+ el(
946
+ "select",
947
+ { class: "input", name: "outcome" },
948
+ ["pass", "fail"].map((o) => el("option", { value: o, text: o })),
949
+ ),
950
+ ),
951
+ ]);
952
+ const go = el("button", { class: "btn btn-ghost", type: "submit", text: "Record outcome" });
953
+ form.appendChild(el("div", { class: "editor-actions" }, [go]));
954
+ form.addEventListener("submit", async (event) => {
955
+ event.preventDefault();
956
+ go.disabled = true;
957
+ const res = await submit(
958
+ api.experimentRecord,
959
+ { id: ctx.id },
960
+ {
961
+ action: "record",
962
+ name: readText(form, "name"),
963
+ version: readText(form, "version"),
964
+ outcome: form.elements["outcome"].value,
965
+ },
966
+ "Outcome recorded",
967
+ reload,
968
+ );
969
+ if (res.ok && payload(res).submitted === false) {
970
+ toast(String(payload(res).note ?? "incomplete"), "error");
971
+ }
972
+ go.disabled = false;
973
+ });
974
+ card.appendChild(form);
975
+ return card;
976
+ }
977
+
978
+ /**
979
+ * F-7, rendered honestly. Eval-sample annotations are durable and visible,
980
+ * and mostly NOT training-data eligible: a sample's transcript lives under
981
+ * the run directory, and distill joins ratings to turns by sweeping the
982
+ * sessions root. The panel states the real counts and the upstream fix
983
+ * rather than showing a pipeline that does not run.
984
+ */
985
+ function annotationsCard(res) {
986
+ return panel("Annotations → distill", res, (node, data) => {
987
+ const join = data.join ?? {};
988
+ node.appendChild(
989
+ el("div", { class: "mini-row" }, [
990
+ stat("annotations", fmtCount(join.total)),
991
+ stat("reach distill", fmtCount(join.resolvable)),
992
+ stat("blocked", fmtCount(join.unresolvable)),
993
+ ]),
994
+ );
995
+ node.appendChild(
996
+ el("p", null, [
997
+ dot(
998
+ join.unresolvable > 0 ? "warn" : "ok",
999
+ join.unresolvable > 0 ? "join incomplete" : "join resolves",
1000
+ ),
1001
+ ]),
1002
+ );
1003
+ node.appendChild(el("p", { class: "muted", text: String(join.reason ?? "") }));
1004
+ if (join.upstreamFix) {
1005
+ node.appendChild(el("p", { class: "gated-why", text: String(join.upstreamFix) }));
1006
+ }
1007
+ node.appendChild(
1008
+ el(
1009
+ "ul",
1010
+ { class: "check-list" },
1011
+ (join.sinks ?? []).map((s) => el("li", { class: "mono", text: String(s) })),
1012
+ ),
1013
+ );
1014
+ node.appendChild(
1015
+ table(
1016
+ ["Run", "Sample", "Verdict", "Note", "State"],
1017
+ (data.annotations ?? []).map((a) =>
1018
+ el("tr", null, [
1019
+ el("td", { class: "mono", text: String(a.runId) }),
1020
+ el("td", { class: "mono", text: String(a.sampleId) }),
1021
+ el("td", null, [dot(a.verdict === "pass" ? "ok" : "bad", String(a.verdict ?? "?"))]),
1022
+ el("td", { class: "muted", text: String(a.note ?? "") }),
1023
+ el("td", { class: "muted", text: String(a.join?.state ?? "") }),
1024
+ ]),
1025
+ ),
1026
+ ),
1027
+ );
1028
+ });
1029
+ }