mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(evals): qualify Runner model receipts with separate operating reconciliation (#17929)
## Thinking Path > - Paperclip must account for paid eval work. > - The maintained Runner workflow distributes one immutable controller manifest. > - Its cost gate currently requires vendor proofs for every operating charge. > - Model-spend qualification must also work under an explicit policy that keeps operating charges separate. > - This PR applies the shared model-readiness policy to the hosted controller. > - It retains every operating hold and still rejects missing model receipts and incomplete workers. ## Linked Issues or Issue Description Refs #17928, #17889. **What happened?** Runner qualification requires full-lifecycle vendor operating proofs even when the operator selects model-request readiness. That blocks the model-token qualification path. **Expected behavior** An explicit `overhead.accountingReadiness: "model_requests"` policy permits absent operating cost proofs. Supplied proofs still validate. Complete final model receipts are required. **Steps to reproduce** Prepare a two-worker hosted manifest with the explicit policy and no vendor cost proofs. Preserve missing-worker allocations, then complete both workers and inspect the private archive summary. A second test returns a provider refusal without terminal usage and requires failure with the full hold. ## What Changed - Apply the shared explicit policy to hosted capacity and observability cost-proof admission. - Preserve pricing windows, model bounds, immutable source/request/runtime identities, trace quotas and every allocation. - Reject zero or unresolved model requests in this policy, even if a request has a provisional bound. - Retain operating amounts and pending reconciliation through campaign and private archive summaries. - Document the separate model-readiness contract. Preserve strict behavior when the field is absent. ## Verification - Regression first failed at the original vendor-bound gate. - All 80 controlled-eval Node checks pass, including source and copied portable runtime checks, archived partial/missing workers, credential exclusion and model receipt failure. - Product readiness prerequisite #17928 has its own eval unit/typecheck and full CI checks. - No paid workflow, credential change or recurring activation is part of this source PR. ## Risks This opt-in policy does not prove a full-lifecycle operating-cost ceiling. The report retains operating holds and pending reconciliation. Default policies retain the existing vendor-proof gate. Source and worker identity checks remain required. A model refusal without final usage now fails readiness even if its reservation has a reviewed bound. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection and code execution. The exact serving model ID and context window are not exposed to 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>
This commit is contained in:
1 parent
f60ac29cf1
commit
35820cf398
9 files changed
+132
-7
No files matched your search
@@ -930,3 +930,25 @@ portable runtime isolation, private archive replay, and complete/partial/empty
|
||||
campaign accounting. They do not establish current vendor prices, future
|
||||
retention charges, live Braintrust attribution or invoice settlement. Keep
|
||||
paid recurrence disabled until these independent gates are qualified.
|
||||
|
||||
|
||||
### Token-spend qualification with separate operating reconciliation
|
||||
|
||||
A policy may explicitly set `overhead.accountingReadiness: "model_requests"`
|
||||
when model-token qualification is the operator's priority. In that mode an
|
||||
absent campaign capacity proof and absent observability cost proof do not block
|
||||
admission. Any supplied proofs must still validate. Current allowance/pricing
|
||||
windows, trace quotas, immutable source/request/runtime bindings, complete
|
||||
selection, per-cell allocations, request limits, no retries and the shared
|
||||
campaign admission cap remain required. Model reservation proofs remain required.
|
||||
|
||||
The final report retains the once-only operating allowance and labels operating
|
||||
reconciliation pending. Missing, failed or canceled model evidence still holds
|
||||
the full cell allocation and fails completion. This policy does not certify a
|
||||
vendor operating-cost bound. Omit the field to retain full-lifecycle proof
|
||||
requirements. Existing manifests and held amounts are not rewritten.
|
||||
|
||||
For this policy version, `operatingReconciliation` is always `pending`; the
|
||||
immutable controller summary has no operating invoice settlement step. Mixed
|
||||
histories retain that warning when any campaign uses the policy. A shared
|
||||
`accountingReadiness` label appears only when every campaign uses it.
|
||||
@@ -170,6 +170,9 @@ export async function summarizeControlledEvidence(bundles, options) {
|
||||
function history(campaigns) {
|
||||
const sum = field => usd(campaigns.reduce((total, campaign) => total + units(campaign[field]), 0n));
|
||||
return { schema: 'paperclip.runner-eval.control-history/v1', costStatus: campaigns.some(c => c.costStatus === 'incomplete') ? 'incomplete' : 'provisional',
|
||||
...(campaigns.length && campaigns.every(c => c.accountingReadiness === 'model_requests')
|
||||
? { accountingReadiness: 'model_requests' } : {}),
|
||||
...(campaigns.some(c => c.operatingReconciliation === 'pending') ? { operatingReconciliation: 'pending' } : {}),
|
||||
knownUsd: sum('knownUsd'), heldUsd: sum('heldUsd'), committedUsd: sum('committedUsd'), operatingHeldUsd: sum('operatingHeldUsd'),
|
||||
incompleteAttempts: campaigns.reduce((total, c) => total + c.incompleteAttempts, 0), overBudget: campaigns.some(c => c.overBudget), campaigns };
|
||||
}
|
||||
|
||||
@@ -787,3 +787,42 @@ test('retained failure codes distinguish native startup, child exit and invalid
|
||||
const artifact = await fixture(t, { missingRoute: true, requests: 0 });
|
||||
assert.equal((await artifact.run()).report.failure, 'artifact_invalid');
|
||||
});
|
||||
|
||||
|
||||
test('model readiness rejects a missing terminal model receipt even when its reservation has a reviewed bound', async t => {
|
||||
const f = await fixture(t, { requests: 1 });
|
||||
f.manifest.overhead.accountingReadiness = 'model_requests';
|
||||
for (const component of f.manifest.overhead.components) delete component.provisionalBound;
|
||||
await f.save();
|
||||
const result = await f.run(0, async () => Response.json({ error: 'synthetic refusal' }, { status: 429 }));
|
||||
assert.equal(result.report.failure, 'request_accounting_unbounded');
|
||||
const summary = await summarizeControlledCampaign(f.manifest);
|
||||
assert.equal(summary.incompleteAttempts, 1);
|
||||
assert.equal(summary.heldUsd, '0.014000000');
|
||||
assert.equal(summary.operatingReconciliation, 'pending');
|
||||
});
|
||||
|
||||
|
||||
test('model readiness rejects zero model requests and keeps the full allocation', async t => {
|
||||
const f = await fixture(t, { requests: 0 });
|
||||
f.manifest.overhead.accountingReadiness = 'model_requests';
|
||||
for (const component of f.manifest.overhead.components) delete component.provisionalBound;
|
||||
await f.save();
|
||||
const result = await f.run();
|
||||
assert.equal(result.report.failure, 'request_accounting_unbounded');
|
||||
assert.equal(result.report.accounting.requests.length, 0);
|
||||
const summary = await summarizeControlledCampaign(f.manifest);
|
||||
assert.equal(summary.incompleteAttempts, 1); assert.equal(summary.heldUsd, '0.014000000');
|
||||
});
|
||||
|
||||
test('mixed-policy private histories keep the pending operating reconciliation warning', async t => {
|
||||
const model = await fixture(t, { requests: 1 }), strict = await fixture(t, { requests: 1 });
|
||||
model.manifest.campaignId = 'model-readiness-history';
|
||||
model.manifest.overhead.accountingReadiness = 'model_requests';
|
||||
for (const component of model.manifest.overhead.components) delete component.provisionalBound;
|
||||
await model.save(); await model.run(); await strict.run();
|
||||
const bundles = await Promise.all([model, strict].map(f => packControlledAttempt({ manifest: f.manifest, attemptId: 'attempt-0', environment: f.environment })));
|
||||
const history = await summarizeControlledEvidence(bundles);
|
||||
assert.equal(history.incompleteAttempts, 0); assert.equal(history.operatingHeldUsd, '0.008000000');
|
||||
assert.equal(history.accountingReadiness, undefined); assert.equal(history.operatingReconciliation, 'pending');
|
||||
});
|
||||
@@ -23,7 +23,9 @@ export function prepareHostedManifest({ catalog, catalogSha256, prepared, policy
|
||||
|| !Number.isSafeInteger(policy.maxCells) || policy.maxCells < 1 || policy.maxCells > 512
|
||||
|| !Array.isArray(policy.allocations) || !policy.allocations.length) throw new Error('A bounded hosted policy with tracing is required');
|
||||
if (!/^[a-f0-9]{64}$/.test(policy.controllerLockSha256 ?? '')) throw new Error('Invalid hosted controller lock binding');
|
||||
costRuntime.reservationBound('operating_allowance', policy.capacityBound, hostedPolicyConfiguration(policy));
|
||||
if (policy.overhead?.accountingReadiness !== 'model_requests' || policy.capacityBound !== undefined) {
|
||||
costRuntime.reservationBound('operating_allowance', policy.capacityBound, hostedPolicyConfiguration(policy));
|
||||
}
|
||||
fields(policy.observability, ['projectId', 'capture', 'nativeLimits']);
|
||||
const runId = environment.GITHUB_RUN_ID, paths = hostedPaths(runId);
|
||||
if (prepared.schema !== 'paperclip.runner-eval.prepared-requests/v1' || prepared.githubRunId !== runId || prepared.githubRunAttempt !== 1
|
||||
@@ -65,7 +67,7 @@ export function prepareHostedManifest({ catalog, catalogSha256, prepared, policy
|
||||
models: policy.models, overhead: policy.overhead, observability: { ...policy.observability },
|
||||
sourceRevision: prepared.sourceRevision, definitionsRevision: prepared.definitionsRevision, ...artifacts,
|
||||
outputDirectory: paths.evidence, hosted: { repository: environment.GITHUB_REPOSITORY, runId, runAttempt: 1, workflowRevision: environment.GITHUB_SHA, controllerLockSha256: policy.controllerLockSha256, policySha256: sha256(canonical(policy)) }, attempts };
|
||||
manifest.observability.provisionalBound = { ...policy.capacityBound, configurationSha256: sha256(canonical(observabilityBoundConfiguration(manifest))) };
|
||||
if (policy.capacityBound !== undefined) manifest.observability.provisionalBound = { ...policy.capacityBound, configurationSha256: sha256(canonical(observabilityBoundConfiguration(manifest))) };
|
||||
const validated = validateManifest(manifest, now);
|
||||
assertHostedAdmission(manifest, attempts[0], { ...environment, PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: attempts[0].executionId });
|
||||
return validated;
|
||||
|
||||
@@ -119,3 +119,52 @@ test('hosted CLI reports safe admission reasons without echoing private paths or
|
||||
assert.match(result.stderr, /A required hosted input file is missing/);
|
||||
assert.doesNotMatch(result.stderr + result.stdout, /SENSITIVE_SENTINEL/);
|
||||
});
|
||||
|
||||
|
||||
test('model-readiness policy retains operating holds without requiring vendor cost proofs', async t => {
|
||||
const c = await campaign(t);
|
||||
c.input.policy.overhead.accountingReadiness = 'model_requests';
|
||||
for (const component of c.input.policy.overhead.components) delete component.provisionalBound;
|
||||
delete c.input.policy.capacityBound;
|
||||
const result = c.build();
|
||||
assert.equal(result.manifest.overhead.accountingReadiness, 'model_requests');
|
||||
assert.equal(result.manifest.observability.provisionalBound, undefined);
|
||||
Object.assign(c.f.manifest, result.manifest); await c.f.save();
|
||||
const missing = await summarizeControlledManifest(result.manifest, []);
|
||||
assert.equal(missing.incompleteAttempts, 2);
|
||||
assert.equal(missing.operatingHeldUsd, '0.004000000');
|
||||
Object.assign(c.f.environment, c.environment, { PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-0' });
|
||||
assert.equal((await c.f.run(0, () => success())).code, 0);
|
||||
const first = await packControlledAttempt({ manifest: result.manifest, attemptId: 'attempt-0', environment: c.f.environment });
|
||||
Object.assign(c.f.environment, { PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-1' });
|
||||
assert.equal((await c.f.run(1, () => success())).code, 0);
|
||||
const second = await packControlledAttempt({ manifest: result.manifest, attemptId: 'attempt-1', environment: c.f.environment });
|
||||
const complete = await summarizeControlledManifest(result.manifest, [first, second]);
|
||||
assert.equal(complete.incompleteAttempts, 0);
|
||||
assert.equal(complete.operatingHeldUsd, '0.004000000');
|
||||
assert.equal(complete.accountingReadiness, 'model_requests');
|
||||
assert.equal(complete.operatingReconciliation, 'pending');
|
||||
delete c.input.policy.overhead.accountingReadiness;
|
||||
assert.throws(() => c.build(), /cost bound|operating bound/);
|
||||
});
|
||||
|
||||
|
||||
test('model readiness still validates supplied operating and capacity proofs', async t => {
|
||||
const c = await campaign(t), policy = c.input.policy;
|
||||
policy.overhead.accountingReadiness = 'model_requests';
|
||||
for (const component of policy.overhead.components) {
|
||||
component.provisionalBound.configurationSha256 = sha256(canonical({ accountingReadiness: 'model_requests',
|
||||
reference: policy.overhead.reference, runner: policy.overhead.runner, verifiedAt: policy.overhead.verifiedAt,
|
||||
expiresAt: policy.overhead.expiresAt, component: { component: component.component, maxUsd: component.maxUsd, reference: component.reference } }));
|
||||
}
|
||||
policy.capacityBound.configurationSha256 = sha256(canonical(hostedPolicyConfiguration(policy)));
|
||||
assert.equal(c.build().manifest.overhead.accountingReadiness, 'model_requests');
|
||||
const mismatch = structuredClone(c.input); mismatch.policy.capacityBound.evidenceSha256 = '0'.repeat(64);
|
||||
assert.throws(() => prepareHostedManifest(mismatch), /Observability capacity requires its operating bound evidence/);
|
||||
const stale = structuredClone(c.input); stale.policy.capacityBound.configurationSha256 = '0'.repeat(64);
|
||||
assert.throws(() => prepareHostedManifest(stale), /Reviewed cost bound configuration changed/);
|
||||
const absent = structuredClone(c.input);
|
||||
for (const component of absent.policy.overhead.components) delete component.provisionalBound;
|
||||
absent.policy.capacityBound.configurationSha256 = sha256(canonical(hostedPolicyConfiguration(absent.policy)));
|
||||
assert.throws(() => prepareHostedManifest(absent), /Observability capacity requires its operating bound evidence/);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export function validateManifest(value, now = Date.now(), { admission = true } =
|
||||
validateHostedIdentity(value);
|
||||
const overhead = costRuntime.validateOperatingOverhead(value.overhead, value.maxUsd);
|
||||
if (admission && Date.parse(value.overhead.verifiedAt) > now || Date.parse(value.overhead.expiresAt) <= deadline
|
||||
|| value.overhead.components.some(component => !component.provisionalBound)) throw new Error('Controller requires current full-lifecycle operating bounds');
|
||||
|| value.overhead.accountingReadiness !== 'model_requests' && value.overhead.components.some(component => !component.provisionalBound)) throw new Error('Controller requires current full-lifecycle operating bounds');
|
||||
if (!Array.isArray(value.models) || !value.models.length || value.models.length > 16
|
||||
|| !Array.isArray(value.attempts) || !value.attempts.length || value.attempts.length > 1000) throw new Error('Invalid controlled campaign selection');
|
||||
const prices = new Map();
|
||||
@@ -141,7 +141,13 @@ function validateObservability(manifest) {
|
||||
|| Object.keys(policy.capture).length !== 4 || Object.values(policy.capture).some(value => typeof value !== 'boolean')
|
||||
|| canonical(Object.keys(policy.nativeLimits ?? {}).sort()) !== canonical(Object.keys(costRuntime.EVAL_OTLP_LIMITS).sort())
|
||||
|| Object.entries(costRuntime.EVAL_OTLP_LIMITS).some(([key, max]) => !Number.isSafeInteger(policy.nativeLimits[key]) || policy.nativeLimits[key] < 1 || policy.nativeLimits[key] > max)) throw new Error('Invalid controller observability allocation');
|
||||
if (manifest.overhead.accountingReadiness === 'model_requests' && policy.provisionalBound === undefined) return;
|
||||
const operating = manifest.overhead.components.find(item => item.component === 'observability');
|
||||
if (!operating || policy.provisionalBound?.evidenceSha256 !== operating.provisionalBound?.evidenceSha256) throw new Error('Observability capacity requires its operating bound evidence');
|
||||
costRuntime.reservationBound('operating_allowance', policy.provisionalBound, observabilityBoundConfiguration(manifest));
|
||||
}
|
||||
|
||||
|
||||
export function modelReadinessDefect(manifest, snapshot) {
|
||||
return manifest.overhead.accountingReadiness === 'model_requests' && (!snapshot.requests.length || snapshot.unknownRequests > 0);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ test('copied controller runs without source dependencies, retains native evidenc
|
||||
const hosted = await exec(process.execPath, ['--test', '--test-reporter=tap', join(source, 'hosted.test.mjs')], {
|
||||
cwd: root, env: { ...environment, PAPERCLIP_CONTROLLER_TEST_RUNTIME: join(directory, 'controlled-evals') }, timeout: 30000, maxBuffer: 1_048_576,
|
||||
});
|
||||
assert.match(hosted.stdout, /# pass 4\b/);
|
||||
assert.match(hosted.stdout, /# pass 6\b/);
|
||||
|
||||
|
||||
const cli = await exec(process.execPath, [join(directory, 'controlled-evals/archive.mjs'), 'report-attempts', join(root, 'empty-attempts')], {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEntrypoint } from './entrypoint.mjs';
|
||||
import { join } from 'node:path';
|
||||
import { canonical, costRuntime, readJson, requestBindingSha256, sha256, units, usd, validateManifest } from './manifest.mjs';
|
||||
import { modelReadinessDefect, canonical, costRuntime, readJson, requestBindingSha256, sha256, units, usd, validateManifest } from './manifest.mjs';
|
||||
import { inspectControlledObservability } from './observability.mjs';
|
||||
import { attemptDirectory } from './run.mjs';
|
||||
import { limitFor, readRegularFile } from './evidence-files.mjs';
|
||||
@@ -79,7 +79,8 @@ export async function summarizeControlledCampaign(value, { filesByAttempt } = {}
|
||||
campaignId: manifest.campaignId, executionId: allocation.executionId, attemptId: allocation.attemptId, descriptorSha256: sha256(descriptorBytes) })
|
||||
|| artifact.attemptId !== allocation.attemptId || artifact.requestedModel !== allocation.model
|
||||
|| snapshot.inflight || snapshot.conflictingRequests || snapshot.overBudget
|
||||
|| snapshot.unknownRequests !== snapshot.boundedUnresolvedRequests) throw new Error('Incomplete controller evidence');
|
||||
|| snapshot.unknownRequests !== snapshot.boundedUnresolvedRequests
|
||||
|| modelReadinessDefect(manifest, snapshot)) throw new Error('Incomplete controller evidence');
|
||||
verified = true;
|
||||
} catch {}
|
||||
const cost = snapshot ? units(snapshot.knownUsd) : 0n;
|
||||
@@ -92,6 +93,7 @@ export async function summarizeControlledCampaign(value, { filesByAttempt } = {}
|
||||
unknownRequests: snapshot?.unknownRequests ?? null, boundedUnresolvedRequests: snapshot?.boundedUnresolvedRequests ?? null });
|
||||
}
|
||||
return { schema: 'paperclip.runner-eval.control-campaign/v1', campaignId: manifest.campaignId, manifestSha256,
|
||||
...(manifest.overhead.accountingReadiness ? { accountingReadiness: manifest.overhead.accountingReadiness, operatingReconciliation: 'pending' } : {}),
|
||||
limitUsd: manifest.maxUsd, allocatedUsd, operatingHeldUsd: overheadUsd, knownUsd: usd(known), heldUsd: usd(committed - known),
|
||||
committedUsd: usd(committed), overBudget: committed > units(manifest.maxUsd), incompleteAttempts: attempts.filter(attempt => attempt.status === 'incomplete').length,
|
||||
costStatus: attempts.some(attempt => attempt.status === 'incomplete') || committed > units(manifest.maxUsd) ? 'incomplete' : 'provisional', attempts };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { modelReadinessDefect } from './manifest.mjs';
|
||||
import { isEntrypoint } from './entrypoint.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
@@ -185,7 +186,8 @@ registerHooks({ resolve(specifier, context, nextResolve) {
|
||||
catch { cleanupVerified = false; failure ??= 'executable_cleanup_unverified'; }
|
||||
}
|
||||
const snapshot = ledger.snapshot();
|
||||
if (snapshot.overBudget || snapshot.conflictingRequests || snapshot.inflight || snapshot.unknownRequests !== snapshot.boundedUnresolvedRequests) failure ??= 'request_accounting_unbounded';
|
||||
if (snapshot.overBudget || snapshot.conflictingRequests || snapshot.inflight || snapshot.unknownRequests !== snapshot.boundedUnresolvedRequests
|
||||
|| modelReadinessDefect(manifest, snapshot)) failure ??= 'request_accounting_unbounded';
|
||||
const receiptHashes = {};
|
||||
try {
|
||||
for (const request of snapshot.requests) receiptHashes[request.requestId] = sha256(await readRegularFile(join(root, 'receipts', `${request.requestId}.json`), limitFor(`receipts/${request.requestId}.json`)));
|
||||
|
||||
Reference in new issue
Block a user