@prismatic-io/lux 0.0.2-preview.32 → 0.0.2-preview.35

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 (114) hide show
  1. package/lib/assertions/rubric/index.d.ts.map +1 -1
  2. package/lib/assertions/rubric/index.js +10 -1
  3. package/lib/assertions/rubric/index.js.map +1 -1
  4. package/lib/assertions/rubric/internal.d.ts.map +1 -1
  5. package/lib/assertions/rubric/internal.js +22 -13
  6. package/lib/assertions/rubric/internal.js.map +1 -1
  7. package/lib/cli/output-schemas.d.ts +6 -2
  8. package/lib/cli/output-schemas.d.ts.map +1 -1
  9. package/lib/cli/output-schemas.js +5 -2
  10. package/lib/cli/output-schemas.js.map +1 -1
  11. package/lib/cli/render/format.d.ts.map +1 -1
  12. package/lib/cli/render/format.js +1 -1
  13. package/lib/cli/render/format.js.map +1 -1
  14. package/lib/cli/render/reporter.js +2 -2
  15. package/lib/cli/render/reporter.js.map +1 -1
  16. package/lib/core/artifact-evidence.d.ts +6 -1
  17. package/lib/core/artifact-evidence.d.ts.map +1 -1
  18. package/lib/core/artifact-evidence.js +27 -6
  19. package/lib/core/artifact-evidence.js.map +1 -1
  20. package/lib/core/experiment.d.ts +1 -0
  21. package/lib/core/experiment.d.ts.map +1 -1
  22. package/lib/core/experiment.js +5 -1
  23. package/lib/core/experiment.js.map +1 -1
  24. package/lib/drivers/claude-code/index.d.ts +7 -0
  25. package/lib/drivers/claude-code/index.d.ts.map +1 -1
  26. package/lib/drivers/claude-code/index.js +12 -1
  27. package/lib/drivers/claude-code/index.js.map +1 -1
  28. package/lib/drivers/codex/config.d.ts +3 -0
  29. package/lib/drivers/codex/config.d.ts.map +1 -1
  30. package/lib/drivers/codex/config.js +9 -0
  31. package/lib/drivers/codex/config.js.map +1 -1
  32. package/lib/drivers/codex/index.d.ts +4 -0
  33. package/lib/drivers/codex/index.d.ts.map +1 -1
  34. package/lib/drivers/codex/index.js +2 -0
  35. package/lib/drivers/codex/index.js.map +1 -1
  36. package/lib/drivers/shared/experiment-behavior.d.ts +1 -1
  37. package/lib/drivers/shared/experiment-behavior.d.ts.map +1 -1
  38. package/lib/drivers/shared/experiment-behavior.js +3 -0
  39. package/lib/drivers/shared/experiment-behavior.js.map +1 -1
  40. package/lib/drivers/shared/experiment-policy.d.ts.map +1 -1
  41. package/lib/drivers/shared/experiment-policy.js +1 -0
  42. package/lib/drivers/shared/experiment-policy.js.map +1 -1
  43. package/lib/drivers/shared/skills.d.ts +3 -0
  44. package/lib/drivers/shared/skills.d.ts.map +1 -0
  45. package/lib/drivers/shared/skills.js +73 -0
  46. package/lib/drivers/shared/skills.js.map +1 -0
  47. package/lib/orchestrator/compare.d.ts +4 -3
  48. package/lib/orchestrator/compare.d.ts.map +1 -1
  49. package/lib/orchestrator/compare.js +13 -6
  50. package/lib/orchestrator/compare.js.map +1 -1
  51. package/lib/orchestrator/experiment-contracts.d.ts +1 -0
  52. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  53. package/lib/orchestrator/experiment-effects.d.ts.map +1 -1
  54. package/lib/orchestrator/experiment-effects.js +4 -2
  55. package/lib/orchestrator/experiment-effects.js.map +1 -1
  56. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  57. package/lib/orchestrator/experiment-evaluation.js +15 -2
  58. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  59. package/lib/orchestrator/experiment-report.d.ts +1 -0
  60. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  61. package/lib/orchestrator/experiment-runs.d.ts +2 -0
  62. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  63. package/lib/orchestrator/experiment-selection.d.ts.map +1 -1
  64. package/lib/orchestrator/experiment-selection.js +99 -43
  65. package/lib/orchestrator/experiment-selection.js.map +1 -1
  66. package/lib/orchestrator/grading.d.ts +1 -0
  67. package/lib/orchestrator/grading.d.ts.map +1 -1
  68. package/lib/orchestrator/grading.js +7 -0
  69. package/lib/orchestrator/grading.js.map +1 -1
  70. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  71. package/lib/orchestrator/list-runs.js +3 -5
  72. package/lib/orchestrator/list-runs.js.map +1 -1
  73. package/lib/orchestrator/paired-effects.d.ts.map +1 -1
  74. package/lib/orchestrator/paired-effects.js +7 -1
  75. package/lib/orchestrator/paired-effects.js.map +1 -1
  76. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  77. package/lib/orchestrator/suite-compare.js +18 -6
  78. package/lib/orchestrator/suite-compare.js.map +1 -1
  79. package/lib/orchestrator/suite-summary.d.ts +2 -0
  80. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  81. package/lib/orchestrator/suite-summary.js +6 -2
  82. package/lib/orchestrator/suite-summary.js.map +1 -1
  83. package/lib/previewer/server.d.ts.map +1 -1
  84. package/lib/previewer/server.js +2 -1
  85. package/lib/previewer/server.js.map +1 -1
  86. package/package.json +2 -1
  87. package/skills/lux-answerer/SKILL.md +1 -1
  88. package/src/assertions/rubric/index.ts +10 -1
  89. package/src/assertions/rubric/internal.ts +27 -13
  90. package/src/cli/output-schemas.ts +5 -2
  91. package/src/cli/render/format.ts +1 -1
  92. package/src/cli/render/reporter.ts +2 -2
  93. package/src/core/artifact-evidence.ts +40 -5
  94. package/src/core/experiment.ts +7 -1
  95. package/src/drivers/claude-code/index.ts +12 -1
  96. package/src/drivers/codex/config.ts +9 -0
  97. package/src/drivers/codex/index.ts +2 -0
  98. package/src/drivers/shared/experiment-behavior.ts +5 -2
  99. package/src/drivers/shared/experiment-policy.ts +1 -0
  100. package/src/drivers/shared/skills.ts +74 -0
  101. package/src/orchestrator/compare.ts +23 -9
  102. package/src/orchestrator/experiment-effects.ts +4 -2
  103. package/src/orchestrator/experiment-evaluation.ts +18 -2
  104. package/src/orchestrator/experiment-selection.ts +101 -18
  105. package/src/orchestrator/grading.ts +11 -0
  106. package/src/orchestrator/list-runs.ts +5 -6
  107. package/src/orchestrator/paired-effects.ts +6 -1
  108. package/src/orchestrator/suite-compare.ts +19 -5
  109. package/src/orchestrator/suite-summary.ts +7 -2
  110. package/src/previewer/server.ts +2 -0
  111. package/viewer/app.css +32 -0
  112. package/viewer/charts.js +16 -4
  113. package/viewer/detail.js +26 -8
  114. package/viewer/format.js +2 -2
@@ -20,6 +20,7 @@ import {
20
20
  import { hasAgentUsage } from "../core/usage.js";
21
21
  import { stableDriverIdentity } from "./comparison-identity.js";
22
22
  import { safeReadJson, walkFiles } from "./fs-read.js";
23
+ import { gradingScoreBasis } from "./grading.js";
23
24
 
24
25
  export type ComparisonInput = {
25
26
  pathA: string;
@@ -39,9 +40,9 @@ export type GradingDelta = {
39
40
  id: string;
40
41
  type: string;
41
42
  name: string;
42
- before: { passed: boolean | null; score: number } | null;
43
- after: { passed: boolean | null; score: number } | null;
44
- status: "added" | "removed" | "improved" | "regressed" | "unchanged";
43
+ before: { passed: boolean | null; score: number | null } | null;
44
+ after: { passed: boolean | null; score: number | null } | null;
45
+ status: "added" | "removed" | "improved" | "regressed" | "unchanged" | "unavailable";
45
46
  };
46
47
 
47
48
  export type RunComparison = {
@@ -67,6 +68,7 @@ export type RunComparison = {
67
68
  casePassedB: boolean | null;
68
69
  caseScoreA: number | null;
69
70
  caseScoreB: number | null;
71
+ caseScoreComparable: boolean;
70
72
  deltas: GradingDelta[];
71
73
  improvements: number;
72
74
  regressions: number;
@@ -156,6 +158,9 @@ const assertionsByIdentity = (
156
158
  return out;
157
159
  };
158
160
 
161
+ const measuredScore = (entry: GradingReport["assertions"][number]): number | null =>
162
+ entry.errorMessage !== undefined || entry.abstention !== undefined ? null : entry.score;
163
+
159
164
  const computeGradingDeltas = (
160
165
  a: GradingReport | null,
161
166
  b: GradingReport | null,
@@ -174,7 +179,8 @@ const computeGradingDeltas = (
174
179
  if (!ax && bx) status = "added";
175
180
  else if (ax && !bx) status = "removed";
176
181
  else if (ax && bx) {
177
- if (ax.passed === bx.passed && ax.score === bx.score) status = "unchanged";
182
+ if (measuredScore(ax) === null || measuredScore(bx) === null) status = "unavailable";
183
+ else if (ax.passed === bx.passed && ax.score === bx.score) status = "unchanged";
178
184
  // A pass/fail flip dominates any score movement.
179
185
  else if (ax.passed !== bx.passed) status = bx.passed ? "improved" : "regressed";
180
186
  else status = ax.score < bx.score ? "improved" : "regressed";
@@ -189,8 +195,8 @@ const computeGradingDeltas = (
189
195
  id: name,
190
196
  type: ax?.type ?? bx?.type ?? "unknown",
191
197
  name: ax?.id || bx?.id ? (ax?.name ?? bx?.name ?? name) : name,
192
- before: ax ? { passed: ax.passed, score: ax.score } : null,
193
- after: bx ? { passed: bx.passed, score: bx.score } : null,
198
+ before: ax ? { passed: ax.passed, score: measuredScore(ax) } : null,
199
+ after: bx ? { passed: bx.passed, score: measuredScore(bx) } : null,
194
200
  status,
195
201
  });
196
202
  }
@@ -314,6 +320,9 @@ export const compareRuns = async (input: ComparisonInput): Promise<RunComparison
314
320
  casePassedB: b.grading?.casePassed ?? null,
315
321
  caseScoreA: a.grading?.caseScore ?? null,
316
322
  caseScoreB: b.grading?.caseScore ?? null,
323
+ caseScoreComparable:
324
+ gradingScoreBasis(a.grading) !== null &&
325
+ gradingScoreBasis(a.grading) === gradingScoreBasis(b.grading),
317
326
  ...grading,
318
327
  },
319
328
  artifactsDelta: computeFileDelta(a.fileTree, b.fileTree),
@@ -327,6 +336,7 @@ export const compareRuns = async (input: ComparisonInput): Promise<RunComparison
327
336
  export const hasRegression = (cmp: RunComparison): boolean =>
328
337
  cmp.caseMismatch.length > 0 ||
329
338
  cmp.grading.regressions > 0 ||
339
+ cmp.grading.deltas.some((delta) => delta.after?.score === null && delta.after.passed !== null) ||
330
340
  cmp.grading.deltas.some((delta) => delta.status === "removed" && delta.before?.passed !== null) ||
331
341
  (cmp.grading.casePassedA === true && cmp.grading.casePassedB !== true);
332
342
 
@@ -376,13 +386,17 @@ export const formatComparisonText = (cmp: RunComparison): string => {
376
386
  lines.push("Grading:");
377
387
  lines.push(
378
388
  ` passed: ${String(cmp.grading.casePassedA)} → ${String(cmp.grading.casePassedB)}; ` +
379
- `score: ${cmp.grading.caseScoreA ?? "?"} → ${cmp.grading.caseScoreB ?? "?"}`,
389
+ `score: ${cmp.grading.caseScoreA ?? "?"} → ${cmp.grading.caseScoreB ?? "?"}${cmp.grading.caseScoreComparable ? "" : " (change unavailable: scoring basis differs or is unknown)"}`,
380
390
  );
381
391
  lines.push(` ${cmp.grading.improvements} improved, ${cmp.grading.regressions} regressed`);
382
392
  for (const d of cmp.grading.deltas) {
383
393
  if (d.status === "unchanged") continue;
384
- const arrow = { added: "+", removed: "-", improved: "↑", regressed: "↓" }[d.status];
385
- lines.push(` ${arrow} ${d.name}`);
394
+ const arrow = { added: "+", removed: "-", improved: "↑", regressed: "↓", unavailable: "?" }[
395
+ d.status
396
+ ];
397
+ lines.push(
398
+ ` ${arrow} ${d.name}: ${d.before?.score ?? "unavailable"} → ${d.after?.score ?? "unavailable"}`,
399
+ );
386
400
  }
387
401
  lines.push("");
388
402
  lines.push("Artifacts:");
@@ -23,7 +23,7 @@ export const experimentEffects = (
23
23
  const metrics = new Set([
24
24
  "quality.passRate",
25
25
  experiment.evaluationKind === "grader" ? "harness.total" : "agent.total",
26
- ...(experiment.evaluationKind === "agent" ? ["interrupts.ask", "toolCalls"] : []),
26
+ ...(experiment.evaluationKind === "agent" ? ["score", "interrupts.ask", "toolCalls"] : []),
27
27
  ...experiment.campaignSnapshot.objectives.constraints
28
28
  .filter((constraint) => constraint.relativeTo)
29
29
  .map((constraint) => constraint.metric),
@@ -46,7 +46,9 @@ export const experimentEffects = (
46
46
  const confidence =
47
47
  constraint?.confidence ??
48
48
  ConfidenceConfigSchema.parse({
49
- method: metric === "quality.passRate" ? "hoeffding" : "percentile-bootstrap",
49
+ method: ["quality.passRate", "score"].includes(metric)
50
+ ? "hoeffding"
51
+ : "percentile-bootstrap",
50
52
  });
51
53
  return {
52
54
  candidateId,
@@ -44,6 +44,7 @@ const gradingFailureMessage = (results: readonly SuiteCaseResult[]): string | un
44
44
  }
45
45
  const report = result.result?.grading;
46
46
  const failedAssertion = report.assertions.find((assertion) => {
47
+ if (assertion.passed === null) return false;
47
48
  if (assertion.errorMessage) return true;
48
49
  const detail =
49
50
  assertion.detail && typeof assertion.detail === "object"
@@ -299,10 +300,12 @@ const agentEvaluationFromSuite = (input: {
299
300
  return {
300
301
  [sample.canonicalCaseId]: {
301
302
  identityHash,
303
+ scoreBasis: sample.scoreBasis ?? null,
302
304
  ...(input.clusterId ? { clusterId: input.clusterId } : {}),
303
305
  metrics: Object.fromEntries(
304
306
  Object.entries({
305
307
  ...sample.metrics,
308
+ ...(sample.score !== undefined ? { score: sample.score } : {}),
306
309
  "quality.passRate": sample.passed === true ? 1 : 0,
307
310
  }).map(([name, value]) => [name, { mean: value, n: 1 }]),
308
311
  ),
@@ -322,7 +325,10 @@ const agentEvaluationFromSuite = (input: {
322
325
  summary.cases.map((entry) => [entry.canonicalCaseId, entry.passRate]),
323
326
  ),
324
327
  metrics: evaluationMetrics(summary, candidate),
325
- metricDefinitions: evaluationMetricDefinitions(summary),
328
+ metricDefinitions: {
329
+ score: { unit: "score", better: "higher", label: "Quality score" },
330
+ ...evaluationMetricDefinitions(summary),
331
+ },
326
332
  samples: evaluationSamples(summary),
327
333
  ...(caseSamples ? { caseSamples } : {}),
328
334
  metricCalls: summary.samples.length,
@@ -406,6 +412,12 @@ const aggregateAgentUnits = (
406
412
  const caseSamples = clusterCaseSamples(units.map((unit) => unit.evaluation.caseSamples));
407
413
  const grouped = groupedCaseMetricSamples(caseSamples, repetitions);
408
414
  const samples = grouped ?? ungroupedSamples;
415
+ const completeScore =
416
+ caseSamples &&
417
+ Object.values(caseSamples).every(
418
+ (sample) => sample.metrics.score?.n === repetitions && Boolean(sample.scoreBasis),
419
+ );
420
+ if (!completeScore) delete samples.score;
409
421
  return {
410
422
  candidateId: candidate.id,
411
423
  profileId,
@@ -417,7 +429,11 @@ const aggregateAgentUnits = (
417
429
  ),
418
430
  metrics: Object.fromEntries(
419
431
  [...metricValues]
420
- .filter(([name]) => !grouped || name === "subject.bytes" || Object.hasOwn(grouped, name))
432
+ .filter(([name]) =>
433
+ name === "score"
434
+ ? completeScore
435
+ : !grouped || name === "subject.bytes" || Object.hasOwn(grouped, name),
436
+ )
421
437
  .map(([name, values]) => [
422
438
  name,
423
439
  name === "subject.bytes" ? candidate.sourceBytes : average(grouped?.[name] ?? values),
@@ -252,13 +252,65 @@ const normalizedConstraintViolation = (
252
252
  return worst;
253
253
  };
254
254
 
255
+ const hasScoreEvidence = (
256
+ experiment: OpenedExperimentRun["manifest"],
257
+ split: ExperimentSplit,
258
+ ): boolean =>
259
+ [...new Set(experiment.evaluations.map((entry) => entry.candidateId))]
260
+ .flatMap((id) => candidateEvaluations(experiment, id))
261
+ .some(
262
+ (entry) =>
263
+ entry.split === split &&
264
+ (entry.metricDefinitions?.score !== undefined ||
265
+ entry.metrics.score !== undefined ||
266
+ Object.values(entry.caseSamples ?? {}).some(
267
+ (sample) => sample.metrics.score !== undefined,
268
+ )),
269
+ );
270
+
271
+ const completeScoreEvidence = (entry: ExperimentEvaluation): boolean =>
272
+ entry.metrics.score !== undefined &&
273
+ (!entry.caseSamples ||
274
+ Object.keys(entry.casePassRates).every((id) => {
275
+ const sample = entry.caseSamples?.[id];
276
+ return sample?.metrics.score?.n === entry.repetitions && Boolean(sample.scoreBasis);
277
+ }));
278
+
279
+ const comparableScoreBasis = (
280
+ entry: ExperimentEvaluation,
281
+ reference: ExperimentEvaluation | undefined,
282
+ ): boolean => {
283
+ if (!reference || !completeScoreEvidence(reference)) return false;
284
+ if (!entry.caseSamples || !reference.caseSamples)
285
+ return !entry.caseSamples && !reference.caseSamples;
286
+ const ids = new Set([
287
+ ...Object.keys(entry.casePassRates),
288
+ ...Object.keys(reference.casePassRates),
289
+ ]);
290
+ return [...ids].every((id) => {
291
+ const basis = entry.caseSamples?.[id]?.scoreBasis;
292
+ return Boolean(basis) && basis === reference.caseSamples?.[id]?.scoreBasis;
293
+ });
294
+ };
295
+
255
296
  export const rankCandidates = <T extends ExperimentDecisionCandidate>(
256
297
  campaign: ExperimentCampaign,
257
298
  candidates: readonly T[],
258
299
  experiment: OpenedExperimentRun["manifest"],
259
300
  constraintSplits: readonly ExperimentSplit[] = ["train", "validation", "test"],
260
- ): T[] =>
261
- [...candidates].sort((a, b) => {
301
+ ): T[] => {
302
+ const validationEvaluations = (id: string): ExperimentEvaluation[] =>
303
+ candidateEvaluations(experiment, id).filter((entry) => entry.split === "validation");
304
+ const referenceId = ["baseline", ...candidates.map((candidate) => candidate.id).sort()].find(
305
+ (id) => {
306
+ const entries = validationEvaluations(id);
307
+ return campaign.profiles.every((profile) =>
308
+ entries.some((entry) => entry.profileId === profile && completeScoreEvidence(entry)),
309
+ );
310
+ },
311
+ );
312
+ const reference = referenceId ? validationEvaluations(referenceId) : [];
313
+ return [...candidates].sort((a, b) => {
262
314
  const activeConstraints = campaign.objectives.constraints.filter((constraint) =>
263
315
  constraintSplits.includes(constraint.split),
264
316
  );
@@ -310,20 +362,40 @@ export const rankCandidates = <T extends ExperimentDecisionCandidate>(
310
362
  }
311
363
  }
312
364
  const quality = (candidate: ExperimentDecisionCandidate): number => {
313
- const values = metricValues(
314
- candidate,
315
- "quality.passRate",
316
- undefined,
317
- "validation",
318
- experiment,
319
- );
320
- return values.length > 0 ? Math.min(...values) : -1;
365
+ if (!hasScoreEvidence(experiment, "validation")) {
366
+ const values = metricValues(
367
+ candidate,
368
+ "quality.passRate",
369
+ undefined,
370
+ "validation",
371
+ experiment,
372
+ );
373
+ return values.length > 0 ? Math.min(...values) : -1;
374
+ }
375
+ const evaluations = candidateEvaluations(experiment, candidate.id);
376
+ const scores = campaign.profiles.map((profile) => {
377
+ const entry = evaluations.find(
378
+ (value) => value.profileId === profile && value.split === "validation",
379
+ );
380
+ if (
381
+ !entry ||
382
+ !completeScoreEvidence(entry) ||
383
+ !comparableScoreBasis(
384
+ entry,
385
+ reference.find((value) => value.profileId === profile),
386
+ )
387
+ )
388
+ return -1;
389
+ return entry.metrics.score ?? -1;
390
+ });
391
+ return scores.length > 0 ? Math.min(...scores) : -1;
321
392
  };
322
393
  const qualityDelta = quality(b) - quality(a);
323
394
  if (qualityDelta !== 0) return qualityDelta;
324
395
  if (a.sourceBytes !== b.sourceBytes) return a.sourceBytes - b.sourceBytes;
325
396
  return a.id.localeCompare(b.id);
326
397
  });
398
+ };
327
399
 
328
400
  const candidateCaseVector = (
329
401
  candidate: Candidate,
@@ -338,10 +410,15 @@ const candidateCaseVector = (
338
410
  evaluations
339
411
  .filter((entry) => entry.split === split)
340
412
  .flatMap((entry) =>
341
- Object.entries(entry.casePassRates).map(([caseId, passRate]) => [
342
- `${entry.profileId}:${caseId}`,
343
- passRate,
344
- ]),
413
+ Object.entries(entry.casePassRates).flatMap(([caseId, passRate]) => {
414
+ if (!hasScoreEvidence(experiment, split))
415
+ return [[`${entry.profileId}:${caseId}`, passRate]];
416
+ const sample = entry.caseSamples?.[caseId];
417
+ const score = sample?.metrics.score;
418
+ return score?.n === entry.repetitions && sample?.scoreBasis
419
+ ? [[JSON.stringify([entry.profileId, caseId, sample.scoreBasis]), score.mean]]
420
+ : [];
421
+ }),
345
422
  ),
346
423
  );
347
424
  };
@@ -351,8 +428,9 @@ const dominates = (a: Record<string, number>, b: Record<string, number>): boolea
351
428
  if (keys.size === 0) return false;
352
429
  let strictlyBetter = false;
353
430
  for (const key of keys) {
354
- const av = a[key] ?? 0;
355
- const bv = b[key] ?? 0;
431
+ const av = a[key];
432
+ const bv = b[key];
433
+ if (av === undefined || bv === undefined) return false;
356
434
  if (av < bv) return false;
357
435
  if (av > bv) strictlyBetter = true;
358
436
  }
@@ -404,8 +482,13 @@ const frontierCoverage = (
404
482
  const candidateVector = candidateCaseVector(candidate, experiment, "train");
405
483
  const keys = new Set(vectors.flatMap((vector) => Object.keys(vector)));
406
484
  return [...keys].filter((key) => {
407
- const best = Math.max(...vectors.map((vector) => vector[key] ?? 0));
408
- return (candidateVector[key] ?? 0) === best;
485
+ const values = vectors.flatMap((vector) => (vector[key] === undefined ? [] : [vector[key]]));
486
+ const best = Math.max(...values);
487
+ return (
488
+ (frontier.length === 1 || values.length > 1) &&
489
+ candidateVector[key] !== undefined &&
490
+ candidateVector[key] === best
491
+ );
409
492
  }).length;
410
493
  };
411
494
 
@@ -131,6 +131,17 @@ const assertionPassed = (result: AssertionResult, threshold: number): boolean =>
131
131
  result.passed &&
132
132
  result.score >= threshold;
133
133
 
134
+ export const gradingScoreBasis = (report: GradingReport | null): string | null =>
135
+ report?.caseScore === null || !report
136
+ ? null
137
+ : valueHash(
138
+ report.assertions
139
+ .map((entry, index) => ({ entry, id: entry.id ?? `${entry.type}:${index + 1}` }))
140
+ .filter(({ entry }) => entry.errorMessage === undefined && entry.abstention === undefined)
141
+ .map(({ entry, id }) => ({ id, weight: entry.weight }))
142
+ .sort((a, b) => a.id.localeCompare(b.id)),
143
+ );
144
+
134
145
  const aggregateGrading = (
135
146
  entries: GradingAssertionEntry[],
136
147
  skippedAssertions: NonNullable<GradingReport["skippedAssertions"]>,
@@ -162,9 +162,7 @@ export const formatRunsTable = (runs: ListedRun[]): string => {
162
162
  else if (r.casePassed === true) status = "PASS";
163
163
  else if (r.casePassed === false) status = "FAIL";
164
164
  const score =
165
- r.caseScore !== null
166
- ? `${(r.caseScore * 100).toFixed(0)}% (${r.assertionPassed}/${r.assertionTotal})`
167
- : "—";
165
+ r.caseScore !== null ? `${r.caseScore} (${r.assertionPassed}/${r.assertionTotal})` : "—";
168
166
  const duration = r.durationMs !== null ? `${(r.durationMs / 1000).toFixed(1)}s` : "—";
169
167
  const agents = r.subagentCount > 0 ? `1+${r.subagentCount}` : "1";
170
168
  return [status, r.caseSlug, r.driverName, agents, r.ts, r.exitReason ?? "?", score, duration];
@@ -362,8 +360,7 @@ const appendGradingDetail = (lines: string[], detail: RunDetail): void => {
362
360
  let status = "ungraded";
363
361
  if (detail.grading.casePassed === true) status = "PASS";
364
362
  else if (detail.grading.casePassed === false) status = "FAIL";
365
- const score =
366
- detail.grading.caseScore !== null ? `${(detail.grading.caseScore * 100).toFixed(0)}%` : "—";
363
+ const score = detail.grading.caseScore !== null ? String(detail.grading.caseScore) : "—";
367
364
  lines.push(
368
365
  `grading: ${status} (${score}, ${detail.grading.assertions.filter((a) => a.passed).length}/${detail.grading.assertions.filter((a) => a.passed !== null).length})`,
369
366
  );
@@ -373,7 +370,9 @@ const appendGradingDetail = (lines: string[], detail: RunDetail): void => {
373
370
  if (a.abstention) mark = "?";
374
371
  const took = ` (${(a.durationMs / 1000).toFixed(1)}s)`;
375
372
  const abstention = a.abstention ? " — insufficient evidence" : "";
376
- lines.push(` ${mark} ${a.name}${took}${abstention}${a.comment ? ` — ${a.comment}` : ""}`);
373
+ lines.push(
374
+ ` ${mark} ${a.name} [score ${a.errorMessage !== undefined || a.abstention ? "unavailable" : a.score}]${took}${abstention}${a.comment ? ` — ${a.comment}` : ""}`,
375
+ );
377
376
  }
378
377
  }
379
378
  };
@@ -24,6 +24,7 @@ export const clusterCaseSamples = (
24
24
  }
25
25
  if (current.identityHash !== sample.identityHash) current.identityHash = null;
26
26
  if (current.clusterId !== sample.clusterId) current.identityHash = null;
27
+ if (current.scoreBasis !== sample.scoreBasis) current.scoreBasis = null;
27
28
  const metrics = new Map(Object.entries(current.metrics));
28
29
  for (const [metric, value] of Object.entries(sample.metrics)) {
29
30
  const before = metrics.get(metric);
@@ -65,7 +66,9 @@ export const groupedCaseMetricSamples = (
65
66
  if (
66
67
  Object.values(samples).some(
67
68
  (sample) =>
68
- !Object.hasOwn(sample.metrics, metric) || sample.metrics[metric]?.n !== repetitions,
69
+ !Object.hasOwn(sample.metrics, metric) ||
70
+ sample.metrics[metric]?.n !== repetitions ||
71
+ (metric === "score" && !sample.scoreBasis),
69
72
  )
70
73
  )
71
74
  return [];
@@ -132,6 +135,8 @@ const pairedDeltas = (
132
135
  const b = after.caseSamples?.[id];
133
136
  if (!a?.identityHash || a.identityHash !== b?.identityHash)
134
137
  throw new Error(`paired case '${id}' has incompatible evaluation identity`);
138
+ if (metric === "score" && (!a.scoreBasis || a.scoreBasis !== b.scoreBasis))
139
+ throw new Error(`paired score for case '${id}' has different or unavailable scoring basis`);
135
140
  if (a.clusterId !== b.clusterId)
136
141
  throw new Error(`paired case '${id}' has incompatible cluster membership`);
137
142
  const av = Object.hasOwn(a.metrics, metric) ? a.metrics[metric] : undefined;
@@ -74,10 +74,19 @@ const pairedMetricDeltas = (
74
74
  const a = before[index];
75
75
  const b = after[index];
76
76
  if (!a || !b) continue;
77
- const names = new Set([...Object.keys(a.metrics), ...Object.keys(b.metrics)]);
77
+ const am: Record<string, number> = {
78
+ ...a.metrics,
79
+ ...(a.score !== undefined ? { score: a.score } : {}),
80
+ };
81
+ const bm: Record<string, number> = {
82
+ ...b.metrics,
83
+ ...(b.score !== undefined ? { score: b.score } : {}),
84
+ };
85
+ const names = new Set([...Object.keys(am), ...Object.keys(bm)]);
78
86
  for (const name of names) {
79
- const av = a.metrics[name];
80
- const bv = b.metrics[name];
87
+ if (name === "score" && (!a.scoreBasis || a.scoreBasis !== b.scoreBasis)) continue;
88
+ const av = am[name];
89
+ const bv = bm[name];
81
90
  if (av === undefined || bv === undefined) continue;
82
91
  const values = deltas.get(name) ?? [];
83
92
  values.push(bv - av);
@@ -196,9 +205,14 @@ export const formatSuiteComparisonText = (comparison: SuiteComparison): string =
196
205
  }
197
206
  }
198
207
  for (const entry of comparison.cases) {
199
- if (!entry.regressed) continue;
208
+ if (entry.regressed) {
209
+ lines.push(
210
+ ` ↓ ${suiteGroupKey(entry)}: ${entry.before ? percent(entry.before.passRate) : "—"} → ${entry.after ? percent(entry.after.passRate) : "missing"}`,
211
+ );
212
+ }
213
+ const score = entry.pairedMetricDeltas.score;
200
214
  lines.push(
201
- ` ↓ ${suiteGroupKey(entry)}: ${entry.before ? percent(entry.before.passRate) : "—"} → ${entry.after ? percent(entry.after.passRate) : "missing"}`,
215
+ ` ${suiteGroupKey(entry)} score change: ${score ? `${score.mean >= 0 ? "+" : ""}${score.mean} (${score.n} paired)` : "unavailable"}`,
202
216
  );
203
217
  }
204
218
  return lines.join("\n");
@@ -11,6 +11,7 @@ import {
11
11
  import { hasAgentUsage } from "../core/usage.js";
12
12
  import { runComparisonIdentity } from "./comparison-identity.js";
13
13
  import { safeReadJson } from "./fs-read.js";
14
+ import { gradingScoreBasis } from "./grading.js";
14
15
  import type { SuiteCaseResult } from "./orchestrator.js";
15
16
  import type { SuiteRun } from "./suite-runs.js";
16
17
 
@@ -65,6 +66,7 @@ export const SuiteSampleSchema = z.object({
65
66
  passed: z.boolean().nullable(),
66
67
  identityHash: z.string().nullable(),
67
68
  score: z.exactOptional(z.number()),
69
+ scoreBasis: z.exactOptional(z.string().nullable()),
68
70
  metrics: z.record(z.string(), z.number()),
69
71
  });
70
72
  export type SuiteSample = z.infer<typeof SuiteSampleSchema>;
@@ -354,7 +356,10 @@ export const buildSuiteSummary = (
354
356
  samples: entries.map((entry, index) => {
355
357
  const samples: MetricSamples = new Map();
356
358
  const result = entry.result?.result;
357
- if (result) collectMetadata(samples, result.metadata);
359
+ if (result) {
360
+ add(samples, "score", result.grading.caseScore ?? undefined);
361
+ collectMetadata(samples, result.metadata);
362
+ }
358
363
  return {
359
364
  index,
360
365
  caseId: entry.item.caseId,
@@ -364,7 +369,7 @@ export const buildSuiteSummary = (
364
369
  passed: entry.result?.casePassed ?? null,
365
370
  identityHash: result ? runComparisonIdentity(result.metadata) : null,
366
371
  ...(result?.grading.caseScore !== null && result?.grading.caseScore !== undefined
367
- ? { score: result.grading.caseScore }
372
+ ? { score: result.grading.caseScore, scoreBasis: gradingScoreBasis(result.grading) }
368
373
  : {}),
369
374
  metrics: Object.fromEntries(
370
375
  [...samples.entries()].map(([name, sample]) => [name, sample.values[0] ?? 0]),
@@ -7,6 +7,7 @@ import { type DriverMetricDescriptor, type DriverMetrics, exec } from "../core/i
7
7
  import {
8
8
  compareRuns,
9
9
  compareSuiteRuns,
10
+ hasRegression,
10
11
  type ListedRun,
11
12
  listExperimentRuns,
12
13
  listRuns,
@@ -424,6 +425,7 @@ const handleRunComparison = async ({
424
425
  ]);
425
426
  sendJson(request, response, 200, {
426
427
  kind: "run",
428
+ regressed: hasRegression(comparison),
427
429
  comparison: {
428
430
  ...comparison,
429
431
  pathA: displayPath(runsRoot, before.runDir),
package/viewer/app.css CHANGED
@@ -1256,6 +1256,37 @@ h1 {
1256
1256
  color: var(--muted);
1257
1257
  }
1258
1258
 
1259
+ .score-bar {
1260
+ position: relative;
1261
+ display: block;
1262
+ width: 100%;
1263
+ min-width: 92px;
1264
+ height: 9px;
1265
+ background: #282d30;
1266
+ }
1267
+
1268
+ .score-fill {
1269
+ display: block;
1270
+ height: 100%;
1271
+ background: var(--muted);
1272
+ }
1273
+
1274
+ .score-fill.tone-positive,
1275
+ .score-fill.tone-after {
1276
+ background: var(--lime);
1277
+ }
1278
+
1279
+ .score-fill.tone-negative {
1280
+ background: var(--coral);
1281
+ }
1282
+
1283
+ .score-bar > .has-threshold {
1284
+ position: absolute;
1285
+ top: -2px;
1286
+ height: 13px;
1287
+ border-left: 1px solid currentColor;
1288
+ }
1289
+
1259
1290
  .quantized-bar {
1260
1291
  display: grid;
1261
1292
  grid-template-columns: repeat(20, minmax(2px, 1fr));
@@ -1405,6 +1436,7 @@ h1 {
1405
1436
  text-transform: uppercase;
1406
1437
  }
1407
1438
 
1439
+ .assertion-content > .score-bar,
1408
1440
  .assertion-content > .quantized-bar {
1409
1441
  max-width: 220px;
1410
1442
  margin-top: 8px;
package/viewer/charts.js CHANGED
@@ -51,28 +51,40 @@ const distributionBar = (segments, label) => {
51
51
  </div>`;
52
52
  };
53
53
 
54
+ export const scoreBar = (value, tone, label, threshold = null) => {
55
+ if (typeof value !== "number" || !Number.isFinite(value)) {
56
+ return `<span class="score-bar unavailable" role="img" aria-label="${escapeHtml(label)}"></span>`;
57
+ }
58
+ return `<span class="score-bar" role="img" aria-label="${escapeHtml(label)}">
59
+ <i class="score-fill ${escapeHtml(tone)}" style="width: ${value * 100}%"></i>
60
+ ${typeof threshold === "number" ? `<i class="has-threshold" style="left: ${threshold * 100}%"></i>` : ""}
61
+ </span>`;
62
+ };
63
+
54
64
  export const comparisonBars = ({
55
65
  title,
56
66
  before,
57
67
  after,
58
68
  maximum,
69
+ continuous = false,
59
70
  beforeText,
60
71
  afterText,
61
72
  deltaText,
62
73
  deltaTone,
63
74
  }) => {
75
+ const bar = continuous ? scoreBar : quantizedBar;
64
76
  const scale = Math.max(maximum ?? 0, before ?? 0, after ?? 0, 1);
65
77
  return `<figure class="comparison-bars">
66
78
  <figcaption><span>${escapeHtml(title)}</span><b class="${escapeHtml(
67
79
  deltaTone,
68
80
  )}">${escapeHtml(deltaText)}</b></figcaption>
69
- <div class="bar-lane"><strong>A</strong>${quantizedBar(
70
- (before ?? 0) / scale,
81
+ <div class="bar-lane"><strong>A</strong>${bar(
82
+ before === null || before === undefined ? null : before / scale,
71
83
  "tone-before",
72
84
  `${title} baseline ${beforeText}`,
73
85
  )}<span>${escapeHtml(beforeText)}</span></div>
74
- <div class="bar-lane"><strong>B</strong>${quantizedBar(
75
- (after ?? 0) / scale,
86
+ <div class="bar-lane"><strong>B</strong>${bar(
87
+ after === null || after === undefined ? null : after / scale,
76
88
  "tone-after",
77
89
  `${title} candidate ${afterText}`,
78
90
  )}<span>${escapeHtml(afterText)}</span></div>