Skip to content

Commit 7e6d593

Browse files
committed
Add PerfTrace latency eval harness with phase assertions
Eval helpers assert phase presence, nesting, and turn inference+tools regressions against a golden multi-tool fixture and the reactor observer pipeline.
1 parent 480fee5 commit 7e6d593

3 files changed

Lines changed: 409 additions & 0 deletions

File tree

src/perf/assert-spans.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Latency eval harness: assert on PerfTrace phase presence and relative magnitudes.
3+
*
4+
* Covers CL-5174 outcomes:
5+
* - phase presence + nesting helpers
6+
* - regression: turn has inference + tools when tools ran
7+
* - golden multi-tool fixture rollup
8+
* - full observer pipeline → snapshot → rollup → assertions
9+
*/
10+
11+
import { afterEach, describe, expect, test } from "bun:test";
12+
import type { ReactorEmittedEvent } from "@intx/inference";
13+
import {
14+
assertLessThan,
15+
assertNesting,
16+
assertPhasePresent,
17+
assertPhaseSummary,
18+
assertTurnHasInferenceAndTools,
19+
} from "./assert-spans.js";
20+
import {
21+
MULTI_TOOL_TURN_GOLDEN,
22+
multiToolTurnFixture,
23+
} from "./fixtures/multi-tool-turn.js";
24+
import { ALLOWED_TAG_KEYS, clear, snapshot, type PerfSpan } from "./index.js";
25+
import { createPerfReactorObserver } from "./reactor-spans.js";
26+
import { rollupByPhase, rollupByTurn, sessionTotals } from "./rollup.js";
27+
28+
afterEach(() => {
29+
clear();
30+
});
31+
32+
const ALLOWED_TAG_KEY_SET: ReadonlySet<string> = new Set(ALLOWED_TAG_KEYS);
33+
34+
function event(type: string, data: unknown = {}): ReactorEmittedEvent {
35+
return { type, seq: 1, data } as ReactorEmittedEvent;
36+
}
37+
38+
const emptyUsage = { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, thinking: 0 };
39+
const source = { provider: "test-provider", model: "test-model" };
40+
41+
function inferenceDone(content: unknown[] = [{ type: "text", text: "hi" }]): ReactorEmittedEvent {
42+
return event("inference.done", {
43+
turn: { role: "assistant", content, model: "test-model", timestamp: 0 },
44+
usage: emptyUsage,
45+
source,
46+
});
47+
}
48+
49+
function completed(spans: PerfSpan[]): PerfSpan[] {
50+
return spans.filter((s) => s.endNs !== undefined);
51+
}
52+
53+
describe("assertPhasePresent / assertNesting", () => {
54+
test("assertPhasePresent finds phases on the golden fixture", () => {
55+
const spans = multiToolTurnFixture();
56+
assertPhasePresent(spans, "turn");
57+
assertPhasePresent(spans, "inference");
58+
assertPhasePresent(spans, "inference.ttft");
59+
assertPhasePresent(spans, "inference.stream");
60+
assertPhasePresent(spans, "tool");
61+
assertPhasePresent(spans, "permission.wait");
62+
});
63+
64+
test("assertPhasePresent throws when phase is missing", () => {
65+
expect(() => assertPhasePresent(multiToolTurnFixture(), "subagent")).toThrow(
66+
/expected phase "subagent"/,
67+
);
68+
});
69+
70+
test("assertNesting verifies parent-child links", () => {
71+
const spans = multiToolTurnFixture();
72+
assertNesting(spans, "inference", "turn");
73+
assertNesting(spans, "inference.ttft", "inference");
74+
assertNesting(spans, "inference.stream", "inference");
75+
assertNesting(spans, "tool", "turn");
76+
assertNesting(spans, "permission.wait", "turn");
77+
});
78+
79+
test("assertNesting throws when link is absent", () => {
80+
expect(() => assertNesting(multiToolTurnFixture(), "tool", "inference")).toThrow(
81+
/expected nesting inference tool/,
82+
);
83+
});
84+
});
85+
86+
describe("golden multi-tool turn fixture", () => {
87+
test("rollupByTurn matches locked golden values", () => {
88+
const turns = rollupByTurn(multiToolTurnFixture());
89+
expect(turns).toHaveLength(1);
90+
expect(turns[0]).toEqual({ ...MULTI_TOOL_TURN_GOLDEN });
91+
});
92+
93+
test("fixture tags are privacy-safe (allowlisted keys only)", () => {
94+
for (const span of multiToolTurnFixture()) {
95+
if (span.tags === undefined) continue;
96+
for (const key of Object.keys(span.tags)) {
97+
expect(ALLOWED_TAG_KEY_SET.has(key)).toBe(true);
98+
}
99+
}
100+
});
101+
102+
test("phase rollup reports expected counts and totals", () => {
103+
const phases = rollupByPhase(multiToolTurnFixture());
104+
assertPhaseSummary(phases, "turn", { minCount: 1, minTotalNs: 5000 });
105+
assertPhaseSummary(phases, "inference", { minCount: 1, minTotalNs: 2000 });
106+
assertPhaseSummary(phases, "tool", { minCount: 2, minTotalNs: 1200 });
107+
assertPhaseSummary(phases, "permission.wait", { minCount: 1, minTotalNs: 400 });
108+
});
109+
});
110+
111+
describe("regression: turn has inference + tools when tools ran", () => {
112+
test("assertTurnHasInferenceAndTools passes on multi-tool golden rollup", () => {
113+
const turns = rollupByTurn(multiToolTurnFixture());
114+
assertTurnHasInferenceAndTools(turns[0]!);
115+
});
116+
117+
test("assertTurnHasInferenceAndTools fails when tools did not run", () => {
118+
const spans: PerfSpan[] = multiToolTurnFixture().filter((s) => s.name !== "tool");
119+
const turns = rollupByTurn(spans);
120+
expect(() => assertTurnHasInferenceAndTools(turns[0]!)).toThrow(/toolCount/);
121+
});
122+
123+
test("TTFT is less than stream on the golden fixture", () => {
124+
const turn = rollupByTurn(multiToolTurnFixture())[0]!;
125+
assertLessThan(turn.ttftNs, turn.streamNs, "ttft vs stream");
126+
expect(turn.ttftNs).toBe(400);
127+
expect(turn.streamNs).toBe(1600);
128+
});
129+
130+
test("session totals include tool and inference cost", () => {
131+
const totals = sessionTotals(multiToolTurnFixture());
132+
expect(totals.turnCount).toBe(1);
133+
expect(totals.totalInferenceNs).toBe(2000);
134+
expect(totals.totalToolNs).toBe(1200);
135+
expect(totals.totalToolCount).toBe(2);
136+
expect(totals.ttftShare).toBeCloseTo(0.2, 5);
137+
expect(totals.streamShare).toBeCloseTo(0.8, 5);
138+
});
139+
});
140+
141+
describe("observer pipeline → snapshot → rollup → assertions", () => {
142+
test("multi-tool reactor events produce assertable turn rollup", () => {
143+
const obs = createPerfReactorObserver();
144+
145+
obs.observe(event("inference.start", { model: "test-model" }));
146+
obs.observe(event("inference.text.delta", { token: "x", partial: { text: "x" } }));
147+
obs.observe(
148+
inferenceDone([
149+
{ type: "tool_call", id: "call-a", name: "read_file", arguments: {} },
150+
{ type: "tool_call", id: "call-b", name: "edit_file", arguments: {} },
151+
]),
152+
);
153+
obs.observe(event("tool.start", { call: { id: "call-a", name: "read_file", arguments: {} } }));
154+
obs.observe(event("tool.done", { result: { callId: "call-a", content: "ok" } }));
155+
obs.observe(event("tool.start", { call: { id: "call-b", name: "edit_file", arguments: {} } }));
156+
obs.observe(event("tool.done", { result: { callId: "call-b", content: "ok" } }));
157+
158+
const spans = completed(snapshot());
159+
160+
assertPhasePresent(spans, "turn");
161+
assertPhasePresent(spans, "inference");
162+
assertPhasePresent(spans, "inference.ttft");
163+
assertPhasePresent(spans, "inference.stream");
164+
assertPhasePresent(spans, "tool");
165+
166+
assertNesting(spans, "inference", "turn");
167+
assertNesting(spans, "inference.ttft", "inference");
168+
assertNesting(spans, "inference.stream", "inference");
169+
assertNesting(spans, "tool", "turn");
170+
171+
const turns = rollupByTurn(spans);
172+
expect(turns).toHaveLength(1);
173+
assertTurnHasInferenceAndTools(turns[0]!);
174+
expect(turns[0]!.toolCount).toBe(2);
175+
176+
// Live clock: TTFT ends at/before stream starts, so ttftNs should be <= streamNs
177+
// only when both are positive; with real hrtime, stream wall is typically longer.
178+
if (turns[0]!.ttftNs > 0 && turns[0]!.streamNs > 0) {
179+
// Relative magnitude: first-token wait should not dominate a multi-token stream
180+
// in the happy path (stream duration is from first token to done).
181+
expect(turns[0]!.streamNs).toBeGreaterThanOrEqual(0);
182+
expect(turns[0]!.ttftNs).toBeGreaterThanOrEqual(0);
183+
}
184+
185+
const phases = rollupByPhase(spans);
186+
assertPhaseSummary(phases, "tool", { minCount: 2 });
187+
assertPhaseSummary(phases, "inference", { minCount: 1 });
188+
});
189+
});

src/perf/assert-spans.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Eval / test harness assertions over PerfTrace snapshots and rollups.
3+
*
4+
* Pure helpers: throw Error with a clear message on failure (no bun:test import).
5+
* Use from unit tests, capability evals, or ad-hoc scripts after snapshot()/rollup.
6+
*/
7+
8+
import type { PerfSpan, SpanName } from "./index.js";
9+
import type { PhaseSummary, TurnSummary } from "./rollup.js";
10+
import { spanDurationNs } from "./rollup.js";
11+
12+
/** Verify at least one span with the given phase name exists. */
13+
export function assertPhasePresent(
14+
spans: readonly PerfSpan[],
15+
phaseName: SpanName | string,
16+
): void {
17+
const found = spans.some((s) => s.name === phaseName);
18+
if (!found) {
19+
const names = [...new Set(spans.map((s) => s.name))].sort().join(", ");
20+
throw new Error(
21+
`expected phase "${phaseName}" in snapshot; present phases: [${names || "none"}]`,
22+
);
23+
}
24+
}
25+
26+
/**
27+
* Verify at least one span named `childName` is nested under a span named
28+
* `parentName` (via parentId → id).
29+
*/
30+
export function assertNesting(
31+
spans: readonly PerfSpan[],
32+
childName: SpanName | string,
33+
parentName: SpanName | string,
34+
): void {
35+
const byId = new Map(spans.map((s) => [s.id, s]));
36+
const ok = spans.some((child) => {
37+
if (child.name !== childName || child.parentId === undefined) return false;
38+
const parent = byId.get(child.parentId);
39+
return parent !== undefined && parent.name === parentName;
40+
});
41+
if (!ok) {
42+
throw new Error(
43+
`expected nesting ${parentName}${childName}; no matching parentId link found`,
44+
);
45+
}
46+
}
47+
48+
/**
49+
* Regression: a turn that ran tools must report positive inference and tool cost.
50+
* Accepts a single TurnSummary (from rollupByTurn).
51+
*/
52+
export function assertTurnHasInferenceAndTools(turn: TurnSummary): void {
53+
if (turn.inferenceNs <= 0) {
54+
throw new Error(
55+
`turn ${turn.turnId}: expected inferenceNs > 0, got ${turn.inferenceNs}`,
56+
);
57+
}
58+
if (turn.toolCount <= 0) {
59+
throw new Error(
60+
`turn ${turn.turnId}: expected toolCount > 0 when tools ran, got ${turn.toolCount}`,
61+
);
62+
}
63+
if (turn.toolNs <= 0) {
64+
throw new Error(
65+
`turn ${turn.turnId}: expected toolNs > 0 when tools ran, got ${turn.toolNs}`,
66+
);
67+
}
68+
}
69+
70+
/**
71+
* Assert a < b for relative magnitude checks (e.g. TTFT < stream wall).
72+
* Values are plain numbers (typically nanoseconds from rollup).
73+
*/
74+
export function assertLessThan(
75+
left: number,
76+
right: number,
77+
label = "magnitude",
78+
): void {
79+
if (!(left < right)) {
80+
throw new Error(`${label}: expected ${left} < ${right}`);
81+
}
82+
}
83+
84+
/**
85+
* Assert a phase summary exists in a rollupByPhase result and has count >= minCount.
86+
*/
87+
export function assertPhaseSummary(
88+
phases: readonly PhaseSummary[],
89+
phaseName: SpanName | string,
90+
opts?: { minCount?: number; minTotalNs?: number },
91+
): PhaseSummary {
92+
const phase = phases.find((p) => p.name === phaseName);
93+
if (phase === undefined) {
94+
const names = phases.map((p) => p.name).join(", ");
95+
throw new Error(
96+
`expected phase summary "${phaseName}"; present: [${names || "none"}]`,
97+
);
98+
}
99+
const minCount = opts?.minCount ?? 1;
100+
if (phase.count < minCount) {
101+
throw new Error(
102+
`phase "${phaseName}": expected count >= ${minCount}, got ${phase.count}`,
103+
);
104+
}
105+
if (opts?.minTotalNs !== undefined && phase.totalNs < opts.minTotalNs) {
106+
throw new Error(
107+
`phase "${phaseName}": expected totalNs >= ${opts.minTotalNs}, got ${phase.totalNs}`,
108+
);
109+
}
110+
return phase;
111+
}
112+
113+
/** Span duration helper re-export for eval scripts that only import assertions. */
114+
export { spanDurationNs };

0 commit comments

Comments
 (0)