Files
PaperClipAI/tests/runner-e2e/ingestion-usage.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

129 lines
8.5 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { openEvalCostLedger } from "./cost-ledger.js";
import { startEvalOtlpRelay } from "./eval-otlp-relay.js";
import { EVAL_FINALIZATION_LIMITS } from "./eval-finalization-relay.js";
import { openExperimentIngestion } from "./experiment-ingestion.js";
import { collectScheduledIngestion } from "./ingestion-usage.js";
const projectId = "11111111-1111-4111-8111-111111111111";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "scheduled-ingestion-")); roots.push(root);
const input = { ledgerPath: path.join(root, "ledger.jsonl"), campaignId: "campaign", projectId, executionIds: ["fixture.local"] };
const ledger = await openEvalCostLedger(input.ledgerPath, { campaignId: input.campaignId, maxUsd: "1",
deadlineAt: new Date(Date.now() + 60_000).toISOString(), maxRequests: 1, maxConcurrent: 1, maxAttemptsPerExecution: 1 });
try { await ledger.admitAttempt(input.executionIds[0]!, "attempt"); } finally { await ledger.close(); }
const name = createHash("sha256").update("attempt").digest("hex"), files: string[] = [];
for (const kind of ["native", "finalization"] as const) {
const receiptPath = path.join(`${input.ledgerPath}.otlp`, `${name}${kind === "finalization" ? "-finalization" : ""}.jsonl`);
files.push(receiptPath);
const relay = await startEvalOtlpRelay({ ...input, attemptId: "attempt", apiKey: "private-fixture", receiptPath,
deadlineAt: Date.now() + 60_000, ...(kind === "finalization" ? { limits: EVAL_FINALIZATION_LIMITS } : {}),
transport: async () => Response.json({}) });
try {
expect((await fetch(relay.baseUrl + "/v1/traces", { method: "POST", body: "{}",
headers: { authorization: `Bearer ${relay.token}`, "content-type": "application/json" } })).status).toBe(200);
} finally { await relay.close(); }
}
const experiment = await openExperimentIngestion({ ...input, receiptPath: `${input.ledgerPath}.experiment-ingestion.jsonl`,
deadlineAt: Date.now() + 60_000, transport: async () => Response.json({}) });
const body = JSON.stringify({ project_id: projectId });
try { await experiment.transport("https://api.braintrust.dev/v1/experiment", { method: "POST", body }); }
finally { await experiment.close(); }
return { input, files, expectedBytes: 4 + Buffer.byteLength(body) };
}
it("reconciles real durable receipts for all three transport paths without inventing vendor billing", async () => {
const f = await fixture(), usage = await collectScheduledIngestion(f.input);
expect(usage).toMatchObject({ status: "complete", observedAdmittedPayloadBytes: f.expectedBytes,
totalAdmittedPayloadBytes: f.expectedBytes, billedCostUsd: null, billingStatus: "unreconciled", missingExecutions: [] });
expect(usage.receipts.map(row => row.kind)).toEqual(["native", "finalization", "experiment"]);
expect(usage.receipts[0]).toMatchObject({ admittedExpandedSourceBytes: 2, admittedRecords: 0 });
expect(usage.receipts.every(row => "receiptSha256" in row && /^[a-f0-9]{64}$/.test(row.receiptSha256!))).toBe(true);
expect(usage.ledgerHash).toMatch(/^[a-f0-9]{64}$/);
expect(JSON.stringify(usage)).not.toContain("private-fixture");
});
it.each(["missing", "unclosed", "unresolved", "foreign-project", "bad-total", "bad-expanded-total", "missing-record-count", "duplicate-outcome"])(
"retains other measured traffic and an unknown total for a %s receipt", async reason => {
const f = await fixture(), file = f.files[0]!;
if (reason === "missing") await rm(file);
else {
const rows = (await readFile(file, "utf8")).trim().split("\n").map(line => JSON.parse(line));
if (reason === "unclosed") rows.pop();
if (reason === "unresolved") rows[2].outcome = "uncertain";
if (reason === "foreign-project") rows[0].projectId = "22222222-2222-4222-8222-222222222222";
if (reason === "bad-total") rows.at(-1).admittedBytes = 0;
if (reason === "bad-expanded-total") rows.at(-1).admittedExpandedBytes = 0;
if (reason === "missing-record-count") delete rows[1].recordCount;
if (reason === "duplicate-outcome") rows.splice(3, 0, rows[2]);
await writeFile(file, rows.map(row => JSON.stringify(row)).join("\n") + "\n");
}
const usage = await collectScheduledIngestion(f.input);
expect(usage).toMatchObject({ status: "incomplete", totalAdmittedPayloadBytes: null, billedCostUsd: null });
expect(usage.observedAdmittedPayloadBytes).toBeGreaterThan(0);
expect(usage.receipts[0]!.status).not.toBe("complete");
expect(usage.receipts.slice(1).every(row => row.status === "complete")).toBe(true);
});
it("binds the expected receipt set to admitted attempts and retains unstarted selections", async () => {
const f = await fixture();
expect(await collectScheduledIngestion({ ...f.input, executionIds: [...f.input.executionIds, "missing.local"] }))
.toMatchObject({ status: "incomplete", missingExecutions: ["missing.local"], totalAdmittedPayloadBytes: null });
await expect(collectScheduledIngestion({ ...f.input, executionIds: [] })).rejects.toThrow("selection");
await expect(collectScheduledIngestion({ ...f.input, executionIds: ["foreign.local"] })).rejects.toThrow("Foreign ingestion attempt");
await expect(collectScheduledIngestion({ ...f.input, campaignId: "foreign" })).rejects.toThrow("campaign mismatch");
});
it("keeps historical native receipts with the larger policy limits readable", async () => {
const f = await fixture(), file = f.files[0]!;
const rows = (await readFile(file, "utf8")).trim().split("\n").map(line => JSON.parse(line));
rows[0].limits.maxExpandedBytes = 256 * 1024 * 1024;
rows[0].limits.maxRecords = 16_384;
await writeFile(file, rows.map(row => JSON.stringify(row)).join("\n") + "\n");
expect(await collectScheduledIngestion(f.input)).toMatchObject({ status: "complete",
totalAdmittedPayloadBytes: f.expectedBytes, billedCostUsd: null });
});
it.each([8, 14, 15])("validates an experiment receipt with a %i-score policy against the supported ceiling", async maxScores => {
const f = await fixture(), file = `${f.input.ledgerPath}.experiment-ingestion.jsonl`;
const rows = (await readFile(file, "utf8")).trim().split("\n").map(line => JSON.parse(line));
rows[0].limits.maxScores = maxScores;
await writeFile(file, rows.map(row => JSON.stringify(row)).join("\n") + "\n");
const usage = await collectScheduledIngestion(f.input);
expect(usage.status).toBe(maxScores <= 14 ? "complete" : "incomplete");
expect(usage.receipts.at(-1)!.status).toBe(maxScores <= 14 ? "complete" : "unavailable");
});
it("keeps version 1 transport receipts readable without inventing expanded data measurements", async () => {
const f = await fixture(), file = f.files[0]!;
const rows = (await readFile(file, "utf8")).trim().split("\n").map(line => JSON.parse(line));
rows[0].version = 1; delete rows[0].sizing;
delete rows[0].limits.maxExpandedBytes; delete rows[0].limits.maxRecords;
for (const row of rows) {
delete row.expandedBytes; delete row.recordCount;
delete row.admittedExpandedBytes; delete row.admittedRecords;
}
await writeFile(file, rows.map(row => JSON.stringify(row)).join("\n") + "\n");
const usage = await collectScheduledIngestion(f.input);
expect(usage).toMatchObject({ status: "complete", billedCostUsd: null });
expect(usage.receipts[0]).toMatchObject({ admittedPayloadBytes: 2, admittedExpandedSourceBytes: null, admittedRecords: null });
});
it.each(["outcome", "closed"])("retains the verified prefix and digest when the final %s line is truncated", async kind => {
const f = await fixture(), file = f.files[0]!;
const lines = (await readFile(file, "utf8")).trim().split("\n");
const prefix = lines.slice(0, kind === "outcome" ? 2 : 3);
const truncated = prefix.join("\n") + '\n{"type":"' + kind;
await writeFile(file, truncated);
const usage = await collectScheduledIngestion(f.input);
expect(usage).toMatchObject({ status: "incomplete", observedAdmittedPayloadBytes: f.expectedBytes, totalAdmittedPayloadBytes: null });
expect(usage.receipts[0]).toMatchObject({ status: "incomplete", admittedRequests: 1, admittedPayloadBytes: 2,
unresolvedRequests: kind === "outcome" ? 1 : 0, receiptSha256: createHash("sha256").update(truncated).digest("hex") });
});