@tea-agent/loop-agent 0.44.0-next.8 → 0.44.0-next.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/dist/application/evaluation/budget.js +21 -0
- package/dist/application/evaluation/corpus-hash.js +10 -15
- package/dist/application/evaluation/corpus.js +2 -1
- package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
- package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
- package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
- package/dist/application/evaluation/frontend-pair-registration.js +143 -0
- package/dist/application/evaluation/frontend-paired-summary.js +150 -0
- package/dist/application/evaluation/frontend-run-observation.js +144 -0
- package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
- package/dist/application/evaluation/types.js +45 -12
- package/dist/application/task-lifecycle/observe.js +26 -1
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +11 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/task-advance.js +3 -5
- package/dist/commands/task-source-prepare.js +13 -1
- package/dist/executors/dag-pi/tools/design-terminal-tools.js +17 -7
- package/dist/executors/dag-pi/tools/review-terminal-tools.js +17 -7
- package/dist/executors/dag-pi-executor.js +2048 -125
- package/dist/executors/pi-executor.js +6 -1
- package/dist/executors/pi-sdk-executor.js +76 -11
- package/dist/executors/shell-executor.js +88 -12
- package/dist/infrastructure/console/operation-store.js +2 -0
- package/dist/infrastructure/evaluation/corpus-store.js +37 -12
- package/dist/shared/operator/capabilities.js +10 -5
- package/dist/task/config-types.js +22 -9
- package/dist/task/contract/project.js +13 -0
- package/dist/task/contract/schema.js +2 -8
- package/dist/task/source-prepare/parse-intent.js +124 -5
- package/dist/task/source-prepare/prepare.js +6 -1
- package/dist/task/source-prepare/semantic-intake.js +17 -2
- package/dist/task/source-prepare/source-execution.js +65 -0
- package/dist/task/source-prepare/source-fidelity-pi.js +21 -8
- package/dist/task/source-prepare/source-provider-budget.js +290 -0
- package/dist/worker/console/operator-actions.js +0 -1
- package/dist/worker/console/operator-user-error.js +1 -1
- package/dist/worker/console/prd-intake-bridge.js +5 -15
- package/dist/workflows/dag/budget-enforcement.js +31 -2
- package/dist/workflows/dag/frontend-closeout.js +3 -1
- package/dist/workflows/dag/frontend-contract-facts.js +11 -8
- package/dist/workflows/dag/frontend-design-policy.js +6 -6
- package/dist/workflows/dag/frontend-durable-tools.js +90 -70
- package/dist/workflows/dag/frontend-implementation-contract.js +146 -59
- package/dist/workflows/dag/frontend-plan-canary.js +66 -16
- package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
- package/dist/workflows/dag/frontend-plan-progress.js +92 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +11 -10
- package/dist/workflows/dag/frontend-recovery-run.js +51 -46
- package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
- package/dist/workflows/dag/frontend-review-context.js +29 -1
- package/dist/workflows/dag/frontend-review-scopes.js +92 -2
- package/dist/workflows/dag/frontend-risk.js +8 -3
- package/dist/workflows/dag/frontend-root-observation.js +261 -0
- package/dist/workflows/dag/frontend-session-budget.js +52 -39
- package/dist/workflows/dag/frontend-session-context.js +10 -0
- package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
- package/dist/workflows/dag/frontend-typed-event-store.js +11 -6
- package/dist/workflows/dag/frontend-verification-trace.js +6 -0
- package/dist/workflows/dag/frontend-writer-admission.js +2 -1
- package/dist/workflows/dag/init-hybrid.js +108 -37
- package/dist/workflows/dag/node-execution.js +11 -11
- package/dist/workflows/dag/rerun-feedback.js +35 -4
- package/dist/workflows/dag/rerun-task.js +62 -67
- package/dist/workflows/dag/runner.js +58 -12
- package/dist/workflows/dag/types.js +15 -8
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/templates/backend-test-dag.json +4 -4
- package/docs/templates/frontend-implementation-contract.schema.json +31 -11
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +8 -11
- package/skills/frontend-design-review/references/review-checklist.md +3 -4
- package/skills/frontend-review/SKILL.md +5 -1
- package/skills/loop-agent/references/command-reference.md +8 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { readFileBoundarySafe } from '../../shared/path-safety.js';
|
|
5
|
+
import { campaignPlanSchema } from './types.js';
|
|
6
|
+
import { computeCampaignPlanHash } from './campaign-hash.js';
|
|
7
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
8
|
+
const count = z.number().int().nonnegative().safe();
|
|
9
|
+
const ref = z.object({ path: z.string().min(1), sha256: digest }).strict();
|
|
10
|
+
const bindingSchema = z.object({ campaignId: z.string().min(1), cellId: z.string().min(1), taskRef: z.string().min(1), planHash: z.string().regex(/^sha256:[a-f0-9]{64}$/) }).strict();
|
|
11
|
+
const contractSchema = z.object({ schemaVersion: z.literal(3), evidenceBinding: bindingSchema, upstreamKind: z.enum(['meter', 'provider']), model: z.string().min(1), upstream: z.string().url(), maxTokens: count.positive(), maxRequests: count.positive(), maxWallMs: count.positive(), requestTokenCeiling: count.positive(), payloadByteLimit: count.positive().optional(), maxOutputTokens: count.positive().optional() }).strict();
|
|
12
|
+
const stateSchema = z.object({ schemaVersion: z.literal(1), id: z.string().uuid(), contract: digest, startedAt: count.nullable(), requests: count, tokens: count, inflight: z.null(), stopped: z.string().nullable(), actualModels: z.array(z.string()) }).strict();
|
|
13
|
+
const rowSchema = z.object({ schemaVersion: z.literal(3), evidenceKind: z.literal('gateway-fetch-attempt'), meterId: z.string().uuid(), contract: digest, evidenceBinding: bindingSchema, upstreamKind: z.enum(['meter', 'provider']), requestId: z.string().uuid(), parentRequestId: z.string().uuid().nullable(), request: count.positive(), startedAt: count, finishedAt: count, httpStatus: z.number().int().min(100).max(599).nullable(), totalTokens: count.nullable(), responseUsage: z.record(count), actualModels: z.array(z.string()), error: z.string().nullable(), stopped: z.string().nullable(), wireRequest: z.object({ model: z.string().min(1), payloadSha256: digest, bytes: count, stream: z.boolean(), parameterCoverage: z.literal('selected-explicit-parameters'), explicitParameters: z.record(z.unknown()) }).strict() }).strict();
|
|
14
|
+
/** A single meter's frozen observations, not a task-wide request count or a
|
|
15
|
+
* billing result. The caller must bind expected identity and artifact hashes
|
|
16
|
+
* to its preregistered protocol, then validate topology and run coverage. */
|
|
17
|
+
export async function readFrontendGatewayEvidence(input) {
|
|
18
|
+
const expected = bindingSchema.parse(input.expectedBinding);
|
|
19
|
+
const snapshots = [];
|
|
20
|
+
const read = async (raw) => {
|
|
21
|
+
const value = ref.parse(raw), target = path.resolve(input.repoRoot, value.path);
|
|
22
|
+
const bytes = await readFileBoundarySafe({ boundaryRoot: input.repoRoot, targetPath: target, label: 'gateway evidence' });
|
|
23
|
+
if (createHash('sha256').update(bytes).digest('hex') !== value.sha256)
|
|
24
|
+
throw Error('gateway evidence hash mismatch');
|
|
25
|
+
snapshots.push({ target, bytes });
|
|
26
|
+
return bytes.toString('utf8');
|
|
27
|
+
};
|
|
28
|
+
const contract = contractSchema.parse(JSON.parse(await read(input.contract)));
|
|
29
|
+
const state = stateSchema.parse(JSON.parse(await read(input.state)));
|
|
30
|
+
const text = await read(input.requests);
|
|
31
|
+
const rows = text.trim() ? text.trim().split('\n').map(line => rowSchema.parse(JSON.parse(line))) : [];
|
|
32
|
+
const sameBinding = (value) => JSON.stringify(value) === JSON.stringify(expected);
|
|
33
|
+
if (!sameBinding(contract.evidenceBinding) || state.contract !== input.contract.sha256)
|
|
34
|
+
throw Error('gateway contract identity mismatch');
|
|
35
|
+
if (state.requests !== rows.length || state.requests > contract.maxRequests)
|
|
36
|
+
throw Error('gateway request ledger mismatch');
|
|
37
|
+
if (state.startedAt !== (rows[0]?.startedAt ?? null))
|
|
38
|
+
throw Error('gateway start time mismatch');
|
|
39
|
+
const seen = new Set(), models = new Set();
|
|
40
|
+
let accountedTokens = 0, knownTokens = 0, unknownUsageAttempts = 0, previousFinished = 0;
|
|
41
|
+
for (const [index, row] of rows.entries()) {
|
|
42
|
+
if (row.meterId !== state.id || row.contract !== state.contract || !sameBinding(row.evidenceBinding) || row.upstreamKind !== contract.upstreamKind || row.wireRequest.model !== contract.model)
|
|
43
|
+
throw Error('gateway row identity mismatch');
|
|
44
|
+
if (row.request !== index + 1 || seen.has(row.requestId))
|
|
45
|
+
throw Error('gateway duplicate or missing request');
|
|
46
|
+
if (row.finishedAt < row.startedAt || row.startedAt < previousFinished)
|
|
47
|
+
throw Error('gateway request timing mismatch');
|
|
48
|
+
if (row.totalTokens !== null && row.responseUsage.total_tokens !== row.totalTokens || row.totalTokens === null && row.responseUsage.total_tokens !== undefined)
|
|
49
|
+
throw Error('gateway reported usage mismatch');
|
|
50
|
+
seen.add(row.requestId);
|
|
51
|
+
previousFinished = row.finishedAt;
|
|
52
|
+
for (const model of row.actualModels)
|
|
53
|
+
models.add(model);
|
|
54
|
+
if (row.totalTokens === null)
|
|
55
|
+
unknownUsageAttempts++;
|
|
56
|
+
else
|
|
57
|
+
knownTokens += row.totalTokens;
|
|
58
|
+
accountedTokens += row.totalTokens ?? contract.requestTokenCeiling;
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isSafeInteger(accountedTokens) || state.tokens !== accountedTokens)
|
|
61
|
+
throw Error('gateway token ledger mismatch');
|
|
62
|
+
if (unknownUsageAttempts && !state.stopped)
|
|
63
|
+
throw Error('gateway unknown usage without stop');
|
|
64
|
+
if (JSON.stringify([...models].sort()) !== JSON.stringify([...new Set(state.actualModels)].sort()))
|
|
65
|
+
throw Error('gateway response model ledger mismatch');
|
|
66
|
+
for (const snapshot of snapshots) {
|
|
67
|
+
const latest = await readFileBoundarySafe({ boundaryRoot: input.repoRoot, targetPath: snapshot.target, label: 'gateway evidence recheck' });
|
|
68
|
+
if (!latest.equals(snapshot.bytes))
|
|
69
|
+
throw Error('gateway evidence changed during collection');
|
|
70
|
+
}
|
|
71
|
+
return { evidenceKind: 'single-meter-observations', meterId: state.id, upstreamKind: contract.upstreamKind, upstream: contract.upstream, binding: expected,
|
|
72
|
+
observedFetchAttempts: rows.length, accountedTokens, reportedTokens: unknownUsageAttempts ? null : knownTokens, unknownUsageAttempts,
|
|
73
|
+
stopped: state.stopped, rows };
|
|
74
|
+
}
|
|
75
|
+
const topologySchema = z.object({ schemaVersion: z.literal(2), kind: z.literal('frontend-gateway-topology'), campaignId: z.string().min(1), registeredAt: z.string().datetime(), meters: z.array(z.object({ meterId: z.string().uuid(), role: z.enum(['cell', 'shared']), endpoint: z.string().url(), contract: ref, binding: bindingSchema, upstreamMeterId: z.string().uuid().nullable() }).strict()).min(1) }).strict();
|
|
76
|
+
/** Plan precedes gateway topology, which precedes pair registration. Gateway
|
|
77
|
+
* contracts reference planHash, never the registration that contains them. */
|
|
78
|
+
export async function assertFrontendGatewayTopology(input) {
|
|
79
|
+
const plan = campaignPlanSchema.parse(input.plan);
|
|
80
|
+
if (computeCampaignPlanHash(plan.campaignId, plan.cells) !== plan.planHash)
|
|
81
|
+
throw Error('gateway plan hash mismatch');
|
|
82
|
+
const snapshots = [];
|
|
83
|
+
const read = async (raw) => {
|
|
84
|
+
const value = ref.parse(raw), target = path.resolve(input.repoRoot, value.path);
|
|
85
|
+
const bytes = await readFileBoundarySafe({ boundaryRoot: input.repoRoot, targetPath: target, label: 'registered gateway topology' });
|
|
86
|
+
if (createHash('sha256').update(bytes).digest('hex') !== value.sha256)
|
|
87
|
+
throw Error('gateway topology hash mismatch');
|
|
88
|
+
snapshots.push({ target, bytes });
|
|
89
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
90
|
+
};
|
|
91
|
+
const topology = topologySchema.parse(await read(input.topology));
|
|
92
|
+
const registeredAt = Date.parse(z.string().datetime().parse(input.registrationCreatedAt));
|
|
93
|
+
if (topology.campaignId !== plan.campaignId || Date.parse(topology.registeredAt) < Date.parse(plan.createdAt) || Date.parse(topology.registeredAt) > registeredAt)
|
|
94
|
+
throw Error('gateway topology registration order mismatch');
|
|
95
|
+
const meters = new Map(topology.meters.map(m => [m.meterId, m])), endpoints = new Set(topology.meters.map(m => m.endpoint));
|
|
96
|
+
if (meters.size !== topology.meters.length || endpoints.size !== meters.size)
|
|
97
|
+
throw Error('gateway topology duplicates');
|
|
98
|
+
const cells = plan.cells.filter(cell => cell.executionRole === 'candidate_run'), matched = new Set();
|
|
99
|
+
for (const meter of meters.values()) {
|
|
100
|
+
if (meter.binding.campaignId !== plan.campaignId || meter.binding.planHash !== plan.planHash || new URL(meter.endpoint).origin !== meter.endpoint)
|
|
101
|
+
throw Error('gateway topology plan binding mismatch');
|
|
102
|
+
const contract = contractSchema.parse(await read(meter.contract));
|
|
103
|
+
if (JSON.stringify(contract.evidenceBinding) !== JSON.stringify(meter.binding))
|
|
104
|
+
throw Error('gateway contract binding mismatch');
|
|
105
|
+
const upstream = new URL(contract.upstream);
|
|
106
|
+
if (meter.upstreamMeterId !== null) {
|
|
107
|
+
if (contract.upstreamKind !== 'meter' || upstream.origin !== meters.get(meter.upstreamMeterId)?.endpoint || !['/chat/completions', '/v1/chat/completions'].includes(upstream.pathname) || upstream.search || upstream.hash || upstream.username || upstream.password)
|
|
108
|
+
throw Error('gateway upstream mismatch');
|
|
109
|
+
}
|
|
110
|
+
else if (contract.upstreamKind !== 'provider' || endpoints.has(upstream.origin))
|
|
111
|
+
throw Error('gateway provider edge mislabeled');
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
let next = meter.meterId;
|
|
114
|
+
while (next !== null) {
|
|
115
|
+
if (seen.has(next) || !meters.has(next))
|
|
116
|
+
throw Error('gateway topology cyclic or incomplete');
|
|
117
|
+
seen.add(next);
|
|
118
|
+
next = meters.get(next).upstreamMeterId;
|
|
119
|
+
}
|
|
120
|
+
if (meter.role === 'cell') {
|
|
121
|
+
const candidates = cells.filter(cell => cell.cellId === meter.binding.cellId && cell.taskRef === meter.binding.taskRef);
|
|
122
|
+
if (candidates.length !== 1 || matched.has(meter.binding.cellId))
|
|
123
|
+
throw Error('gateway topology cell mismatch');
|
|
124
|
+
matched.add(meter.binding.cellId);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (matched.size !== cells.length)
|
|
128
|
+
throw Error('gateway topology does not cover planned cells');
|
|
129
|
+
for (const snapshot of snapshots)
|
|
130
|
+
if (!(await readFileBoundarySafe({ boundaryRoot: input.repoRoot, targetPath: snapshot.target, label: 'gateway admission recheck' })).equals(snapshot.bytes))
|
|
131
|
+
throw Error('gateway admission evidence changed');
|
|
132
|
+
return { topologySha256: input.topology.sha256, cellIds: [...matched] };
|
|
133
|
+
}
|
|
134
|
+
/** Verify the supplied snapshot graph. This does not establish that a runtime
|
|
135
|
+
* used only these gateways, nor that the snapshots cover its whole lifetime. */
|
|
136
|
+
export async function readFrontendGatewayGraph(input) {
|
|
137
|
+
const topologyRef = ref.parse(input.topology), protocol = z.string().regex(/^sha256:[a-f0-9]{64}$/).parse(input.expectedPlanHash);
|
|
138
|
+
const target = path.resolve(input.repoRoot, topologyRef.path);
|
|
139
|
+
const bytes = await readFileBoundarySafe({ boundaryRoot: input.repoRoot, targetPath: target, label: 'gateway topology' });
|
|
140
|
+
if (createHash('sha256').update(bytes).digest('hex') !== topologyRef.sha256)
|
|
141
|
+
throw Error('gateway topology hash mismatch');
|
|
142
|
+
const topology = topologySchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
143
|
+
const declared = new Map(topology.meters.map(m => [m.meterId, m]));
|
|
144
|
+
const supplied = new Map(input.evidence.map(m => [m.meterId, m]));
|
|
145
|
+
if (declared.size !== topology.meters.length || supplied.size !== input.evidence.length || declared.size !== supplied.size || [...supplied.keys()].some(id => !declared.has(id)))
|
|
146
|
+
throw Error('gateway topology meter set mismatch');
|
|
147
|
+
const endpoints = new Set();
|
|
148
|
+
for (const meter of declared.values()) {
|
|
149
|
+
if (new URL(meter.endpoint).origin !== meter.endpoint || endpoints.has(meter.endpoint))
|
|
150
|
+
throw Error('gateway endpoint invalid or duplicated');
|
|
151
|
+
endpoints.add(meter.endpoint);
|
|
152
|
+
if (meter.binding.campaignId !== topology.campaignId || meter.binding.planHash !== protocol)
|
|
153
|
+
throw Error('gateway topology protocol mismatch');
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
let next = meter.meterId;
|
|
156
|
+
while (next !== null) {
|
|
157
|
+
if (seen.has(next))
|
|
158
|
+
throw Error('gateway topology cycle');
|
|
159
|
+
seen.add(next);
|
|
160
|
+
const item = declared.get(next);
|
|
161
|
+
if (!item)
|
|
162
|
+
throw Error('gateway topology upstream missing');
|
|
163
|
+
next = item.upstreamMeterId;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const snapshots = [];
|
|
167
|
+
for (const meter of declared.values()) {
|
|
168
|
+
const evidence = supplied.get(meter.meterId);
|
|
169
|
+
const snapshot = await readFrontendGatewayEvidence({ repoRoot: input.repoRoot, expectedBinding: meter.binding, contract: meter.contract, state: evidence.state, requests: evidence.requests });
|
|
170
|
+
if (snapshot.meterId !== meter.meterId)
|
|
171
|
+
throw Error('gateway topology meter identity mismatch');
|
|
172
|
+
const upstream = new URL(snapshot.upstream);
|
|
173
|
+
if (meter.upstreamMeterId !== null) {
|
|
174
|
+
if (snapshot.upstreamKind !== 'meter' || upstream.origin !== declared.get(meter.upstreamMeterId).endpoint || !['/chat/completions', '/v1/chat/completions'].includes(upstream.pathname) || upstream.search || upstream.hash || upstream.username || upstream.password)
|
|
175
|
+
throw Error('gateway topology upstream endpoint mismatch');
|
|
176
|
+
}
|
|
177
|
+
else if (snapshot.upstreamKind !== 'provider' || endpoints.has(upstream.origin))
|
|
178
|
+
throw Error('gateway provider edge mislabeled');
|
|
179
|
+
if (snapshot.rows.some(row => Date.parse(topology.registeredAt) >= row.startedAt))
|
|
180
|
+
throw Error('gateway topology registered after dispatch');
|
|
181
|
+
snapshots.push(snapshot);
|
|
182
|
+
}
|
|
183
|
+
const rows = new Map();
|
|
184
|
+
for (const row of snapshots.flatMap(s => s.rows)) {
|
|
185
|
+
if (rows.has(row.requestId))
|
|
186
|
+
throw Error('gateway request ID reused across meters');
|
|
187
|
+
rows.set(row.requestId, row);
|
|
188
|
+
}
|
|
189
|
+
const children = new Map();
|
|
190
|
+
for (const row of rows.values()) {
|
|
191
|
+
if (row.parentRequestId === null) {
|
|
192
|
+
if (declared.get(row.meterId).role !== 'cell')
|
|
193
|
+
throw Error('shared gateway request lacks originating cell');
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const parent = rows.get(row.parentRequestId);
|
|
197
|
+
if (!parent || declared.get(parent.meterId).upstreamMeterId !== row.meterId || children.has(parent.requestId))
|
|
198
|
+
throw Error('gateway parent edge missing or ambiguous');
|
|
199
|
+
if (parent.wireRequest.payloadSha256 !== row.wireRequest.payloadSha256 || parent.wireRequest.bytes !== row.wireRequest.bytes || parent.wireRequest.model !== row.wireRequest.model || row.startedAt < parent.startedAt || row.finishedAt > parent.finishedAt)
|
|
200
|
+
throw Error('gateway forwarded request mismatch');
|
|
201
|
+
children.set(parent.requestId, row);
|
|
202
|
+
}
|
|
203
|
+
const diagnostics = [];
|
|
204
|
+
for (const row of rows.values())
|
|
205
|
+
if (row.upstreamKind === 'meter' && !children.has(row.requestId)) {
|
|
206
|
+
if (row.httpStatus !== null && row.httpStatus >= 200 && row.httpStatus < 300)
|
|
207
|
+
throw Error('successful gateway hop missing');
|
|
208
|
+
diagnostics.push(`unresolved-meter-hop:${row.requestId}`);
|
|
209
|
+
}
|
|
210
|
+
const providerAttempts = [...rows.values()].filter(row => row.upstreamKind === 'provider').map(row => {
|
|
211
|
+
let origin = row;
|
|
212
|
+
while (origin.parentRequestId !== null)
|
|
213
|
+
origin = rows.get(origin.parentRequestId);
|
|
214
|
+
return { ...row, originCellId: origin.evidenceBinding.cellId, originTaskRef: origin.evidenceBinding.taskRef };
|
|
215
|
+
});
|
|
216
|
+
// Re-read against the same hashes after graph validation, so independent
|
|
217
|
+
// meter snapshots cannot silently change while other meters are collected.
|
|
218
|
+
for (const meter of declared.values()) {
|
|
219
|
+
const evidence = supplied.get(meter.meterId);
|
|
220
|
+
await readFrontendGatewayEvidence({ repoRoot: input.repoRoot, expectedBinding: meter.binding, contract: meter.contract, state: evidence.state, requests: evidence.requests });
|
|
221
|
+
}
|
|
222
|
+
if (!(await readFileBoundarySafe({ boundaryRoot: input.repoRoot, targetPath: target, label: 'gateway topology recheck' })).equals(bytes))
|
|
223
|
+
throw Error('gateway topology changed during collection');
|
|
224
|
+
return { evidenceKind: 'observed-gateway-request-graph', topologySha256: topologyRef.sha256, observedProviderAttempts: providerAttempts.length,
|
|
225
|
+
reportedProviderTokens: diagnostics.length || providerAttempts.some(row => row.totalTokens === null) ? null : providerAttempts.reduce((sum, row) => sum + row.totalTokens, 0), providerAttempts, diagnostics };
|
|
226
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-store.js";
|
|
5
|
+
import { readFileBoundarySafe } from "../../shared/path-safety.js";
|
|
6
|
+
import { campaignPlanSchema, candidateIdSchema } from "./types.js";
|
|
7
|
+
import { computeCampaignPlanHash } from "./campaign-hash.js";
|
|
8
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
9
|
+
const bundleHash = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
10
|
+
const ref = z.object({ path: z.string().min(1), sha256: hash }).strict();
|
|
11
|
+
// Keep registration schemas independent of DAG evidence readers: DAG types
|
|
12
|
+
// import this module while the readers themselves depend on DAG lifecycle.
|
|
13
|
+
export const frontendSharedArtifactsSchema = z.object({ source: ref, budget: ref, model: ref, initialTree: ref }).strict();
|
|
14
|
+
const sharedSchema = z.object({ source: hash, model: hash, budget: hash, initialTree: hash, acceptance: hash }).strict();
|
|
15
|
+
const armSchema = z.object({ candidateId: candidateIdSchema, bundleHash, controllerFingerprint: bundleHash, controller: ref, promptTools: ref }).strict();
|
|
16
|
+
const registrationSchema = z.object({
|
|
17
|
+
schemaVersion: z.literal(1), kind: z.literal("frontend-task-pair-registration"),
|
|
18
|
+
createdAt: z.string().datetime(), campaignId: z.string().min(1), planHash: bundleHash,
|
|
19
|
+
baseline: armSchema, candidate: armSchema, shared: sharedSchema, sharedArtifacts: frontendSharedArtifactsSchema.optional(),
|
|
20
|
+
gatewayTopology: ref.optional(),
|
|
21
|
+
}).strict();
|
|
22
|
+
const observedSchema = sharedSchema.extend({ protocol: hash, controller: hash, promptTools: hash }).strict();
|
|
23
|
+
/** Identity admission only, not acceptance or promotion. `identities` must be
|
|
24
|
+
* collected by a reader which verifies the run and all referenced artifacts;
|
|
25
|
+
* this gate binds those identities to predeclared, rehashed candidate bytes. */
|
|
26
|
+
export async function validateFrontendPairRegistration(input) {
|
|
27
|
+
const registrationRef = ref.parse(input.registration);
|
|
28
|
+
const read = async () => {
|
|
29
|
+
const bytes = await readFileBoundarySafe({ boundaryRoot: input.repoRoot,
|
|
30
|
+
targetPath: path.resolve(input.repoRoot, registrationRef.path), label: "frontend pair registration" });
|
|
31
|
+
if (createHash("sha256").update(bytes).digest("hex") !== registrationRef.sha256)
|
|
32
|
+
throw Error("pair registration hash mismatch");
|
|
33
|
+
return registrationSchema.parse(JSON.parse(bytes.toString("utf8")));
|
|
34
|
+
};
|
|
35
|
+
const registration = await read();
|
|
36
|
+
const plan = campaignPlanSchema.parse(input.plan);
|
|
37
|
+
if (computeCampaignPlanHash(plan.campaignId, plan.cells) !== plan.planHash)
|
|
38
|
+
throw Error("pair plan content hash mismatch");
|
|
39
|
+
if (registration.campaignId !== plan.campaignId || registration.planHash !== plan.planHash)
|
|
40
|
+
throw Error("pair registration plan mismatch");
|
|
41
|
+
if (registration.baseline.candidateId === registration.candidate.candidateId)
|
|
42
|
+
throw Error("distinct registered candidates required");
|
|
43
|
+
const startedAt = Date.parse(z.string().datetime().parse(input.earliestRunStartedAt));
|
|
44
|
+
const registeredAt = Date.parse(registration.createdAt), plannedAt = Date.parse(plan.createdAt);
|
|
45
|
+
if (!Number.isFinite(plannedAt) || plannedAt > registeredAt || registeredAt >= startedAt)
|
|
46
|
+
throw Error("pair must be planned and registered before execution");
|
|
47
|
+
const baselineCells = plan.cells.filter(cell => cell.cellId === input.baselineCellId);
|
|
48
|
+
const candidateCells = plan.cells.filter(cell => cell.cellId === input.candidateCellId);
|
|
49
|
+
if (baselineCells.length !== 1 || candidateCells.length !== 1)
|
|
50
|
+
throw Error("pair cells missing or duplicated");
|
|
51
|
+
const baseline = baselineCells[0], candidate = candidateCells[0];
|
|
52
|
+
if (baseline.executionRole !== "candidate_run" || candidate.executionRole !== "candidate_run" ||
|
|
53
|
+
baseline.taskRef !== candidate.taskRef || baseline.seed !== candidate.seed || baseline.split !== candidate.split)
|
|
54
|
+
throw Error("pair cells do not share task/seed/split");
|
|
55
|
+
for (const [side, cell] of [["baseline", baseline], ["candidate", candidate]]) {
|
|
56
|
+
const arm = registration[side];
|
|
57
|
+
if (cell.candidateId !== arm.candidateId || cell.bundleHash !== arm.bundleHash)
|
|
58
|
+
throw Error("pair cell candidate mismatch");
|
|
59
|
+
const record = await readCandidateRecord(input.repoRoot, arm.candidateId);
|
|
60
|
+
if (record.manifest.bundleHash !== arm.bundleHash)
|
|
61
|
+
throw Error("registered candidate bundle mismatch");
|
|
62
|
+
const candidateTime = Date.parse(record.manifest.createdAt);
|
|
63
|
+
const recordedAt = Date.parse(record.events[0]?.at ?? "");
|
|
64
|
+
if (!Number.isFinite(candidateTime) || !Number.isFinite(recordedAt) ||
|
|
65
|
+
candidateTime > registeredAt || recordedAt > registeredAt)
|
|
66
|
+
throw Error("candidate registered after pair protocol");
|
|
67
|
+
const identity = observedSchema.parse(input.identities[side]);
|
|
68
|
+
if (identity.protocol !== registrationRef.sha256)
|
|
69
|
+
throw Error("observed protocol mismatch");
|
|
70
|
+
for (const key of ["source", "model", "budget", "initialTree", "acceptance"]) {
|
|
71
|
+
if (identity[key] !== registration.shared[key])
|
|
72
|
+
throw Error(`shared pair identity mismatch: ${key}`);
|
|
73
|
+
}
|
|
74
|
+
for (const key of ["controller", "promptTools"]) {
|
|
75
|
+
const declared = arm[key];
|
|
76
|
+
if (!record.manifest.contentRefs.some(content => content.path === declared.path && content.sha256 === `sha256:${declared.sha256}`))
|
|
77
|
+
throw Error(`candidate does not own registered ${key} snapshot`);
|
|
78
|
+
if (identity[key] !== declared.sha256)
|
|
79
|
+
throw Error(`observed candidate ${key} mismatch`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (registration.gatewayTopology) {
|
|
83
|
+
const { assertFrontendGatewayTopology } = await import("./frontend-gateway-evidence.js");
|
|
84
|
+
await assertFrontendGatewayTopology({ repoRoot: input.repoRoot, topology: registration.gatewayTopology, plan, registrationCreatedAt: registration.createdAt });
|
|
85
|
+
}
|
|
86
|
+
await read();
|
|
87
|
+
return { campaignId: plan.campaignId, planHash: plan.planHash, registrationSha256: registrationRef.sha256,
|
|
88
|
+
baselineCandidateId: baseline.candidateId, candidateId: candidate.candidateId };
|
|
89
|
+
}
|
|
90
|
+
export const frontendPairExecutionBindingSchema = z.object({
|
|
91
|
+
registration: ref, cellId: z.string().min(1),
|
|
92
|
+
}).strict();
|
|
93
|
+
/** Called by the runner before allocation and on resume. */
|
|
94
|
+
export async function assertFrontendPairExecution(input) {
|
|
95
|
+
const pair = frontendPairExecutionBindingSchema.parse(input.binding.frontendPair);
|
|
96
|
+
const bytes = await readFileBoundarySafe({ boundaryRoot: input.repoRoot,
|
|
97
|
+
targetPath: path.resolve(input.repoRoot, pair.registration.path), label: "frontend execution registration" });
|
|
98
|
+
if (createHash("sha256").update(bytes).digest("hex") !== pair.registration.sha256)
|
|
99
|
+
throw Error("execution registration hash mismatch");
|
|
100
|
+
const protocol = registrationSchema.parse(JSON.parse(bytes.toString("utf8")));
|
|
101
|
+
const { readCampaignPlan } = await import("../../infrastructure/evaluation/campaign-store.js");
|
|
102
|
+
const stored = await readCampaignPlan(input.repoRoot, input.binding.campaignId);
|
|
103
|
+
if (!stored)
|
|
104
|
+
throw Error("frontend pair campaign plan missing");
|
|
105
|
+
const plan = campaignPlanSchema.parse(stored);
|
|
106
|
+
if (protocol.campaignId !== input.binding.campaignId || protocol.planHash !== plan.planHash ||
|
|
107
|
+
computeCampaignPlanHash(plan.campaignId, plan.cells) !== plan.planHash)
|
|
108
|
+
throw Error("execution pair plan mismatch");
|
|
109
|
+
const registeredAt = Date.parse(protocol.createdAt), startedAt = Date.parse(input.startedAt), plannedAt = Date.parse(plan.createdAt);
|
|
110
|
+
if (![registeredAt, startedAt, plannedAt].every(Number.isFinite) || plannedAt > registeredAt || registeredAt >= startedAt)
|
|
111
|
+
throw Error("execution pair protocol must predate run");
|
|
112
|
+
const arms = [protocol.baseline, protocol.candidate].filter(arm => arm.candidateId === input.binding.candidateId);
|
|
113
|
+
const cells = plan.cells.filter(cell => cell.cellId === pair.cellId);
|
|
114
|
+
if (arms.length !== 1 || cells.length !== 1)
|
|
115
|
+
throw Error("execution pair arm or cell missing/ambiguous");
|
|
116
|
+
const arm = arms[0], cell = cells[0];
|
|
117
|
+
if (cell.executionRole !== "candidate_run" || cell.candidateId !== arm.candidateId || cell.bundleHash !== arm.bundleHash ||
|
|
118
|
+
input.binding.bundleHash !== arm.bundleHash || cell.seed !== input.binding.seed || cell.split !== input.binding.split || cell.taskRef !== input.binding.taskRef)
|
|
119
|
+
throw Error("execution pair cell binding mismatch");
|
|
120
|
+
if (protocol.baseline.candidateId === protocol.candidate.candidateId)
|
|
121
|
+
throw Error("execution pair requires distinct arms");
|
|
122
|
+
for (const member of [protocol.baseline, protocol.candidate]) {
|
|
123
|
+
const partners = plan.cells.filter(row => row.executionRole === "candidate_run" && row.candidateId === member.candidateId &&
|
|
124
|
+
row.taskRef === cell.taskRef && row.seed === cell.seed && row.split === cell.split);
|
|
125
|
+
if (partners.length !== 1 || partners[0].bundleHash !== member.bundleHash)
|
|
126
|
+
throw Error("execution pair counterpart missing or mismatched");
|
|
127
|
+
}
|
|
128
|
+
if (arm.controllerFingerprint !== input.controllerFingerprint)
|
|
129
|
+
throw Error("execution pair controller fingerprint mismatch");
|
|
130
|
+
const record = await readCandidateRecord(input.repoRoot, arm.candidateId);
|
|
131
|
+
const recordedAt = Date.parse(record.events[0]?.at ?? "");
|
|
132
|
+
if (record.manifest.bundleHash !== arm.bundleHash || !Number.isFinite(recordedAt) || recordedAt > registeredAt)
|
|
133
|
+
throw Error("execution pair candidate registration mismatch");
|
|
134
|
+
for (const key of ["controller", "promptTools"]) {
|
|
135
|
+
if (!record.manifest.contentRefs.some(content => content.path === arm[key].path && content.sha256 === `sha256:${arm[key].sha256}`))
|
|
136
|
+
throw Error("execution pair snapshot not owned by candidate");
|
|
137
|
+
}
|
|
138
|
+
if (protocol.gatewayTopology) {
|
|
139
|
+
const { assertFrontendGatewayTopology } = await import("./frontend-gateway-evidence.js");
|
|
140
|
+
await assertFrontendGatewayTopology({ repoRoot: input.repoRoot, topology: protocol.gatewayTopology, plan, registrationCreatedAt: protocol.createdAt });
|
|
141
|
+
}
|
|
142
|
+
return { planHash: plan.planHash, shared: protocol.shared, sharedArtifacts: protocol.sharedArtifacts, gatewayTopology: protocol.gatewayTopology };
|
|
143
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { campaignPlanSchema } from "./types.js";
|
|
3
|
+
const metric = z.number().finite().nonnegative().nullable();
|
|
4
|
+
const observationSchema = z.object({
|
|
5
|
+
cellId: z.string().min(1),
|
|
6
|
+
bundleHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
7
|
+
runId: z.string().min(1),
|
|
8
|
+
firstPassed: z.boolean().nullable(),
|
|
9
|
+
finalPassed: z.boolean().nullable(),
|
|
10
|
+
requests: z.number().int().nonnegative().nullable(),
|
|
11
|
+
tokens: z.number().int().nonnegative().nullable(),
|
|
12
|
+
wallClockMs: metric,
|
|
13
|
+
}).strict().refine(row => row.firstPassed !== true || row.finalPassed === true, {
|
|
14
|
+
message: "first success requires final success",
|
|
15
|
+
});
|
|
16
|
+
/** For independent paired trials, Hoeffding bounds the mean of differences
|
|
17
|
+
* in [-1, 1]: P(|mean - E(mean)| >= e) <= 2 exp(-n e² / 2).
|
|
18
|
+
* This deliberately stays wide for small samples, including all ties.
|
|
19
|
+
* Each interval has marginal 95% coverage, not simultaneous coverage across
|
|
20
|
+
* metrics/cases, and targets this fixed plan rather than unseen task classes. */
|
|
21
|
+
function pairedUncertainty(pairs) {
|
|
22
|
+
const calculate = (field) => {
|
|
23
|
+
let observed = 0, totalDifference = 0;
|
|
24
|
+
for (const pair of pairs) {
|
|
25
|
+
const before = pair.baseline?.[field], after = pair.candidate?.[field];
|
|
26
|
+
if (before == null || after == null)
|
|
27
|
+
continue;
|
|
28
|
+
observed++;
|
|
29
|
+
totalDifference += Number(after) - Number(before);
|
|
30
|
+
}
|
|
31
|
+
const complete = observed === pairs.length && observed > 0;
|
|
32
|
+
const difference = complete ? totalDifference / observed : null;
|
|
33
|
+
const halfWidth = complete ? Math.sqrt(2 * Math.log(40) / observed) : null;
|
|
34
|
+
return {
|
|
35
|
+
method: "paired-hoeffding", confidenceLevel: 0.95,
|
|
36
|
+
assumption: "independent-paired-trials-fixed-plan",
|
|
37
|
+
planned: pairs.length, observed, difference,
|
|
38
|
+
interval: difference === null || halfWidth === null ? null : {
|
|
39
|
+
lower: Math.max(-1, difference - halfWidth), upper: Math.min(1, difference + halfWidth),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
return { first: calculate("firstPassed"), final: calculate("finalPassed") };
|
|
44
|
+
}
|
|
45
|
+
function distribution(values) {
|
|
46
|
+
if (!values.length || values.some(value => value === null)) {
|
|
47
|
+
return { values, total: null, median: null, maximum: null };
|
|
48
|
+
}
|
|
49
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
50
|
+
const middle = Math.floor(sorted.length / 2);
|
|
51
|
+
return {
|
|
52
|
+
values,
|
|
53
|
+
total: sorted.reduce((sum, value) => sum + value, 0),
|
|
54
|
+
median: sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2,
|
|
55
|
+
maximum: sorted.at(-1),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function summarize(rows) {
|
|
59
|
+
const success = (field) => {
|
|
60
|
+
const values = rows.map(row => row?.[field] ?? null);
|
|
61
|
+
const passed = values.filter(value => value === true).length;
|
|
62
|
+
const failed = values.filter(value => value === false).length;
|
|
63
|
+
const unknown = values.length - passed - failed;
|
|
64
|
+
return { planned: rows.length, passed, failed, unknown, rate: unknown || !rows.length ? null : passed / rows.length };
|
|
65
|
+
};
|
|
66
|
+
const first = success("firstPassed"), final = success("finalPassed");
|
|
67
|
+
const requests = distribution(rows.map(row => row?.requests ?? null));
|
|
68
|
+
const tokens = distribution(rows.map(row => row?.tokens ?? null));
|
|
69
|
+
const wallClockMs = distribution(rows.map(row => row?.wallClockMs ?? null));
|
|
70
|
+
return {
|
|
71
|
+
first, final, requests, tokens, wallClockMs,
|
|
72
|
+
// Failed attempts remain in the numerator. Unknown or zero correct
|
|
73
|
+
// deliveries cannot yield an apparent zero-cost delivery.
|
|
74
|
+
tokensPerCorrectDelivery: final.unknown || !final.passed || tokens.total === null ? null : tokens.total / final.passed,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Descriptive statistics only. The caller must verify source artifacts and
|
|
78
|
+
* preregistered identity/model/budget/oracle bindings before interpreting these
|
|
79
|
+
* observations. This function never authorizes promotion or returns a pass. */
|
|
80
|
+
export function summarizeFrontendTaskPairs(input) {
|
|
81
|
+
const plan = campaignPlanSchema.parse(input.plan);
|
|
82
|
+
if (input.baselineCandidateId === input.candidateId)
|
|
83
|
+
throw Error("distinct candidates required");
|
|
84
|
+
const cells = plan.cells.filter(cell => cell.executionRole === "candidate_run" &&
|
|
85
|
+
[input.baselineCandidateId, input.candidateId].includes(cell.candidateId));
|
|
86
|
+
const byId = new Map(cells.map(cell => [cell.cellId, cell]));
|
|
87
|
+
if (byId.size !== cells.length)
|
|
88
|
+
throw Error("duplicate planned cell");
|
|
89
|
+
const observations = new Map();
|
|
90
|
+
const runIds = new Set();
|
|
91
|
+
for (const raw of input.observations) {
|
|
92
|
+
const row = observationSchema.parse(raw), cell = byId.get(row.cellId);
|
|
93
|
+
if (!cell || cell.bundleHash !== row.bundleHash)
|
|
94
|
+
throw Error("observation does not match planned candidate cell");
|
|
95
|
+
if (observations.has(row.cellId) || runIds.has(row.runId))
|
|
96
|
+
throw Error("duplicate cell or run observation");
|
|
97
|
+
observations.set(row.cellId, row);
|
|
98
|
+
runIds.add(row.runId);
|
|
99
|
+
}
|
|
100
|
+
const groups = new Map();
|
|
101
|
+
for (const cell of cells) {
|
|
102
|
+
const key = JSON.stringify([cell.split, cell.taskRef, cell.seed]);
|
|
103
|
+
const pair = groups.get(key) ?? { taskRef: cell.taskRef, split: cell.split, seed: cell.seed };
|
|
104
|
+
const side = cell.candidateId === input.baselineCandidateId ? "baselineCellId" : "candidateCellId";
|
|
105
|
+
if (pair[side])
|
|
106
|
+
throw Error("duplicate planned candidate/task/seed/split");
|
|
107
|
+
pair[side] = cell.cellId;
|
|
108
|
+
groups.set(key, pair);
|
|
109
|
+
}
|
|
110
|
+
if (!groups.size || [...groups.values()].some(pair => !pair.baselineCellId || !pair.candidateCellId)) {
|
|
111
|
+
throw Error("plan must contain both arms for every pair");
|
|
112
|
+
}
|
|
113
|
+
const pairs = [...groups.values()].map(pair => ({
|
|
114
|
+
...pair,
|
|
115
|
+
baseline: observations.get(pair.baselineCellId) ?? null,
|
|
116
|
+
candidate: observations.get(pair.candidateCellId) ?? null,
|
|
117
|
+
}));
|
|
118
|
+
const perCase = [...new Set(pairs.map(pair => JSON.stringify([pair.split, pair.taskRef])))].map(key => {
|
|
119
|
+
const selected = pairs.filter(pair => JSON.stringify([pair.split, pair.taskRef]) === key);
|
|
120
|
+
const tally = (field) => {
|
|
121
|
+
const counts = { baselineWins: 0, candidateWins: 0, ties: 0, unknown: 0 };
|
|
122
|
+
for (const pair of selected) {
|
|
123
|
+
const before = pair.baseline?.[field], after = pair.candidate?.[field];
|
|
124
|
+
if (before == null || after == null)
|
|
125
|
+
counts.unknown++;
|
|
126
|
+
else if (before === after)
|
|
127
|
+
counts.ties++;
|
|
128
|
+
else if (after)
|
|
129
|
+
counts.candidateWins++;
|
|
130
|
+
else
|
|
131
|
+
counts.baselineWins++;
|
|
132
|
+
}
|
|
133
|
+
return counts;
|
|
134
|
+
};
|
|
135
|
+
return { taskRef: selected[0].taskRef, split: selected[0].split,
|
|
136
|
+
first: tally("firstPassed"), final: tally("finalPassed"),
|
|
137
|
+
pairedUncertainty: pairedUncertainty(selected),
|
|
138
|
+
baseline: summarize(selected.map(pair => pair.baseline)),
|
|
139
|
+
candidate: summarize(selected.map(pair => pair.candidate)) };
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
evidenceKind: "descriptive-task-pair-summary", perCase,
|
|
143
|
+
campaignId: plan.campaignId, planHash: plan.planHash,
|
|
144
|
+
baselineCandidateId: input.baselineCandidateId, candidateId: input.candidateId,
|
|
145
|
+
pairs,
|
|
146
|
+
pairedUncertainty: pairedUncertainty(pairs),
|
|
147
|
+
baseline: summarize(pairs.map(pair => pair.baseline)),
|
|
148
|
+
candidate: summarize(pairs.map(pair => pair.candidate)),
|
|
149
|
+
};
|
|
150
|
+
}
|