@ggui-ai/negotiator 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +49 -0
- package/dist/contract-hash.d.ts +54 -0
- package/dist/contract-hash.d.ts.map +1 -0
- package/dist/contract-hash.js +96 -0
- package/dist/contract-validators.d.ts +171 -0
- package/dist/contract-validators.d.ts.map +1 -0
- package/dist/contract-validators.js +478 -0
- package/dist/decision-input.d.ts +48 -0
- package/dist/decision-input.d.ts.map +1 -0
- package/dist/decision-input.js +14 -0
- package/dist/decision.d.ts +54 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +500 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/intent.d.ts +22 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +28 -0
- package/dist/llm-caller.d.ts +70 -0
- package/dist/llm-caller.d.ts.map +1 -0
- package/dist/llm-caller.js +38 -0
- package/dist/llm-rerank.d.ts +101 -0
- package/dist/llm-rerank.d.ts.map +1 -0
- package/dist/llm-rerank.js +178 -0
- package/dist/negotiate.d.ts +141 -0
- package/dist/negotiate.d.ts.map +1 -0
- package/dist/negotiate.js +161 -0
- package/dist/normalize-schema.d.ts +22 -0
- package/dist/normalize-schema.d.ts.map +1 -0
- package/dist/normalize-schema.js +191 -0
- package/dist/pure.d.ts +30 -0
- package/dist/pure.d.ts.map +1 -0
- package/dist/pure.js +43 -0
- package/dist/rag-search.d.ts +73 -0
- package/dist/rag-search.d.ts.map +1 -0
- package/dist/rag-search.js +192 -0
- package/dist/rerank-eval/pairs.d.ts +28 -0
- package/dist/rerank-eval/pairs.d.ts.map +1 -0
- package/dist/rerank-eval/pairs.js +531 -0
- package/dist/rerank-eval/run-probe-cli.d.ts +3 -0
- package/dist/rerank-eval/run-probe-cli.d.ts.map +1 -0
- package/dist/rerank-eval/run-probe-cli.js +146 -0
- package/dist/rerank-eval/run-probe.d.ts +68 -0
- package/dist/rerank-eval/run-probe.d.ts.map +1 -0
- package/dist/rerank-eval/run-probe.js +113 -0
- package/dist/session.d.ts +42 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +21 -0
- package/dist/suggestion.d.ts +38 -0
- package/dist/suggestion.d.ts.map +1 -0
- package/dist/suggestion.js +47 -0
- package/dist/synth-bench/corpus.d.ts +106 -0
- package/dist/synth-bench/corpus.d.ts.map +1 -0
- package/dist/synth-bench/corpus.js +994 -0
- package/dist/synth-bench/run-bench-cli.d.ts +3 -0
- package/dist/synth-bench/run-bench-cli.d.ts.map +1 -0
- package/dist/synth-bench/run-bench-cli.js +181 -0
- package/dist/synth-bench/run-bench.d.ts +101 -0
- package/dist/synth-bench/run-bench.d.ts.map +1 -0
- package/dist/synth-bench/run-bench.js +374 -0
- package/dist/synthesize-contract.d.ts +131 -0
- package/dist/synthesize-contract.d.ts.map +1 -0
- package/dist/synthesize-contract.js +948 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/package.json +74 -0
- package/src/contract-hash.ts +102 -0
- package/src/contract-validators.ts +604 -0
- package/src/decision-input.ts +49 -0
- package/src/decision.ts +581 -0
- package/src/index.ts +63 -0
- package/src/intent.ts +37 -0
- package/src/llm-caller.ts +82 -0
- package/src/llm-rerank.ts +280 -0
- package/src/negotiate.ts +312 -0
- package/src/normalize-schema.ts +193 -0
- package/src/pure.ts +46 -0
- package/src/rag-search.ts +274 -0
- package/src/rerank-eval/pairs.ts +624 -0
- package/src/rerank-eval/run-probe-cli.ts +197 -0
- package/src/rerank-eval/run-probe.ts +198 -0
- package/src/session.ts +41 -0
- package/src/suggestion.ts +73 -0
- package/src/synth-bench/corpus.ts +1126 -0
- package/src/synth-bench/run-bench-cli.ts +237 -0
- package/src/synth-bench/run-bench.ts +525 -0
- package/src/synthesize-contract.ts +1161 -0
- package/src/types.ts +31 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesizer bench runner.
|
|
3
|
+
*
|
|
4
|
+
* Two evaluators:
|
|
5
|
+
*
|
|
6
|
+
* - {@link evaluateAgainstCorpus} — given an `LLMCaller`, runs
|
|
7
|
+
* `synthesizeContract` over the corpus and compares each output to
|
|
8
|
+
* the expected structural shape. Used by {@link runSynthBench} for
|
|
9
|
+
* a real LLM probe (opt-in CLI, costs ~$0.001 per entry on Haiku).
|
|
10
|
+
*
|
|
11
|
+
* - {@link scoreSynthesizedContract} — pure structural compare. Used
|
|
12
|
+
* by the deterministic structure-bench test against fixtures and
|
|
13
|
+
* by the LLM bench to grade live outputs.
|
|
14
|
+
*
|
|
15
|
+
* Pass criteria:
|
|
16
|
+
* - `hasActionSpec` / `hasContextSpec` / `hasStreamSpec` / `hasProps`
|
|
17
|
+
* match, OR the entry sets `tolerateEitherShape: true`.
|
|
18
|
+
* - Gadget / agent-tool *identity* (`capabilityHooks`,
|
|
19
|
+
* `forbiddenCapabilityHooks`) — registry-bounded names, so a
|
|
20
|
+
* mismatch is a real retrieval/discrimination bug and DOES fail.
|
|
21
|
+
*
|
|
22
|
+
* Advisory (reported, NOT pass-gating) — see {@link ADVISORY_FAILURE_KINDS}:
|
|
23
|
+
* - `actionNames` / `contextSlots` / `agentToolNames` are
|
|
24
|
+
* free-vocabulary: the synth invents the action/slot/tool name and
|
|
25
|
+
* a checkout-completion action (`finish` / `complete` /
|
|
26
|
+
* `completePurchase` / `placeOrder` / …) has an unbounded valid
|
|
27
|
+
* name space. An exact-name allow-list only generates false
|
|
28
|
+
* negatives, so a name mismatch is surfaced but does not fail the
|
|
29
|
+
* bench — spec-presence + placement already verify the shape.
|
|
30
|
+
*
|
|
31
|
+
* Validator findings ride along on the report so an operator inspecting
|
|
32
|
+
* a regression can see WHY a synthesized contract was flagged
|
|
33
|
+
* (redundant-action vs novel-shape vs nothing).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { DataContract } from '@ggui-ai/protocol';
|
|
37
|
+
import { listContractGadgets } from '@ggui-ai/protocol';
|
|
38
|
+
import { synthesizeContract } from '../synthesize-contract.js';
|
|
39
|
+
import {
|
|
40
|
+
validateContractStructure,
|
|
41
|
+
type ContractValidationFinding,
|
|
42
|
+
} from '../contract-validators.js';
|
|
43
|
+
import type { LLMCaller } from '../llm-caller.js';
|
|
44
|
+
import {
|
|
45
|
+
BENCH_CORPUS,
|
|
46
|
+
contractShape,
|
|
47
|
+
type BenchEntry,
|
|
48
|
+
type BenchExpectation,
|
|
49
|
+
} from './corpus.js';
|
|
50
|
+
|
|
51
|
+
export interface ScoreFailure {
|
|
52
|
+
readonly kind:
|
|
53
|
+
| 'synth-declined'
|
|
54
|
+
| 'has-action-mismatch'
|
|
55
|
+
| 'has-context-mismatch'
|
|
56
|
+
| 'has-stream-mismatch'
|
|
57
|
+
| 'has-props-mismatch'
|
|
58
|
+
| 'has-client-capabilities-mismatch'
|
|
59
|
+
| 'has-agent-tools-mismatch'
|
|
60
|
+
| 'action-names-disjoint'
|
|
61
|
+
| 'context-slots-disjoint'
|
|
62
|
+
| 'capability-hooks-disjoint'
|
|
63
|
+
| 'forbidden-capability-hooks-present'
|
|
64
|
+
| 'agent-tool-names-disjoint';
|
|
65
|
+
readonly hint: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ScoreResult {
|
|
69
|
+
readonly pass: boolean;
|
|
70
|
+
/** True when expectation tolerated either shape and structural match
|
|
71
|
+
* was loose. Used to distinguish strict pass from tolerated pass in
|
|
72
|
+
* reporting. */
|
|
73
|
+
readonly tolerated: boolean;
|
|
74
|
+
readonly failures: readonly ScoreFailure[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Failure kinds that are ADVISORY — surfaced in the report but do NOT
|
|
79
|
+
* gate pass/fail. These check free-vocabulary names (action / slot /
|
|
80
|
+
* agent-tool keys the synth invents); an exact-name allow-list cannot
|
|
81
|
+
* enumerate the unbounded valid space, so a mismatch is a bench
|
|
82
|
+
* false-negative, not a synth bug. Spec-presence + placement carry the
|
|
83
|
+
* real verdict. Gadget-identity kinds are deliberately NOT here —
|
|
84
|
+
* those names are registry-bounded.
|
|
85
|
+
*/
|
|
86
|
+
const ADVISORY_FAILURE_KINDS: ReadonlySet<ScoreFailure['kind']> = new Set([
|
|
87
|
+
'action-names-disjoint',
|
|
88
|
+
'context-slots-disjoint',
|
|
89
|
+
'agent-tool-names-disjoint',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compare a synthesized contract against the expected shape. Pure /
|
|
94
|
+
* deterministic — used both by the deterministic structure-bench and
|
|
95
|
+
* by the live-LLM probe.
|
|
96
|
+
*/
|
|
97
|
+
export function scoreSynthesizedContract(
|
|
98
|
+
contract: DataContract,
|
|
99
|
+
expected: BenchExpectation,
|
|
100
|
+
): ScoreResult {
|
|
101
|
+
const failures: ScoreFailure[] = [];
|
|
102
|
+
const tolerate = expected.tolerateEitherShape === true;
|
|
103
|
+
|
|
104
|
+
const hasActionSpec =
|
|
105
|
+
contract.actionSpec !== undefined &&
|
|
106
|
+
Object.keys(contract.actionSpec).length > 0;
|
|
107
|
+
const hasContextSpec =
|
|
108
|
+
contract.contextSpec !== undefined &&
|
|
109
|
+
Object.keys(contract.contextSpec).length > 0;
|
|
110
|
+
const hasStreamSpec =
|
|
111
|
+
contract.streamSpec !== undefined &&
|
|
112
|
+
Object.keys(contract.streamSpec).length > 0;
|
|
113
|
+
const hasProps =
|
|
114
|
+
contract.propsSpec !== undefined &&
|
|
115
|
+
contract.propsSpec.properties !== undefined &&
|
|
116
|
+
Object.keys(contract.propsSpec.properties).length > 0;
|
|
117
|
+
// The wire `clientCapabilities.gadgets` map is package-keyed two-level —
|
|
118
|
+
// `listContractGadgets` flattens it to `(package, name)` use
|
|
119
|
+
// records. The export NAME is the discriminating identifier (a
|
|
120
|
+
// `use`-prefixed hook or a PascalCase component). Default empty when
|
|
121
|
+
// the catalog is absent.
|
|
122
|
+
const gadgetUses = listContractGadgets(contract);
|
|
123
|
+
const hasClientCapabilities = gadgetUses.length > 0;
|
|
124
|
+
const agentToolMap = contract.agentCapabilities?.tools ?? {};
|
|
125
|
+
const hasAgentTools = Object.keys(agentToolMap).length > 0;
|
|
126
|
+
|
|
127
|
+
if (!tolerate) {
|
|
128
|
+
if (hasActionSpec !== expected.hasActionSpec) {
|
|
129
|
+
failures.push({
|
|
130
|
+
kind: 'has-action-mismatch',
|
|
131
|
+
hint: `expected hasActionSpec=${expected.hasActionSpec}, got ${hasActionSpec}${hasActionSpec ? ` (actions: ${Object.keys(contract.actionSpec ?? {}).join(', ')})` : ''}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (hasContextSpec !== expected.hasContextSpec) {
|
|
135
|
+
failures.push({
|
|
136
|
+
kind: 'has-context-mismatch',
|
|
137
|
+
hint: `expected hasContextSpec=${expected.hasContextSpec}, got ${hasContextSpec}${hasContextSpec ? ` (slots: ${Object.keys(contract.contextSpec ?? {}).join(', ')})` : ''}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (hasStreamSpec !== expected.hasStreamSpec) {
|
|
141
|
+
failures.push({
|
|
142
|
+
kind: 'has-stream-mismatch',
|
|
143
|
+
hint: `expected hasStreamSpec=${expected.hasStreamSpec}, got ${hasStreamSpec}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (hasProps !== expected.hasProps) {
|
|
147
|
+
failures.push({
|
|
148
|
+
kind: 'has-props-mismatch',
|
|
149
|
+
hint: `expected hasProps=${expected.hasProps}, got ${hasProps}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// EE+ surfaces — only enforced when the corpus entry opts in
|
|
153
|
+
// (`hasClientCapabilities`/`hasAgentTools` field present). Legacy
|
|
154
|
+
// entries leave them undefined and skip the check.
|
|
155
|
+
if (expected.hasClientCapabilities !== undefined) {
|
|
156
|
+
if (hasClientCapabilities !== expected.hasClientCapabilities) {
|
|
157
|
+
failures.push({
|
|
158
|
+
kind: 'has-client-capabilities-mismatch',
|
|
159
|
+
hint: `expected hasClientCapabilities=${expected.hasClientCapabilities}, got ${hasClientCapabilities}${hasClientCapabilities ? ` (exports: ${gadgetUses.map((u) => u.name).join(', ')})` : ''}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (expected.hasAgentTools !== undefined) {
|
|
164
|
+
if (hasAgentTools !== expected.hasAgentTools) {
|
|
165
|
+
failures.push({
|
|
166
|
+
kind: 'has-agent-tools-mismatch',
|
|
167
|
+
hint: `expected hasAgentTools=${expected.hasAgentTools}, got ${hasAgentTools}${hasAgentTools ? ` (tools: ${Object.keys(agentToolMap).join(', ')})` : ''}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
expected.actionNames !== undefined &&
|
|
175
|
+
expected.actionNames.length > 0 &&
|
|
176
|
+
hasActionSpec
|
|
177
|
+
) {
|
|
178
|
+
const allowed = expected.actionNames.map(toFold);
|
|
179
|
+
const got = Object.keys(contract.actionSpec ?? {});
|
|
180
|
+
const intersects = got.some((name) =>
|
|
181
|
+
allowed.some((a) => containsEither(toFold(name), a)),
|
|
182
|
+
);
|
|
183
|
+
if (!intersects) {
|
|
184
|
+
failures.push({
|
|
185
|
+
kind: 'action-names-disjoint',
|
|
186
|
+
hint: `synthesized actions [${got.join(', ')}] disjoint from allowed [${expected.actionNames.join(', ')}]`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (
|
|
192
|
+
expected.contextSlots !== undefined &&
|
|
193
|
+
expected.contextSlots.length > 0 &&
|
|
194
|
+
hasContextSpec
|
|
195
|
+
) {
|
|
196
|
+
const allowed = expected.contextSlots.map(toFold);
|
|
197
|
+
const got = Object.keys(contract.contextSpec ?? {});
|
|
198
|
+
const intersects = got.some((slot) =>
|
|
199
|
+
allowed.some((a) => containsEither(toFold(slot), a)),
|
|
200
|
+
);
|
|
201
|
+
if (!intersects) {
|
|
202
|
+
failures.push({
|
|
203
|
+
kind: 'context-slots-disjoint',
|
|
204
|
+
hint: `synthesized slots [${got.join(', ')}] disjoint from allowed [${expected.contextSlots.join(', ')}]`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
expected.capabilityHooks !== undefined &&
|
|
211
|
+
expected.capabilityHooks.length > 0 &&
|
|
212
|
+
hasClientCapabilities
|
|
213
|
+
) {
|
|
214
|
+
const allowed = new Set(expected.capabilityHooks.map(toFold));
|
|
215
|
+
const got = gadgetUses.map((u) => u.name);
|
|
216
|
+
const intersects = got.some((h) => allowed.has(toFold(h)));
|
|
217
|
+
if (!intersects) {
|
|
218
|
+
failures.push({
|
|
219
|
+
kind: 'capability-hooks-disjoint',
|
|
220
|
+
hint: `synthesized hooks [${got.join(', ')}] disjoint from allowed [${expected.capabilityHooks.join(', ')}]`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Forbidden hooks check. Fires even when
|
|
226
|
+
// `hasClientCapabilities=false` is the headline expectation, because
|
|
227
|
+
// the LLM might violate by attaching the wrapper anyway. The check
|
|
228
|
+
// is silent when the contract has no clientCapabilities (nothing to
|
|
229
|
+
// forbid).
|
|
230
|
+
if (
|
|
231
|
+
expected.forbiddenCapabilityHooks !== undefined &&
|
|
232
|
+
expected.forbiddenCapabilityHooks.length > 0
|
|
233
|
+
) {
|
|
234
|
+
const forbidden = new Set(
|
|
235
|
+
expected.forbiddenCapabilityHooks.map(toFold),
|
|
236
|
+
);
|
|
237
|
+
const got = gadgetUses.map((u) => u.name);
|
|
238
|
+
const violating = got.filter((h) => forbidden.has(toFold(h)));
|
|
239
|
+
if (violating.length > 0) {
|
|
240
|
+
failures.push({
|
|
241
|
+
kind: 'forbidden-capability-hooks-present',
|
|
242
|
+
hint: `synthesized hooks [${violating.join(', ')}] present in forbidden set [${expected.forbiddenCapabilityHooks.join(', ')}] — registered wrapper attached without intent justification`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
expected.agentToolNames !== undefined &&
|
|
249
|
+
expected.agentToolNames.length > 0 &&
|
|
250
|
+
hasAgentTools
|
|
251
|
+
) {
|
|
252
|
+
const allowed = expected.agentToolNames.map(toFold);
|
|
253
|
+
const got = Object.keys(agentToolMap);
|
|
254
|
+
const intersects = got.some((name) =>
|
|
255
|
+
allowed.some((a) => containsEither(toFold(name), a)),
|
|
256
|
+
);
|
|
257
|
+
if (!intersects) {
|
|
258
|
+
failures.push({
|
|
259
|
+
kind: 'agent-tool-names-disjoint',
|
|
260
|
+
hint: `synthesized agentTools [${got.join(', ')}] disjoint from allowed [${expected.agentToolNames.join(', ')}]`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Pass when there are no NON-advisory failures. Advisory findings
|
|
266
|
+
// (free-vocabulary name mismatches) ride along on `failures` for
|
|
267
|
+
// report visibility but never gate the verdict.
|
|
268
|
+
const pass = failures.every((f) => ADVISORY_FAILURE_KINDS.has(f.kind));
|
|
269
|
+
return {
|
|
270
|
+
pass,
|
|
271
|
+
tolerated: tolerate && pass,
|
|
272
|
+
failures,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toFold(s: string): string {
|
|
277
|
+
return s.toLowerCase();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function containsEither(a: string, b: string): boolean {
|
|
281
|
+
return a.includes(b) || b.includes(a);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface BenchOutcome {
|
|
285
|
+
readonly entry: BenchEntry;
|
|
286
|
+
readonly contract: DataContract | null;
|
|
287
|
+
readonly score: ScoreResult;
|
|
288
|
+
readonly findings: readonly ContractValidationFinding[];
|
|
289
|
+
readonly latencyMs: number;
|
|
290
|
+
/** LLM attempts the synthesizer made (1 = no repair retry needed). */
|
|
291
|
+
readonly attempts: number;
|
|
292
|
+
readonly synthReason: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface BenchReport {
|
|
296
|
+
readonly outcomes: readonly BenchOutcome[];
|
|
297
|
+
readonly totals: {
|
|
298
|
+
readonly all: number;
|
|
299
|
+
readonly pass: number;
|
|
300
|
+
readonly fail: number;
|
|
301
|
+
readonly synthDeclined: number;
|
|
302
|
+
readonly precision: number;
|
|
303
|
+
};
|
|
304
|
+
readonly byShape: Readonly<
|
|
305
|
+
Record<string, { all: number; pass: number; precision: number }>
|
|
306
|
+
>;
|
|
307
|
+
readonly redundantActionFindings: number;
|
|
308
|
+
readonly latency: { readonly p50Ms: number; readonly p95Ms: number };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface RunSynthBenchOptions {
|
|
312
|
+
readonly limit?: number;
|
|
313
|
+
/** Filter the corpus to entries of one `contractShape` bucket. */
|
|
314
|
+
readonly shapeFilter?: string;
|
|
315
|
+
readonly onProgress?: (
|
|
316
|
+
outcome: BenchOutcome,
|
|
317
|
+
index: number,
|
|
318
|
+
total: number,
|
|
319
|
+
) => void;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function evaluateAgainstCorpus(
|
|
323
|
+
deps: { readonly llm: LLMCaller },
|
|
324
|
+
options: RunSynthBenchOptions = {},
|
|
325
|
+
corpus: readonly BenchEntry[] = BENCH_CORPUS,
|
|
326
|
+
): Promise<BenchReport> {
|
|
327
|
+
let subset: readonly BenchEntry[] = corpus;
|
|
328
|
+
if (options.shapeFilter !== undefined) {
|
|
329
|
+
const shapeFilter = options.shapeFilter;
|
|
330
|
+
subset = subset.filter((e) => contractShape(e.expected) === shapeFilter);
|
|
331
|
+
}
|
|
332
|
+
if (options.limit !== undefined) {
|
|
333
|
+
subset = subset.slice(0, options.limit);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const outcomes: BenchOutcome[] = [];
|
|
337
|
+
for (let i = 0; i < subset.length; i++) {
|
|
338
|
+
const entry = subset[i]!;
|
|
339
|
+
// Thread the entry's per-app registered catalog through to
|
|
340
|
+
// `synthesizeContract`. When absent, synth falls through to the
|
|
341
|
+
// static stdlib hint baked into the system prompt.
|
|
342
|
+
const synth = await synthesizeContract(
|
|
343
|
+
{ llm: deps.llm },
|
|
344
|
+
entry.intent,
|
|
345
|
+
entry.appGadgets !== undefined
|
|
346
|
+
? { appGadgets: entry.appGadgets }
|
|
347
|
+
: undefined,
|
|
348
|
+
);
|
|
349
|
+
let outcome: BenchOutcome;
|
|
350
|
+
if (synth.contract === null) {
|
|
351
|
+
outcome = {
|
|
352
|
+
entry,
|
|
353
|
+
contract: null,
|
|
354
|
+
score: {
|
|
355
|
+
pass: false,
|
|
356
|
+
tolerated: false,
|
|
357
|
+
failures: [
|
|
358
|
+
{
|
|
359
|
+
kind: 'synth-declined',
|
|
360
|
+
hint: `synth declined: ${synth.reason}`,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
findings: [],
|
|
365
|
+
latencyMs: synth.latencyMs,
|
|
366
|
+
attempts: synth.attempts,
|
|
367
|
+
synthReason: synth.reason,
|
|
368
|
+
};
|
|
369
|
+
} else {
|
|
370
|
+
const findings = validateContractStructure(synth.contract).findings;
|
|
371
|
+
const score = scoreSynthesizedContract(synth.contract, entry.expected);
|
|
372
|
+
outcome = {
|
|
373
|
+
entry,
|
|
374
|
+
contract: synth.contract,
|
|
375
|
+
score,
|
|
376
|
+
findings,
|
|
377
|
+
latencyMs: synth.latencyMs,
|
|
378
|
+
attempts: synth.attempts,
|
|
379
|
+
synthReason: synth.reason,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
outcomes.push(outcome);
|
|
383
|
+
options.onProgress?.(outcome, i, subset.length);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return summarize(outcomes);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function summarize(outcomes: readonly BenchOutcome[]): BenchReport {
|
|
390
|
+
const all = outcomes.length;
|
|
391
|
+
const pass = outcomes.filter((o) => o.score.pass).length;
|
|
392
|
+
const synthDeclined = outcomes.filter((o) => o.contract === null).length;
|
|
393
|
+
const fail = all - pass;
|
|
394
|
+
const precision = all === 0 ? 0 : pass / all;
|
|
395
|
+
|
|
396
|
+
// Roll precision up by contract SHAPE (the retired archetype
|
|
397
|
+
// categories are gone). Canonical order keeps the report stable;
|
|
398
|
+
// any shape the corpus doesn't exercise simply drops out.
|
|
399
|
+
const shapeOrder = [
|
|
400
|
+
'props-only',
|
|
401
|
+
'context-only',
|
|
402
|
+
'context+action',
|
|
403
|
+
'stream',
|
|
404
|
+
'with-gadgets',
|
|
405
|
+
'empty',
|
|
406
|
+
];
|
|
407
|
+
const presentShapes = new Set(
|
|
408
|
+
outcomes.map((o) => contractShape(o.entry.expected)),
|
|
409
|
+
);
|
|
410
|
+
const byShape = Object.fromEntries(
|
|
411
|
+
shapeOrder
|
|
412
|
+
.filter((shape) => presentShapes.has(shape))
|
|
413
|
+
.map((shape) => {
|
|
414
|
+
const subset = outcomes.filter(
|
|
415
|
+
(o) => contractShape(o.entry.expected) === shape,
|
|
416
|
+
);
|
|
417
|
+
const subsetAll = subset.length;
|
|
418
|
+
const subsetPass = subset.filter((o) => o.score.pass).length;
|
|
419
|
+
return [
|
|
420
|
+
shape,
|
|
421
|
+
{
|
|
422
|
+
all: subsetAll,
|
|
423
|
+
pass: subsetPass,
|
|
424
|
+
precision: subsetAll === 0 ? 0 : subsetPass / subsetAll,
|
|
425
|
+
},
|
|
426
|
+
];
|
|
427
|
+
}),
|
|
428
|
+
) as BenchReport['byShape'];
|
|
429
|
+
|
|
430
|
+
const redundantActionFindings = outcomes
|
|
431
|
+
.flatMap((o) => o.findings)
|
|
432
|
+
.filter((f) => f.kind === 'redundant-action').length;
|
|
433
|
+
|
|
434
|
+
const latencies = outcomes.map((o) => o.latencyMs).sort((a, b) => a - b);
|
|
435
|
+
const p50 = percentile(latencies, 0.5);
|
|
436
|
+
const p95 = percentile(latencies, 0.95);
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
outcomes,
|
|
440
|
+
totals: { all, pass, fail, synthDeclined, precision },
|
|
441
|
+
byShape,
|
|
442
|
+
redundantActionFindings,
|
|
443
|
+
latency: { p50Ms: p50, p95Ms: p95 },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function percentile(sorted: readonly number[], p: number): number {
|
|
448
|
+
if (sorted.length === 0) return 0;
|
|
449
|
+
const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length));
|
|
450
|
+
return sorted[idx] ?? 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function formatBenchReport(report: BenchReport): string {
|
|
454
|
+
const lines: string[] = [];
|
|
455
|
+
lines.push('=== synth bench report ===');
|
|
456
|
+
lines.push('');
|
|
457
|
+
lines.push('Precision by contract shape:');
|
|
458
|
+
for (const [shape, stats] of Object.entries(report.byShape)) {
|
|
459
|
+
if (stats.all === 0) continue;
|
|
460
|
+
const pct = (stats.precision * 100).toFixed(1);
|
|
461
|
+
lines.push(` ${shape.padEnd(16)} ${stats.pass}/${stats.all} (${pct}%)`);
|
|
462
|
+
}
|
|
463
|
+
lines.push('');
|
|
464
|
+
lines.push(
|
|
465
|
+
`Overall: ${report.totals.pass}/${report.totals.all} (${(report.totals.precision * 100).toFixed(1)}%)`,
|
|
466
|
+
);
|
|
467
|
+
lines.push(`Synth declined: ${report.totals.synthDeclined}`);
|
|
468
|
+
lines.push(
|
|
469
|
+
`Redundant-action firings: ${report.redundantActionFindings}`,
|
|
470
|
+
);
|
|
471
|
+
// Free-vocabulary name mismatches — surfaced, non-gating.
|
|
472
|
+
const advisoryCount = report.outcomes.reduce(
|
|
473
|
+
(n, o) =>
|
|
474
|
+
n +
|
|
475
|
+
o.score.failures.filter((f) => ADVISORY_FAILURE_KINDS.has(f.kind))
|
|
476
|
+
.length,
|
|
477
|
+
0,
|
|
478
|
+
);
|
|
479
|
+
lines.push(`Name advisories (non-gating): ${advisoryCount}`);
|
|
480
|
+
lines.push(
|
|
481
|
+
`Latency: p50=${report.latency.p50Ms}ms p95=${report.latency.p95Ms}ms`,
|
|
482
|
+
);
|
|
483
|
+
// Repair-loop turn distribution — `1t×N` means N entries synthesized
|
|
484
|
+
// on the first attempt (no repair retry). Early-skip outcomes
|
|
485
|
+
// (attempts=0) are excluded.
|
|
486
|
+
const turnDist = new Map<number, number>();
|
|
487
|
+
for (const o of report.outcomes) {
|
|
488
|
+
if (o.attempts > 0) {
|
|
489
|
+
turnDist.set(o.attempts, (turnDist.get(o.attempts) ?? 0) + 1);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const turnStr = [...turnDist.keys()]
|
|
493
|
+
.sort((a, b) => a - b)
|
|
494
|
+
.map((t) => `${t}t×${turnDist.get(t)}`)
|
|
495
|
+
.join(' ');
|
|
496
|
+
lines.push(`Attempts: ${turnStr || '(none)'}`);
|
|
497
|
+
|
|
498
|
+
const failed = report.outcomes.filter((o) => !o.score.pass);
|
|
499
|
+
if (failed.length > 0) {
|
|
500
|
+
lines.push('');
|
|
501
|
+
lines.push('Failures:');
|
|
502
|
+
for (const o of failed) {
|
|
503
|
+
lines.push(
|
|
504
|
+
` [${contractShape(o.entry.expected)}] ${o.entry.id}: ${o.entry.intent.slice(0, 60)}`,
|
|
505
|
+
);
|
|
506
|
+
for (const f of o.score.failures) {
|
|
507
|
+
lines.push(` ${f.kind}: ${f.hint}`);
|
|
508
|
+
}
|
|
509
|
+
if (o.findings.length > 0) {
|
|
510
|
+
for (const f of o.findings) {
|
|
511
|
+
lines.push(` [${f.severity}:${f.kind}] ${f.hint.slice(0, 120)}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return lines.join('\n');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function runSynthBench(
|
|
521
|
+
deps: { readonly llm: LLMCaller },
|
|
522
|
+
options: RunSynthBenchOptions = {},
|
|
523
|
+
): Promise<BenchReport> {
|
|
524
|
+
return evaluateAgainstCorpus(deps, options);
|
|
525
|
+
}
|