Skip to content

Commit fe5dfbb

Browse files
authored
feat(evals): grade & report metric-tool adoption (#2694)
## Summary The metrics runs needs to know whether the agent actually *reaches for the metric tools*, separately from whether it solves the incident. Grading only ever looked at the final answer text, so tool usage was invisible. This adds an **adoption** signal end-to-end — graded, reported, and shown in the viewer — while keeping it strictly out of the outcome score. - New transcript-aware check kind: rubric regexes run against a serialized tool-call transcript (names + args) via `serializeTranscript()` / `runTranscriptChecks()`; optional `rubric.transcript` block parsed in `rubric.ts`, results stored in `GradeRecord.adoption`. - `adoption` is **excluded from `combinedScore`**. - Sample rubric on `metric-saturation` (`used_metric_tool`, `described_jvm_memory_metric`, `described_gc_pause_metric`); `used_metric_tool` = `clickstack_(list|describe)_metric|metricType`, so metric reads via `clickstack_timeseries` count without false-positiving trace/log charts. - Reports: `aggregate.ts` emits `CellSummary.adoption {mean, perCheck}` + `DeltaSummary.adoptionScore` to `_summary.json`; `markdown.ts` adds an "Adoption (tool use)" row + "Adoption per-check (usage rate)" table to `_summary.md`. - Viewer: adoption section in the run grading tab, `adopt%` badges in nav/summary, and adoption row + per-check heatmap in the summary dashboard. ### Results (`metric-saturation`, opus-4-6, n=3/arm) | Signal | clickhouse | hyperdx | |---|---|---| | **Adoption (mean)** | **44%** | **100%** | | ↳ `used_metric_tool` | 0% | 100% | | ↳ `described_jvm_memory_metric` | 33% | 100% | | ↳ `described_gc_pause_metric` | 100% | 100% | | Combined (outcome) | ~97% | ~93% | The metric-tool arm used them in every run; the raw-SQL arm never invoked a metric tool and only incidentally hit metric names via SQL. Outcome stays comparable across arms — adoption diverges sharply while `combinedScore` does not. ### Screenshots <img width="1002" height="176" alt="Screenshot 2026-07-21 at 15 23 00" src="https://github.com/user-attachments/assets/9ccc4bd8-511e-409f-87f8-5cd0ca21ecbd" /> <img width="1185" height="163" alt="Screenshot 2026-07-21 at 15 22 45" src="https://github.com/user-attachments/assets/3d2ee2ea-a54e-429f-90b9-c05128c5a1f8" /> ### References - Linear Issue: Closes HDX-4786, Closes HDX-4785
1 parent ad27a51 commit fe5dfbb

15 files changed

Lines changed: 833 additions & 12 deletions

File tree

‎.changeset/hungry-chefs-wink.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/hdx-eval": patch
3+
---
4+
5+
feat(evals): grade & report metric-tool adoption

‎packages/hdx-eval/scripts/viewer/public/app.js‎

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,17 @@
293293
r.toolErrors > 0
294294
? el('span', { class: 'tag error' }, `${r.toolErrors}!`)
295295
: null;
296+
const adoptBadge =
297+
r.adoptionScore != null
298+
? el(
299+
'span',
300+
{
301+
class: 'tag adopt',
302+
title: 'metric-tool adoption (not part of combined score)',
303+
},
304+
`adopt ${pct(r.adoptionScore)}`,
305+
)
306+
: null;
296307
const termBadge =
297308
r.termination && r.termination !== 'final_answer'
298309
? el('span', { class: 'tag' }, r.termination)
@@ -310,7 +321,7 @@
310321
' ',
311322
el('span', { class: 'muted small' }, `${r.toolCalls ?? '?'} calls`),
312323
),
313-
el('span', {}, termBadge, ' ', errBadge, ' ', chip),
324+
el('span', {}, termBadge, ' ', adoptBadge, ' ', errBadge, ' ', chip),
314325
);
315326
mNode.appendChild(node);
316327
}
@@ -350,6 +361,11 @@
350361
'combined',
351362
grade?.combinedScore != null ? grade.combinedScore.toFixed(3) : '—',
352363
],
364+
// Adoption is only present when the scenario rubric defines transcript
365+
// checks; omit the field entirely otherwise to avoid a dangling '—'.
366+
...(grade?.adoption
367+
? [['adoption', pct(grade.adoption.score)]]
368+
: []),
353369
];
354370
for (const [k, v] of fields) {
355371
hdr.appendChild(
@@ -638,6 +654,48 @@
638654
pane.appendChild(checks);
639655
}
640656

657+
// Adoption checks (transcript-aware tool usage). Reported alongside the
658+
// outcome score but intentionally excluded from the combined score.
659+
if (g.adoption?.hits) {
660+
const checks = el('div', { class: 'grade-section' });
661+
checks.appendChild(
662+
el(
663+
'h3',
664+
{},
665+
'Adoption (tool usage)',
666+
el('span', { class: 'score' }, fmtScore(g.adoption.score)),
667+
),
668+
);
669+
for (const h of g.adoption.hits) {
670+
const good = !!h.satisfied;
671+
checks.appendChild(
672+
el(
673+
'div',
674+
{ class: 'check' + (h.negative ? ' negative' : '') },
675+
el(
676+
'div',
677+
{ class: 'marker ' + (good ? 'good' : 'bad') },
678+
good ? '✓' : '✗',
679+
),
680+
el('div', { class: 'id' }, h.id),
681+
el(
682+
'div',
683+
{ class: 'weight' },
684+
`w${h.weight}${h.matched != null ? ` · matched=${h.matched}` : ''}`,
685+
),
686+
),
687+
);
688+
}
689+
checks.appendChild(
690+
el(
691+
'div',
692+
{ class: 'muted small', style: 'margin-top:0.6rem; font-size:11px' },
693+
'Regexes run against the serialized tool-call transcript (tool names + args). Adoption is reported but NOT part of the combined score.',
694+
),
695+
);
696+
pane.appendChild(checks);
697+
}
698+
641699
// Judge scores
642700
if (g.judge?.scores) {
643701
const judge = el('div', { class: 'grade-section' });
@@ -857,6 +915,14 @@
857915
const section = el('div', { class: 'summary-section scenario-breakdown' });
858916
section.appendChild(el('h2', {}, scenario.scenario));
859917

918+
// Forward-compat: the aggregate/summary layer (reports/aggregate.ts) does
919+
// not yet emit adoption stats (that's HDX-4785). Only surface the adoption
920+
// metric row + per-check table when the summary actually carries the data,
921+
// so the viewer lights up automatically once aggregation lands.
922+
const hasAdoption = Object.values(scenario.cells || {}).some(
923+
(c) => c?.adoption != null,
924+
);
925+
860926
// Metrics table
861927
const metrics = [
862928
{
@@ -873,6 +939,17 @@
873939
dKey: 'programmaticScore',
874940
dFmt: signedPct,
875941
},
942+
...(hasAdoption
943+
? [
944+
{
945+
label: 'Adoption (tool use)',
946+
get: (c) => c?.adoption?.mean,
947+
fmt: pct,
948+
dKey: 'adoptionScore',
949+
dFmt: signedPct,
950+
},
951+
]
952+
: []),
876953
{
877954
label: 'Judge (weighted)',
878955
get: (c) => c?.judge?.weightedMean,
@@ -1049,6 +1126,51 @@
10491126
);
10501127
}
10511128

1129+
// Adoption per-check (forward-compatible: only when the summary carries
1130+
// adoption.perCheck, populated once HDX-4785 extends aggregate.ts).
1131+
const allAdoptionChecks = new Set();
1132+
for (const cell of Object.values(scenario.cells || {})) {
1133+
if (cell?.adoption?.perCheck) {
1134+
for (const id of Object.keys(cell.adoption.perCheck))
1135+
allAdoptionChecks.add(id);
1136+
}
1137+
}
1138+
if (allAdoptionChecks.size > 0) {
1139+
const aHeader = [el('th', {}, 'Adoption check')];
1140+
for (const m of mcpOrder) aHeader.push(el('th', {}, m));
1141+
const aRows = [...allAdoptionChecks].sort().map((id) => {
1142+
const cells = [el('td', { class: 'metric-label' }, id)];
1143+
for (const m of mcpOrder) {
1144+
const v = scenario.cells?.[m]?.adoption?.perCheck?.[id];
1145+
const cls =
1146+
v != null
1147+
? v >= 0.99
1148+
? 'check-pass'
1149+
: v >= 0.5
1150+
? 'check-partial'
1151+
: 'check-fail'
1152+
: '';
1153+
cells.push(
1154+
el('td', { class: `heatmap-cell ${cls}` }, v != null ? pct(v) : '—'),
1155+
);
1156+
}
1157+
return el('tr', {}, ...cells);
1158+
});
1159+
section.appendChild(
1160+
el(
1161+
'div',
1162+
{ class: 'sub-table' },
1163+
el('h3', {}, 'Adoption per-check (usage rate)'),
1164+
el(
1165+
'table',
1166+
{ class: 'summary-table heatmap' },
1167+
el('thead', {}, el('tr', {}, ...aHeader)),
1168+
el('tbody', {}, ...aRows),
1169+
),
1170+
),
1171+
);
1172+
}
1173+
10521174
// Side-by-side run comparison
10531175
const scenarioCells = state.cells.filter((c) => c.scenario === scenario.scenario);
10541176
if (scenarioCells.length > 0) {
@@ -1075,6 +1197,17 @@
10751197
r.toolErrors > 0
10761198
? el('span', { class: 'tag error small' }, `${r.toolErrors}err`)
10771199
: null;
1200+
const adoptBit =
1201+
r.adoptionScore != null
1202+
? el(
1203+
'span',
1204+
{
1205+
class: 'tag adopt small',
1206+
title: 'metric-tool adoption',
1207+
},
1208+
`adopt ${pct(r.adoptionScore)}`,
1209+
)
1210+
: null;
10781211
const termBit =
10791212
r.termination !== 'final_answer'
10801213
? el('span', { class: 'tag small' }, r.termination)
@@ -1090,6 +1223,7 @@
10901223
' ',
10911224
el('span', { class: 'muted small' }, `${r.toolCalls ?? '?'}c ${fmtMs(r.durationMs)}`),
10921225
termBit ? el('span', {}, ' ', termBit) : null,
1226+
adoptBit ? el('span', {}, ' ', adoptBit) : null,
10931227
errBit ? el('span', {}, ' ', errBit) : null,
10941228
),
10951229
);

‎packages/hdx-eval/scripts/viewer/public/style.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,10 @@ pre.code.error {
473473
font-size: 10px;
474474
padding: 0 0.3rem;
475475
}
476+
.tag.adopt {
477+
color: var(--accent);
478+
border-color: var(--accent);
479+
}
476480
.small {
477481
font-size: 11px;
478482
}

‎packages/hdx-eval/scripts/viewer/server.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ function collectRuns(cellDir) {
8383
idx: i,
8484
combinedScore: grade?.combinedScore ?? null,
8585
programmaticScore: grade?.programmatic?.score ?? null,
86+
// Transcript-aware tool-adoption score (null when the scenario rubric
87+
// has no `transcript` block, so the grade has no `adoption`).
88+
adoptionScore: grade?.adoption?.score ?? null,
8689
judgeScore: grade?.judge?.weightedScore ?? null,
8790
termination: traj?.termination ?? null,
8891
durationMs: traj?.durationMs ?? null,

‎packages/hdx-eval/src/__tests__/aggregate.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ function pair(args: {
1717
durationMs: number;
1818
model?: string;
1919
plugin?: string;
20+
/** When set, attaches a transcript-aware adoption block to the grade. */
21+
adoption?: { score: number; hits: Array<{ id: string; satisfied: boolean }> };
2022
}): GradedRunPair {
2123
const run: RunRecord = {
2224
schemaVersion: 1,
@@ -98,6 +100,17 @@ function pair(args: {
98100
gradedAt: '',
99101
judgeModel: 'claude-opus-4-7',
100102
};
103+
if (args.adoption) {
104+
grade.adoption = {
105+
score: args.adoption.score,
106+
hits: args.adoption.hits.map(h => ({
107+
id: h.id,
108+
weight: 1,
109+
matched: h.satisfied,
110+
satisfied: h.satisfied,
111+
})),
112+
};
113+
}
101114
return { run, grade };
102115
}
103116

@@ -448,4 +461,100 @@ describe('buildAggregate', () => {
448461
expect(summary.multiPlugin).toBe(false);
449462
expect(summary.columnOrder).toEqual(['clickhouse', 'hyperdx']);
450463
});
464+
465+
it('aggregates adoption mean + per-check usage rate with a baseline delta', () => {
466+
const base = {
467+
scenario: 'metric-saturation',
468+
programmaticScore: 1,
469+
judgeScore: 1,
470+
toolCalls: 10,
471+
outputTokens: 3000,
472+
durationMs: 40_000,
473+
};
474+
const pairs: GradedRunPair[] = [
475+
// baseline (clickhouse): no metric tools used.
476+
pair({
477+
...base,
478+
mcp: 'clickhouse',
479+
i: 0,
480+
adoption: {
481+
score: 0,
482+
hits: [
483+
{ id: 'used_metric_tool', satisfied: false },
484+
{ id: 'named_jvm_memory', satisfied: false },
485+
],
486+
},
487+
}),
488+
// challenger (hyperdx): run 0 fully adopts, run 1 partially adopts.
489+
pair({
490+
...base,
491+
mcp: 'hyperdx',
492+
i: 0,
493+
adoption: {
494+
score: 1,
495+
hits: [
496+
{ id: 'used_metric_tool', satisfied: true },
497+
{ id: 'named_jvm_memory', satisfied: true },
498+
],
499+
},
500+
}),
501+
pair({
502+
...base,
503+
mcp: 'hyperdx',
504+
i: 1,
505+
adoption: {
506+
score: 0.5,
507+
hits: [
508+
{ id: 'used_metric_tool', satisfied: true },
509+
{ id: 'named_jvm_memory', satisfied: false },
510+
],
511+
},
512+
}),
513+
];
514+
const summary = buildAggregate({ batchDir: '/tmp/x', pairs });
515+
const sc = summary.scenarios[0];
516+
const h = sc.cells['hyperdx']!;
517+
const c = sc.cells['clickhouse']!;
518+
// Mean adoption score across the two hyperdx runs: (1 + 0.5) / 2.
519+
expect(h.adoption?.mean).toBeCloseTo(0.75, 5);
520+
expect(c.adoption?.mean).toBeCloseTo(0, 5);
521+
// Per-check usage rate: used_metric_tool hit in both hyperdx runs (1.0),
522+
// named_jvm_memory only in run 0 (0.5).
523+
expect(h.adoption?.perCheck['used_metric_tool']).toBeCloseTo(1, 5);
524+
expect(h.adoption?.perCheck['named_jvm_memory']).toBeCloseTo(0.5, 5);
525+
expect(c.adoption?.perCheck['used_metric_tool']).toBeCloseTo(0, 5);
526+
// Delta = challenger - baseline.
527+
expect(sc.deltas['hyperdx'].adoptionScore).toBeCloseTo(0.75, 5);
528+
});
529+
530+
it('omits adoption from cells and deltas when no grade carries it', () => {
531+
const pairs: GradedRunPair[] = [
532+
pair({
533+
scenario: 'error-root-cause',
534+
mcp: 'clickhouse',
535+
i: 0,
536+
programmaticScore: 1,
537+
judgeScore: 1,
538+
toolCalls: 7,
539+
outputTokens: 1500,
540+
durationMs: 30_000,
541+
}),
542+
pair({
543+
scenario: 'error-root-cause',
544+
mcp: 'hyperdx',
545+
i: 0,
546+
programmaticScore: 1,
547+
judgeScore: 0.9,
548+
toolCalls: 13,
549+
outputTokens: 4000,
550+
durationMs: 60_000,
551+
}),
552+
];
553+
const summary = buildAggregate({ batchDir: '/tmp/x', pairs });
554+
const sc = summary.scenarios[0];
555+
expect(sc.cells['hyperdx'].adoption).toBeUndefined();
556+
expect(sc.cells['clickhouse'].adoption).toBeUndefined();
557+
// The delta object should not carry an adoptionScore key at all.
558+
expect('adoptionScore' in sc.deltas['hyperdx']).toBe(false);
559+
});
451560
});

0 commit comments

Comments
 (0)