@prismatic-io/lux 0.0.2-preview.30 → 0.0.2-preview.33

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 (111) hide show
  1. package/lib/assertions/core/command-exits-zero.d.ts +1 -0
  2. package/lib/assertions/core/command-exits-zero.d.ts.map +1 -1
  3. package/lib/assertions/core/file-checks.d.ts +6 -0
  4. package/lib/assertions/core/file-checks.d.ts.map +1 -1
  5. package/lib/assertions/core/json-pointer-equals.d.ts +1 -0
  6. package/lib/assertions/core/json-pointer-equals.d.ts.map +1 -1
  7. package/lib/assertions/core/output-checks.d.ts +3 -0
  8. package/lib/assertions/core/output-checks.d.ts.map +1 -1
  9. package/lib/assertions/core/predicate.d.ts +1 -0
  10. package/lib/assertions/core/predicate.d.ts.map +1 -1
  11. package/lib/assertions/core/resource-checks.d.ts +6 -0
  12. package/lib/assertions/core/resource-checks.d.ts.map +1 -1
  13. package/lib/assertions/rubric/index.d.ts +2 -0
  14. package/lib/assertions/rubric/index.d.ts.map +1 -1
  15. package/lib/cli/output-schemas.d.ts +11 -5
  16. package/lib/cli/output-schemas.d.ts.map +1 -1
  17. package/lib/cli/output-schemas.js +5 -2
  18. package/lib/cli/output-schemas.js.map +1 -1
  19. package/lib/cli/program.d.ts.map +1 -1
  20. package/lib/cli/program.js +2 -1
  21. package/lib/cli/program.js.map +1 -1
  22. package/lib/cli/render/format.d.ts.map +1 -1
  23. package/lib/cli/render/format.js +1 -1
  24. package/lib/cli/render/format.js.map +1 -1
  25. package/lib/cli/render/reporter.d.ts.map +1 -1
  26. package/lib/cli/render/reporter.js +11 -4
  27. package/lib/cli/render/reporter.js.map +1 -1
  28. package/lib/core/assertion.d.ts +5 -2
  29. package/lib/core/assertion.d.ts.map +1 -1
  30. package/lib/core/assertion.js +3 -1
  31. package/lib/core/assertion.js.map +1 -1
  32. package/lib/core/case.d.ts +5 -1
  33. package/lib/core/case.d.ts.map +1 -1
  34. package/lib/core/case.js +1 -0
  35. package/lib/core/case.js.map +1 -1
  36. package/lib/core/experiment.d.ts +1 -0
  37. package/lib/core/experiment.d.ts.map +1 -1
  38. package/lib/core/experiment.js +5 -1
  39. package/lib/core/experiment.js.map +1 -1
  40. package/lib/core/rubric.d.ts +1 -0
  41. package/lib/core/rubric.d.ts.map +1 -1
  42. package/lib/core/run.d.ts +1 -0
  43. package/lib/core/run.d.ts.map +1 -1
  44. package/lib/orchestrator/compare.d.ts +6 -5
  45. package/lib/orchestrator/compare.d.ts.map +1 -1
  46. package/lib/orchestrator/compare.js +20 -11
  47. package/lib/orchestrator/compare.js.map +1 -1
  48. package/lib/orchestrator/config.d.ts +2 -0
  49. package/lib/orchestrator/config.d.ts.map +1 -1
  50. package/lib/orchestrator/experiment-contracts.d.ts +1 -0
  51. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  52. package/lib/orchestrator/experiment-effects.d.ts.map +1 -1
  53. package/lib/orchestrator/experiment-effects.js +4 -2
  54. package/lib/orchestrator/experiment-effects.js.map +1 -1
  55. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  56. package/lib/orchestrator/experiment-evaluation.js +15 -2
  57. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  58. package/lib/orchestrator/experiment-report.d.ts +1 -0
  59. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  60. package/lib/orchestrator/experiment-runs.d.ts +2 -0
  61. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  62. package/lib/orchestrator/experiment-selection.d.ts.map +1 -1
  63. package/lib/orchestrator/experiment-selection.js +99 -43
  64. package/lib/orchestrator/experiment-selection.js.map +1 -1
  65. package/lib/orchestrator/grader-experiment.d.ts +4 -2
  66. package/lib/orchestrator/grader-experiment.d.ts.map +1 -1
  67. package/lib/orchestrator/grading.d.ts +1 -0
  68. package/lib/orchestrator/grading.d.ts.map +1 -1
  69. package/lib/orchestrator/grading.js +34 -31
  70. package/lib/orchestrator/grading.js.map +1 -1
  71. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  72. package/lib/orchestrator/list-runs.js +7 -7
  73. package/lib/orchestrator/list-runs.js.map +1 -1
  74. package/lib/orchestrator/paired-effects.d.ts.map +1 -1
  75. package/lib/orchestrator/paired-effects.js +7 -1
  76. package/lib/orchestrator/paired-effects.js.map +1 -1
  77. package/lib/orchestrator/regrade.d.ts +2 -1
  78. package/lib/orchestrator/regrade.d.ts.map +1 -1
  79. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  80. package/lib/orchestrator/suite-compare.js +18 -6
  81. package/lib/orchestrator/suite-compare.js.map +1 -1
  82. package/lib/orchestrator/suite-summary.d.ts +2 -0
  83. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  84. package/lib/orchestrator/suite-summary.js +6 -2
  85. package/lib/orchestrator/suite-summary.js.map +1 -1
  86. package/lib/previewer/server.d.ts.map +1 -1
  87. package/lib/previewer/server.js +2 -1
  88. package/lib/previewer/server.js.map +1 -1
  89. package/package.json +1 -1
  90. package/skills/lux-answerer/SKILL.md +1 -1
  91. package/src/cli/output-schemas.ts +5 -2
  92. package/src/cli/program.ts +3 -1
  93. package/src/cli/render/format.ts +1 -1
  94. package/src/cli/render/reporter.ts +10 -4
  95. package/src/core/assertion.ts +3 -1
  96. package/src/core/case.ts +5 -1
  97. package/src/core/experiment.ts +7 -1
  98. package/src/orchestrator/compare.ts +28 -12
  99. package/src/orchestrator/experiment-effects.ts +4 -2
  100. package/src/orchestrator/experiment-evaluation.ts +18 -2
  101. package/src/orchestrator/experiment-selection.ts +101 -18
  102. package/src/orchestrator/grading.ts +50 -28
  103. package/src/orchestrator/list-runs.ts +8 -8
  104. package/src/orchestrator/paired-effects.ts +6 -1
  105. package/src/orchestrator/suite-compare.ts +19 -5
  106. package/src/orchestrator/suite-summary.ts +7 -2
  107. package/src/previewer/server.ts +2 -0
  108. package/viewer/app.css +36 -0
  109. package/viewer/charts.js +16 -4
  110. package/viewer/detail.js +40 -16
  111. package/viewer/format.js +2 -2
@@ -60,7 +60,9 @@ export const ViewOutputSchema = z.union([
60
60
  z.object({ url: z.string(), runsRoot: z.string() }),
61
61
  ]);
62
62
  const interrupts = z.object({ ask: z.number(), approve: z.number(), custom: z.number() });
63
- const verdict = z.object({ passed: z.boolean(), score: z.number() }).nullable();
63
+ const verdict = z
64
+ .object({ passed: z.boolean().nullable(), score: z.number().nullable() })
65
+ .nullable();
64
66
  const runComparison = z.object({
65
67
  pathA: z.string(),
66
68
  pathB: z.string(),
@@ -83,6 +85,7 @@ const runComparison = z.object({
83
85
  casePassedB: z.boolean().nullable(),
84
86
  caseScoreA: nullableNumber,
85
87
  caseScoreB: nullableNumber,
88
+ caseScoreComparable: z.boolean(),
86
89
  deltas: z.array(
87
90
  z.object({
88
91
  id: z.string(),
@@ -90,7 +93,7 @@ const runComparison = z.object({
90
93
  name: z.string(),
91
94
  before: verdict,
92
95
  after: verdict,
93
- status: z.enum(["added", "removed", "improved", "regressed", "unchanged"]),
96
+ status: z.enum(["added", "removed", "improved", "regressed", "unchanged", "unavailable"]),
94
97
  }),
95
98
  ),
96
99
  improvements: z.number(),
@@ -948,7 +948,9 @@ const runCommand = async (filters: string[], options: RunOptions, human = false)
948
948
  caseScore: result.result?.grading?.caseScore ?? null,
949
949
  assertionsPassed:
950
950
  result.result?.grading?.assertions.filter((assertion) => assertion.passed).length ?? 0,
951
- assertionsTotal: result.result?.grading?.assertions.length ?? 0,
951
+ assertionsTotal:
952
+ result.result?.grading?.assertions.filter((assertion) => assertion.passed !== null)
953
+ .length ?? 0,
952
954
  })),
953
955
  };
954
956
  } finally {
@@ -59,7 +59,7 @@ export const formatAlignedUsage = (usage: TokenUsage): string =>
59
59
  ].join(" "),
60
60
  ].join(" ");
61
61
 
62
- export const formatScore = (score: number): string => `${Math.round(score * 100)}%`;
62
+ export const formatScore = (score: number): string => `${Number((score * 100).toPrecision(15))}%`;
63
63
 
64
64
  /** A one-line count summary: "2 passed · 1 failed · 1 running (4)". */
65
65
  export const formatTally = (parts: [count: number, label: string][], total: number): string => {
@@ -121,7 +121,10 @@ const statusMark = (status: CaseStatus, theme: Theme): string => {
121
121
 
122
122
  const assertionTally = (view: CaseView): string => {
123
123
  const passed = view.assertions.filter((a) => a.passed).length;
124
- const evaluated = view.assertions.length === 0 ? "—" : `${passed}/${view.assertions.length}`;
124
+ const evaluated =
125
+ view.assertions.length === 0
126
+ ? "—"
127
+ : `${passed}/${view.assertions.filter((a) => a.passed !== null).length}`;
125
128
  return view.skippedAssertions.length > 0
126
129
  ? `${evaluated} · ${view.skippedAssertions.length} skipped`
127
130
  : evaluated;
@@ -162,7 +165,8 @@ const cell = (text: string, width: number): string => {
162
165
  const scoreNote = (entry: GradingAssertionEntry): string => {
163
166
  if (entry.errorMessage) return entry.errorMessage;
164
167
  if (entry.abstention) return "insufficient evidence";
165
- if (entry.passed) return "";
168
+ if (entry.passed === null) return `informational; score ${formatScore(entry.score)}`;
169
+ if (entry.passed) return entry.score < 1 ? `score ${formatScore(entry.score)}` : "";
166
170
  const score = formatScore(entry.score);
167
171
  if (entry.threshold > 0) return `score ${score} < ${formatScore(entry.threshold)}`;
168
172
  return entry.score > 0 ? `score ${score}` : "";
@@ -464,11 +468,12 @@ export class ConsoleReporter implements SuiteObserver {
464
468
  );
465
469
  for (const entry of view.assertions) {
466
470
  let mark = entry.passed ? this.theme.green("✓") : this.theme.red("✗");
471
+ if (entry.passed === null) mark = this.theme.dim("ℹ");
467
472
  if (entry.abstention) mark = this.theme.dim("?");
468
473
  const duration = formatDuration(entry.durationMs).padStart(DURATION_COL);
469
474
  const note = scoreNote(entry);
470
475
  const suffix = note
471
- ? ` ${entry.abstention ? this.theme.dim(note) : this.theme.red(note)}`
476
+ ? ` ${entry.abstention || entry.passed !== false ? this.theme.dim(note) : this.theme.red(note)}`
472
477
  : "";
473
478
  nodes.push({
474
479
  head: `${mark} ${cell(entry.name, width)} ${this.theme.dim(duration)}${suffix}`,
@@ -581,11 +586,12 @@ export class ConsoleReporter implements SuiteObserver {
581
586
  const skippedAssertions = this.cases.flatMap((c) => c.skippedAssertions);
582
587
  if (assertions.length > 0 || skippedAssertions.length > 0) {
583
588
  const ok = assertions.filter((a) => a.passed).length;
584
- const bad = assertions.length - ok;
589
+ const bad = assertions.filter((a) => a.passed === false).length;
585
590
  const tally = formatTally(
586
591
  [
587
592
  [ok, this.theme.green("passed")],
588
593
  [bad, this.theme.red("failed")],
594
+ [assertions.filter((a) => a.passed === null).length, this.theme.dim("informational")],
589
595
  [skippedAssertions.length, this.theme.dim("skipped")],
590
596
  ],
591
597
  assertions.length + skippedAssertions.length,
@@ -107,6 +107,7 @@ export const baseAssertionFields = {
107
107
  weight: z.number().positive().optional(),
108
108
  threshold: z.number().min(0).max(1).optional(),
109
109
  name: z.string().optional(),
110
+ informational: z.boolean().optional(),
110
111
  } as const;
111
112
 
112
113
  export const defineAssertion = <S extends z.ZodObject>(
@@ -137,7 +138,8 @@ export const GradingAssertionEntrySchema = z.object({
137
138
  id: z.exactOptional(z.string()),
138
139
  type: z.string(),
139
140
  name: z.string(),
140
- passed: z.boolean(),
141
+ passed: z.boolean().nullable(),
142
+ informational: z.exactOptional(z.boolean()),
141
143
  score: z.number(),
142
144
  weight: z.number(),
143
145
  threshold: z.number(),
package/src/core/case.ts CHANGED
@@ -45,6 +45,7 @@ export const AssertionSchema = z
45
45
  name: z.string().optional(),
46
46
  weight: z.number().positive().optional(),
47
47
  threshold: z.number().min(0).max(1).optional(),
48
+ informational: z.boolean().optional(),
48
49
  })
49
50
  .loose();
50
51
  export type Assertion = z.infer<typeof AssertionSchema>;
@@ -83,7 +84,10 @@ export const EvalCaseSchema = EvalCaseObjectSchema
83
84
  });
84
85
  export type EvalCase = z.infer<typeof EvalCaseSchema>;
85
86
 
86
- type AssertionPlanFields = Pick<Assertion, "id" | "name" | "threshold" | "weight">;
87
+ type AssertionPlanFields = Pick<
88
+ Assertion,
89
+ "id" | "name" | "threshold" | "weight" | "informational"
90
+ >;
87
91
  type AssertionInputOf<Plugin> =
88
92
  Plugin extends AssertionPlugin<Assertion, infer Input> ? Input & AssertionPlanFields : never;
89
93
 
@@ -173,6 +173,11 @@ const validateConstraintConfidence = (
173
173
  ) {
174
174
  issue("relative constraints require a metric with per-case observations");
175
175
  }
176
+ if (constraint.metric === "score" && constraint.confidence?.method === "wilson") {
177
+ issue(
178
+ "continuous scores are not Bernoulli observations; use Hoeffding or percentile-bootstrap confidence",
179
+ );
180
+ }
176
181
  const bounded = boundedMetrics.has(constraint.metric);
177
182
  if (
178
183
  (constraint.confidence?.method === "hoeffding" || constraint.confidence?.method === "wilson") &&
@@ -263,7 +268,7 @@ export const ExperimentCampaignSchema = z
263
268
  "grader.operationalErrorRate",
264
269
  "grader.scoreMae",
265
270
  ]);
266
- const unitIntervalMetrics = graderConfidenceMetrics;
271
+ const unitIntervalMetrics = new Set([...graderConfidenceMetrics, "score"]);
267
272
  if (campaign.optimizer && !campaign.splits.train) {
268
273
  ctx.addIssue({
269
274
  code: "custom",
@@ -454,6 +459,7 @@ export const CandidateEvaluationSchema = z.object({
454
459
  z.string(),
455
460
  z.object({
456
461
  identityHash: z.string().nullable(),
462
+ scoreBasis: z.exactOptional(z.string().nullable()),
457
463
  clusterId: z.exactOptional(z.string().min(1)),
458
464
  metrics: z.record(
459
465
  z.string(),
@@ -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; score: number } | null;
43
- after: { passed: boolean; 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,21 +179,24 @@ 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";
181
187
  } else {
182
188
  status = "unchanged";
183
189
  }
184
- if (status === "improved") improvements++;
185
- if (status === "regressed") regressions++;
190
+ if (ax?.passed !== null && bx?.passed !== null) {
191
+ if (status === "improved") improvements++;
192
+ if (status === "regressed") regressions++;
193
+ }
186
194
  deltas.push({
187
195
  id: name,
188
196
  type: ax?.type ?? bx?.type ?? "unknown",
189
197
  name: ax?.id || bx?.id ? (ax?.name ?? bx?.name ?? name) : name,
190
- before: ax ? { passed: ax.passed, score: ax.score } : null,
191
- 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,
192
200
  status,
193
201
  });
194
202
  }
@@ -312,6 +320,9 @@ export const compareRuns = async (input: ComparisonInput): Promise<RunComparison
312
320
  casePassedB: b.grading?.casePassed ?? null,
313
321
  caseScoreA: a.grading?.caseScore ?? null,
314
322
  caseScoreB: b.grading?.caseScore ?? null,
323
+ caseScoreComparable:
324
+ gradingScoreBasis(a.grading) !== null &&
325
+ gradingScoreBasis(a.grading) === gradingScoreBasis(b.grading),
315
326
  ...grading,
316
327
  },
317
328
  artifactsDelta: computeFileDelta(a.fileTree, b.fileTree),
@@ -325,7 +336,8 @@ export const compareRuns = async (input: ComparisonInput): Promise<RunComparison
325
336
  export const hasRegression = (cmp: RunComparison): boolean =>
326
337
  cmp.caseMismatch.length > 0 ||
327
338
  cmp.grading.regressions > 0 ||
328
- cmp.grading.deltas.some((delta) => delta.status === "removed") ||
339
+ cmp.grading.deltas.some((delta) => delta.after?.score === null && delta.after.passed !== null) ||
340
+ cmp.grading.deltas.some((delta) => delta.status === "removed" && delta.before?.passed !== null) ||
329
341
  (cmp.grading.casePassedA === true && cmp.grading.casePassedB !== true);
330
342
 
331
343
  const formatMismatchValue = (value: unknown): string => {
@@ -374,13 +386,17 @@ export const formatComparisonText = (cmp: RunComparison): string => {
374
386
  lines.push("Grading:");
375
387
  lines.push(
376
388
  ` passed: ${String(cmp.grading.casePassedA)} → ${String(cmp.grading.casePassedB)}; ` +
377
- `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)"}`,
378
390
  );
379
391
  lines.push(` ${cmp.grading.improvements} improved, ${cmp.grading.regressions} regressed`);
380
392
  for (const d of cmp.grading.deltas) {
381
393
  if (d.status === "unchanged") continue;
382
- const arrow = { added: "+", removed: "-", improved: "↑", regressed: "↓" }[d.status];
383
- 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
+ );
384
400
  }
385
401
  lines.push("");
386
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
 
@@ -15,7 +15,7 @@ import {
15
15
  } from "../core/index.js";
16
16
  import { validatePluginConfigAsync } from "./validate-plugin-config.js";
17
17
 
18
- const FRAMEWORK_KEYS = new Set(["id", "type", "name", "weight", "threshold"]);
18
+ const FRAMEWORK_KEYS = new Set(["id", "type", "name", "weight", "threshold", "informational"]);
19
19
 
20
20
  const shapeOf = (schema: z.ZodTypeAny): Record<string, unknown> | null => {
21
21
  const shape = (schema as { shape?: unknown }).shape;
@@ -131,6 +131,50 @@ 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
+
145
+ const aggregateGrading = (
146
+ entries: GradingAssertionEntry[],
147
+ skippedAssertions: NonNullable<GradingReport["skippedAssertions"]>,
148
+ gradingIncomplete: boolean,
149
+ ): GradingReport => {
150
+ const requiredEntries = entries.filter((entry) => entry.passed !== null);
151
+ const incomplete =
152
+ gradingIncomplete || requiredEntries.some((entry) => entry.errorMessage !== undefined);
153
+ const gradingAbstained = requiredEntries.some((entry) => entry.abstention !== undefined);
154
+ const scoredEntries = entries.filter(
155
+ (entry) => entry.errorMessage === undefined && entry.abstention === undefined,
156
+ );
157
+ const weightSum = scoredEntries.reduce((sum, entry) => sum + entry.weight, 0);
158
+ const weightedScoreSum = scoredEntries.reduce(
159
+ (sum, entry) => sum + entry.weight * entry.score,
160
+ 0,
161
+ );
162
+ const hasVerdict = !incomplete && !gradingAbstained && weightSum > 0;
163
+ return {
164
+ schemaVersion: 1,
165
+ casePassed:
166
+ hasVerdict && requiredEntries.length > 0
167
+ ? requiredEntries.every((entry) => entry.passed)
168
+ : null,
169
+ caseScore: hasVerdict ? weightedScoreSum / weightSum : null,
170
+ assertions: entries,
171
+ ...(skippedAssertions.length > 0 ? { skippedAssertions } : {}),
172
+ ...(incomplete
173
+ ? { incomplete: { exitReason: "error" as const, phase: "grading" as const } }
174
+ : {}),
175
+ };
176
+ };
177
+
134
178
  export const gradeRun = async (
135
179
  run: Run,
136
180
  registry: Registry,
@@ -157,11 +201,7 @@ export const gradeRun = async (
157
201
  name: string;
158
202
  reason: "judge-disabled";
159
203
  }> = [];
160
- let weightedScoreSum = 0;
161
- let weightSum = 0;
162
- let allPassed = true;
163
204
  let gradingIncomplete = false;
164
- let gradingAbstained = false;
165
205
 
166
206
  for (const [index, a] of assertions.entries()) {
167
207
  if (skipJudge && a.type === "rubric") {
@@ -232,7 +272,8 @@ export const gradeRun = async (
232
272
  id,
233
273
  type: a.type,
234
274
  name,
235
- passed,
275
+ passed: a.informational ? null : passed,
276
+ ...(a.informational ? { informational: true } : {}),
236
277
  score: result.score,
237
278
  weight,
238
279
  threshold,
@@ -243,29 +284,20 @@ export const gradeRun = async (
243
284
  ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
244
285
  ...(result.usage ? { usage: result.usage } : {}),
245
286
  };
246
- if (result.errorMessage !== undefined) {
247
- gradingIncomplete = true;
248
- } else if (result.abstention !== undefined) {
249
- gradingAbstained = true;
250
- } else {
251
- if (!passed) allPassed = false;
252
- weightedScoreSum += weight * result.score;
253
- weightSum += weight;
254
- }
255
287
  } catch (err) {
256
288
  const message = err instanceof Error ? err.message : String(err);
257
289
  entry = {
258
290
  id,
259
291
  type: a.type,
260
292
  name,
261
- passed: false,
293
+ passed: a.informational ? null : false,
294
+ ...(a.informational ? { informational: true } : {}),
262
295
  score: 0,
263
296
  weight,
264
297
  threshold,
265
298
  durationMs: Date.now() - startedAt,
266
299
  errorMessage: message,
267
300
  };
268
- gradingIncomplete = true;
269
301
  }
270
302
 
271
303
  entries.push(entry);
@@ -274,15 +306,5 @@ export const gradeRun = async (
274
306
  await modelAttempts?.complete(assertionAttemptIds);
275
307
  }
276
308
 
277
- const hasVerdict = !gradingIncomplete && !gradingAbstained && weightSum > 0;
278
- return {
279
- schemaVersion: 1,
280
- casePassed: hasVerdict ? allPassed : null,
281
- caseScore: hasVerdict ? weightedScoreSum / weightSum : null,
282
- assertions: entries,
283
- ...(skippedAssertions.length > 0 ? { skippedAssertions } : {}),
284
- ...(gradingIncomplete
285
- ? { incomplete: { exitReason: "error" as const, phase: "grading" as const } }
286
- : {}),
287
- };
309
+ return aggregateGrading(entries, skippedAssertions, gradingIncomplete);
288
310
  };