Skip to content

Commit d94678e

Browse files
[OPIK-7094] [QA] test: cover experiment comparison view (#7550)
Add E2E coverage for the experiment comparison view (/experiments/{datasetId}/compare in multi-experiment mode), the gap called out in OPIK-7094. Seeds two experiments over one shared dataset with divergent per-item outcomes and asserts the side-by-side grid, aggregate scores, sort/search, and the per-item drill-in panel. - New compare-seed bridge route keeps task_output off the dataset item so both experiments run over the same item ids while scoring differently. - New comparison fixture + compare-experiments POM + 4-test spec. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dc3af1c commit d94678e

8 files changed

Lines changed: 719 additions & 17 deletions

File tree

tests_end_to_end/e2e/core/sdk/python-sdk-client.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,30 @@ export interface PythonSdkClient {
6969
score_value: number;
7070
}>;
7171
}>;
72+
compareSeed(args: {
73+
project_name: string;
74+
dataset_name: string;
75+
items: Array<{ input: string; expected_output: string }>;
76+
experiments: Array<{ experiment_name: string; task_outputs: string[] }>;
77+
dataset_description?: string;
78+
workspace?: string;
79+
}): Promise<{
80+
dataset_id: string;
81+
dataset_name: string;
82+
item_count: number;
83+
experiments: Array<{
84+
experiment_id: string;
85+
experiment_name: string;
86+
scores: Array<{
87+
dataset_item_id: string;
88+
input: string;
89+
expected_output: string;
90+
task_output: string;
91+
score_name: string;
92+
score_value: number;
93+
}>;
94+
}>;
95+
}>;
7296
createTextPrompt(args: {
7397
name: string;
7498
prompt: string;
@@ -233,6 +257,25 @@ export function makePythonSdkClient(opts: { bridgeUrl?: string } = {}): PythonSd
233257
async createDataset(args) {
234258
return request<{ id: string; name: string }>('POST', '/datasets', args);
235259
},
260+
async compareSeed(args) {
261+
return request<{
262+
dataset_id: string;
263+
dataset_name: string;
264+
item_count: number;
265+
experiments: Array<{
266+
experiment_id: string;
267+
experiment_name: string;
268+
scores: Array<{
269+
dataset_item_id: string;
270+
input: string;
271+
expected_output: string;
272+
task_output: string;
273+
score_name: string;
274+
score_value: number;
275+
}>;
276+
}>;
277+
}>('POST', '/experiments/compare-seed', args);
278+
},
236279
async createTextPrompt(args) {
237280
return request<{ id: string; name: string }>('POST', '/prompts/text', args);
238281
},
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { test as baseTest } from './experiment.fixture';
2+
import { shouldLeaveArtifacts } from '../core/artifacts';
3+
4+
export interface ComparisonItemSeed {
5+
input: string;
6+
expected_output: string;
7+
}
8+
9+
export interface ComparisonExperimentRef {
10+
experimentId: string;
11+
experimentName: string;
12+
/** Score keyed by shared dataset item id. */
13+
scoresByItemId: Record<string, number>;
14+
/** Evaluation-task output keyed by shared dataset item id. */
15+
outputsByItemId: Record<string, string>;
16+
/** Mean of this experiment's per-item scores. */
17+
aggregateScore: number;
18+
}
19+
20+
export interface ComparisonRef {
21+
datasetId: string;
22+
datasetName: string;
23+
projectName: string;
24+
items: ComparisonItemSeed[];
25+
/** Dataset item ids, aligned by index with `items` (shared across both experiments). */
26+
itemIds: string[];
27+
evaluator: { name: string };
28+
experiments: ComparisonExperimentRef[];
29+
}
30+
31+
export interface ComparisonFixtures {
32+
comparison: ComparisonRef;
33+
}
34+
35+
/**
36+
* Three shared dataset items scored by two experiments. The seed is tuned so a
37+
* comparison test can see everything that matters, all at once:
38+
* item expA out expB out expA expB
39+
* q1 -> A A A pass pass (agree)
40+
* q2 -> B B WRONG pass fail (disagree)
41+
* q3 -> C C WRONG pass fail (disagree)
42+
* aggregate mean: 1.00 0.33
43+
* Properties this buys:
44+
* - the two experiments DISAGREE per item (q2, q3) — side-by-side is meaningful;
45+
* - their AGGREGATES differ (1.00 vs 0.33) — the Feedback scores tab is meaningful;
46+
* - q1 is the UNIQUE highest-aggregate item (2 vs 1) — score-sort has a stable top.
47+
*/
48+
const SEED_ITEMS: ComparisonItemSeed[] = [
49+
{ input: 'q1', expected_output: 'A' },
50+
{ input: 'q2', expected_output: 'B' },
51+
{ input: 'q3', expected_output: 'C' },
52+
];
53+
54+
const EXPERIMENT_A_OUTPUTS = ['A', 'B', 'C'];
55+
const EXPERIMENT_B_OUTPUTS = ['A', 'WRONG', 'WRONG'];
56+
57+
export const test = baseTest.extend<ComparisonFixtures>({
58+
comparison: async ({ sdkClient, backendClient, project, testNamespace }, use, testInfo) => {
59+
const datasetName = `${testNamespace}-cmp-ds`;
60+
const experimentNameA = `${testNamespace}-cmp-expA`;
61+
const experimentNameB = `${testNamespace}-cmp-expB`;
62+
63+
const seeded = await sdkClient.python.compareSeed({
64+
project_name: project.name,
65+
dataset_name: datasetName,
66+
items: SEED_ITEMS,
67+
experiments: [
68+
{ experiment_name: experimentNameA, task_outputs: EXPERIMENT_A_OUTPUTS },
69+
{ experiment_name: experimentNameB, task_outputs: EXPERIMENT_B_OUTPUTS },
70+
],
71+
});
72+
73+
// itemId per seed input, so tests can address a shared item by its input.
74+
const itemIdByInput: Record<string, string> = {};
75+
for (const s of seeded.experiments[0].scores) {
76+
itemIdByInput[s.input] = s.dataset_item_id;
77+
}
78+
79+
// The bridge doesn't echo task_output on the dataset item, so map each
80+
// experiment's per-item output from the seed arrays (aligned to SEED_ITEMS).
81+
const outputsBySeedIndex = [EXPERIMENT_A_OUTPUTS, EXPERIMENT_B_OUTPUTS];
82+
83+
const experiments: ComparisonExperimentRef[] = seeded.experiments.map((exp, expIndex) => {
84+
const scoresByItemId = Object.fromEntries(exp.scores.map((s) => [s.dataset_item_id, s.score_value]));
85+
const outputsByItemId: Record<string, string> = {};
86+
SEED_ITEMS.forEach((item, i) => {
87+
outputsByItemId[itemIdByInput[item.input]] = outputsBySeedIndex[expIndex][i];
88+
});
89+
const scoreValues = Object.values(scoresByItemId);
90+
const aggregateScore = scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length;
91+
return {
92+
experimentId: exp.experiment_id,
93+
experimentName: exp.experiment_name,
94+
scoresByItemId,
95+
outputsByItemId,
96+
aggregateScore,
97+
};
98+
});
99+
100+
const ref: ComparisonRef = {
101+
datasetId: seeded.dataset_id,
102+
datasetName,
103+
projectName: project.name,
104+
items: SEED_ITEMS,
105+
itemIds: SEED_ITEMS.map((item) => itemIdByInput[item.input]),
106+
evaluator: { name: 'equals_metric' },
107+
experiments,
108+
};
109+
110+
await testInfo.attach('opik.comparison', {
111+
body: JSON.stringify(ref, null, 2),
112+
contentType: 'application/json',
113+
});
114+
115+
await use(ref);
116+
117+
if (!shouldLeaveArtifacts(testInfo)) {
118+
for (const exp of experiments) {
119+
try {
120+
await backendClient.deleteExperiment(exp.experimentId);
121+
} catch (err) {
122+
console.warn(`[comparison fixture] delete experiment warning for ${exp.experimentName}:`, err);
123+
}
124+
}
125+
try {
126+
await backendClient.deleteDataset(seeded.dataset_id);
127+
} catch (err) {
128+
console.warn(`[comparison fixture] delete dataset warning for ${datasetName}:`, err);
129+
}
130+
}
131+
},
132+
});
133+
134+
export { expect } from './experiment.fixture';

tests_end_to_end/e2e/fixtures/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export type {
1515
ExperimentItemSeed,
1616
ExperimentItemScore,
1717
} from './experiment.fixture';
18+
export type {
19+
ComparisonRef,
20+
ComparisonFixtures,
21+
ComparisonItemSeed,
22+
ComparisonExperimentRef,
23+
} from './comparison-experiment.fixture';
1824
export type {
1925
TestSuiteRef,
2026
TestSuiteFixtures,

tests_end_to_end/e2e/fixtures/test-suite.fixture.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test as baseTest } from './experiment.fixture';
1+
import { test as baseTest } from './comparison-experiment.fixture';
22
import { shouldLeaveArtifacts } from '../core/artifacts';
33

44
export interface TestSuiteItemSeed {

0 commit comments

Comments
 (0)