Files
PaperClipAI/tests/runner-e2e/experiment-ingestion.test.ts
T
DottaandPaperclip e7ea2e2961 fix(evals): export grades for expanded qualification batches (#18010)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Product E2E campaigns retain each attempt and its independent
grades.
> - Braintrust receives product and cleanup grades for each retained
attempt.
> - A six-case qualification produces twelve scores. The export allowed
only eight.
> - Experiment creation succeeded, but grade insertion stopped before
dispatch.
> - This fix allows fourteen scores so the six- and seven-case batches
retain every result.
> - Operators can inspect failed and successful grades without running
an agent again.

## Linked Issues or Issue Description

Refs: #17044

**What happened?**

A retained six-case Product E2E campaign created its Braintrust
experiment, then failed with `Experiment ingestion allowance exhausted`.
Its twelve deterministic scores exceeded the eight-score cap. No insert
request was sent. Agent results and model accounting remained saved.

**Expected behavior**

The bounded qualification batches must export both grades for every
selected attempt. Failed grades must remain visible.

**Steps to reproduce**

Create an experiment through `openExperimentIngestion`. Insert six or
seven events with `product_pass` and `cleanup_pass` scores. The old
limit rejects the insert before it reaches the transport.

**Paperclip version or commit**

Reproduced on master `cff331f6ca0a652b5e41641c68dfa5fa584c22ae`.

**Deployment mode**

Local Docker controller built from source. The failure is in the shared
Product export path.

## What Changed

- Raise the finite experiment score cap from eight to fourteen. Keep the
three-request, 4 MiB and 60-second limits.
- Add regressions for the six- and seven-case batches. Keep the failed
product grade in each test batch.
- Verify that the receipt collector accepts the old eight-score policy
and the new fourteen-score policy. It rejects a fifteen-score policy.
- Document the seven-attempt export ceiling. This change does not add
agent calls or automatic retries.

## Verification

- Before the fix: both new batch regressions failed with `Experiment
ingestion allowance exhausted`.
- After the fix: 67 tests pass across experiment ingestion, ingestion
accounting, experiment projection and scheduled finalization.
- Command: `node node_modules/vitest/vitest.mjs run --config
tests/runner-e2e/vitest.config.ts
tests/runner-e2e/experiment-ingestion.test.ts
tests/runner-e2e/ingestion-usage.test.ts
tests/runner-e2e/scheduled-finalization.test.ts
tests/runner-e2e/braintrust-experiment.test.ts --configLoader runner
--cache=false`.
- `git diff --check` passes. All 58 current-head checks are acceptable,
including typecheck, build and tests. Both reviews are 5/5, with no
unresolved threads.
- Live agent attempts will not be repeated to verify this reporting fix.
Recovery uses the saved results and a separate bounded ingestion
receipt.

## Risks

- New exports can submit at most six more deterministic scores per
experiment. These are saved grades, not model judge calls.
- Larger campaigns still fail closed. Existing receipt files keep their
original policies and traffic counts.
- This fix does not resolve the plan-lifecycle behavior failures
observed in the qualification.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tool use and code execution.
The exact serving variant and context-window size are not exposed in
this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 07:50:59 -05:00

122 lines
7.5 KiB
TypeScript

import { afterEach, expect, it, vi } from "vitest";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { EVAL_EXPERIMENT_LIMITS, openExperimentIngestion } from "./experiment-ingestion.js";
const projectId = "11111111-1111-4111-8111-111111111111", experimentId = "22222222-2222-4222-8222-222222222222";
const roots: string[] = [], meters: Array<Awaited<ReturnType<typeof openExperimentIngestion>>> = [];
afterEach(async () => { await Promise.allSettled(meters.splice(0).map(meter => meter.close()));
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
async function fixture(transport: typeof fetch = async () => Response.json({ ok: true })) {
const root = await mkdtemp(path.join(tmpdir(), "experiment-ingestion-")); roots.push(root);
const policy = { projectId, campaignId: "campaign", deadlineAt: Date.now() + 60_000, receiptPath: path.join(root, "receipts.jsonl"), transport };
const meter = await openExperimentIngestion(policy); meters.push(meter);
return { ...meter, policy, records: async () => (await readFile(policy.receiptPath, "utf8")).trim().split("\n").map(line => JSON.parse(line)),
send: (payload: unknown, endpoint = "experiment") => meter.transport(`https://api.braintrust.dev/v1/${endpoint}`, {
method: "POST", body: JSON.stringify(payload), headers: { authorization: "Bearer private-secret" },
}) };
}
it("durably meters payloads and scores before dispatch while retaining body-free private evidence", async () => {
const upstream = vi.fn<typeof fetch>(async (_url, init) => {
expect((await f.records()).at(-1)).toMatchObject({ type: "admitted", bodyBytes: init?.body === undefined ? 0 : Buffer.byteLength(String(init.body)) });
expect(init!.redirect).toBe("error");
return Response.json({ ok: true });
});
const f = await fixture(upstream);
const create = { project_id: projectId, name: "private-payload-canary" };
const insert = { events: [{ scores: { product_pass: 0, cleanup_pass: 0 }, input: "private-input-canary" }] };
await f.send(create); await f.send(insert, `experiment/${experimentId}/insert`);
await f.transport(`https://api.braintrust.dev/v1/experiment/${experimentId}/summarize`, { method: "GET" });
await f.close();
const records = await f.records();
expect(records.at(-1)).toMatchObject({ type: "closed", admittedRequests: 3, admittedScores: 2,
admittedBytes: Buffer.byteLength(JSON.stringify(create)) + Buffer.byteLength(JSON.stringify(insert)), unresolvedRequests: 0 });
const raw = await readFile(f.policy.receiptPath, "utf8");
expect(raw).not.toMatch(/private-secret|private-payload-canary|private-input-canary/);
expect((await stat(f.policy.receiptPath)).mode & 0o777).toBe(0o600);
await expect(openExperimentIngestion(f.policy)).rejects.toMatchObject({ code: "EEXIST" });
expect(await readFile(f.policy.receiptPath, "utf8")).toBe(raw);
});
it("keeps failed forwarding charged to the finite allowance and records an unresolved outcome", async () => {
const upstream = vi.fn<typeof fetch>(async () => { throw new Error("private-upstream-error"); });
const f = await fixture(upstream);
for (let i = 0; i < EVAL_EXPERIMENT_LIMITS.maxRequests; i++) {
await expect(f.send({ project_id: projectId })).rejects.toThrow("unconfirmed");
}
await expect(f.send({ project_id: projectId })).rejects.toThrow("allowance exhausted");
expect(upstream).toHaveBeenCalledTimes(3);
await f.close();
expect((await f.records()).at(-1)).toMatchObject({ admittedRequests: 3, responses: 0, unresolvedRequests: 3 });
});
it.each([6, 7])("exports both deterministic grades for every retained case in a %i-case qualification", async cases => {
const upstream = vi.fn<typeof fetch>(async () => Response.json({ ok: true }));
const f = await fixture(upstream);
await f.send({ project_id: projectId });
await f.send({ events: Array.from({ length: cases }, (_, index) => ({
id: `attempt-${index}`, scores: { product_pass: Number(index !== 0), cleanup_pass: 1 },
})) }, `experiment/${experimentId}/insert`);
await f.transport(`https://api.braintrust.dev/v1/experiment/${experimentId}/summarize`, { method: "GET" });
await f.close();
expect(upstream).toHaveBeenCalledTimes(3);
expect((await f.records()).at(-1)).toMatchObject({ admittedRequests: 3, admittedScores: cases * 2, unresolvedRequests: 0 });
});
it("retains an unresolved allowance when response headers arrive but the body disconnects", async () => {
const f = await fixture(async () => new Response(new ReadableStream({ pull(controller) {
controller.error(new Error("private-disconnect-error"));
} }), { status: 200 }));
await expect(f.send({ project_id: projectId })).rejects.toThrow("unconfirmed");
await f.close();
const rows = await f.records();
expect(rows[2]).toMatchObject({ type: "outcome", outcome: "unconfirmed" });
expect(rows.at(-1)).toMatchObject({ admittedRequests: 1, responses: 0, unresolvedRequests: 1 });
expect(JSON.stringify(rows)).not.toContain("private-disconnect-error");
});
it("keeps a pending response body unresolved until the whole response is read", async () => {
let finish!: () => void;
const f = await fixture(async () => new Response(new ReadableStream({ start(controller) {
controller.enqueue(new TextEncoder().encode('{"ok":'));
finish = () => { controller.enqueue(new TextEncoder().encode('true}')); controller.close(); };
} })));
const pending = f.send({ project_id: projectId });
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
expect((await f.records()).at(-1)).toMatchObject({ type: "admitted" });
await expect(f.send({ project_id: projectId })).rejects.toThrow("unavailable");
finish();
expect(await (await pending).json()).toEqual({ ok: true });
await f.close();
expect((await f.records()).at(-1)).toMatchObject({ responses: 1, unresolvedRequests: 0 });
});
it("refuses excess bytes, scores, foreign project and unqualified endpoints before dispatch", async () => {
const upstream = vi.fn<typeof fetch>(async () => Response.json({}));
const f = await fixture(upstream);
await expect(f.send({ project_id: experimentId })).rejects.toThrow("project mismatch");
await expect(f.send({ project_id: projectId, data: "x".repeat(EVAL_EXPERIMENT_LIMITS.maxTotalBytes) })).rejects.toThrow("byte limit");
await expect(f.send({ events: Array.from({ length: EVAL_EXPERIMENT_LIMITS.maxScores + 1 }, () => ({ scores: { pass: 1 } })) }, `experiment/${experimentId}/insert`))
.rejects.toThrow("allowance exhausted");
await expect(f.send({}, "project")).rejects.toThrow("Unqualified");
expect(upstream).not.toHaveBeenCalled();
await f.close();
expect((await f.records()).at(-1)).toMatchObject({ admittedRequests: 0, admittedBytes: 0, admittedScores: 0 });
});
it("refuses concurrent requests and expired windows without resetting reservations", async () => {
let release!: () => void;
const upstream = vi.fn<typeof fetch>(async () => { await new Promise<void>(resolve => { release = resolve; }); return Response.json({}); });
const f = await fixture(upstream);
const pending = f.send({ project_id: projectId });
await vi.waitFor(() => expect(upstream).toHaveBeenCalledOnce());
await expect(f.send({ project_id: projectId })).rejects.toThrow("unavailable");
release(); await pending;
const now = vi.spyOn(Date, "now").mockReturnValue(f.policy.deadlineAt);
try { await expect(f.send({ project_id: projectId })).rejects.toThrow("unavailable"); }
finally { now.mockRestore(); }
expect(upstream).toHaveBeenCalledOnce();
});