@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.
Files changed (91) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +49 -0
  3. package/dist/contract-hash.d.ts +54 -0
  4. package/dist/contract-hash.d.ts.map +1 -0
  5. package/dist/contract-hash.js +96 -0
  6. package/dist/contract-validators.d.ts +171 -0
  7. package/dist/contract-validators.d.ts.map +1 -0
  8. package/dist/contract-validators.js +478 -0
  9. package/dist/decision-input.d.ts +48 -0
  10. package/dist/decision-input.d.ts.map +1 -0
  11. package/dist/decision-input.js +14 -0
  12. package/dist/decision.d.ts +54 -0
  13. package/dist/decision.d.ts.map +1 -0
  14. package/dist/decision.js +500 -0
  15. package/dist/index.d.ts +36 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +25 -0
  18. package/dist/intent.d.ts +22 -0
  19. package/dist/intent.d.ts.map +1 -0
  20. package/dist/intent.js +28 -0
  21. package/dist/llm-caller.d.ts +70 -0
  22. package/dist/llm-caller.d.ts.map +1 -0
  23. package/dist/llm-caller.js +38 -0
  24. package/dist/llm-rerank.d.ts +101 -0
  25. package/dist/llm-rerank.d.ts.map +1 -0
  26. package/dist/llm-rerank.js +178 -0
  27. package/dist/negotiate.d.ts +141 -0
  28. package/dist/negotiate.d.ts.map +1 -0
  29. package/dist/negotiate.js +161 -0
  30. package/dist/normalize-schema.d.ts +22 -0
  31. package/dist/normalize-schema.d.ts.map +1 -0
  32. package/dist/normalize-schema.js +191 -0
  33. package/dist/pure.d.ts +30 -0
  34. package/dist/pure.d.ts.map +1 -0
  35. package/dist/pure.js +43 -0
  36. package/dist/rag-search.d.ts +73 -0
  37. package/dist/rag-search.d.ts.map +1 -0
  38. package/dist/rag-search.js +192 -0
  39. package/dist/rerank-eval/pairs.d.ts +28 -0
  40. package/dist/rerank-eval/pairs.d.ts.map +1 -0
  41. package/dist/rerank-eval/pairs.js +531 -0
  42. package/dist/rerank-eval/run-probe-cli.d.ts +3 -0
  43. package/dist/rerank-eval/run-probe-cli.d.ts.map +1 -0
  44. package/dist/rerank-eval/run-probe-cli.js +146 -0
  45. package/dist/rerank-eval/run-probe.d.ts +68 -0
  46. package/dist/rerank-eval/run-probe.d.ts.map +1 -0
  47. package/dist/rerank-eval/run-probe.js +113 -0
  48. package/dist/session.d.ts +42 -0
  49. package/dist/session.d.ts.map +1 -0
  50. package/dist/session.js +21 -0
  51. package/dist/suggestion.d.ts +38 -0
  52. package/dist/suggestion.d.ts.map +1 -0
  53. package/dist/suggestion.js +47 -0
  54. package/dist/synth-bench/corpus.d.ts +106 -0
  55. package/dist/synth-bench/corpus.d.ts.map +1 -0
  56. package/dist/synth-bench/corpus.js +994 -0
  57. package/dist/synth-bench/run-bench-cli.d.ts +3 -0
  58. package/dist/synth-bench/run-bench-cli.d.ts.map +1 -0
  59. package/dist/synth-bench/run-bench-cli.js +181 -0
  60. package/dist/synth-bench/run-bench.d.ts +101 -0
  61. package/dist/synth-bench/run-bench.d.ts.map +1 -0
  62. package/dist/synth-bench/run-bench.js +374 -0
  63. package/dist/synthesize-contract.d.ts +131 -0
  64. package/dist/synthesize-contract.d.ts.map +1 -0
  65. package/dist/synthesize-contract.js +948 -0
  66. package/dist/types.d.ts +30 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +13 -0
  69. package/package.json +74 -0
  70. package/src/contract-hash.ts +102 -0
  71. package/src/contract-validators.ts +604 -0
  72. package/src/decision-input.ts +49 -0
  73. package/src/decision.ts +581 -0
  74. package/src/index.ts +63 -0
  75. package/src/intent.ts +37 -0
  76. package/src/llm-caller.ts +82 -0
  77. package/src/llm-rerank.ts +280 -0
  78. package/src/negotiate.ts +312 -0
  79. package/src/normalize-schema.ts +193 -0
  80. package/src/pure.ts +46 -0
  81. package/src/rag-search.ts +274 -0
  82. package/src/rerank-eval/pairs.ts +624 -0
  83. package/src/rerank-eval/run-probe-cli.ts +197 -0
  84. package/src/rerank-eval/run-probe.ts +198 -0
  85. package/src/session.ts +41 -0
  86. package/src/suggestion.ts +73 -0
  87. package/src/synth-bench/corpus.ts +1126 -0
  88. package/src/synth-bench/run-bench-cli.ts +237 -0
  89. package/src/synth-bench/run-bench.ts +525 -0
  90. package/src/synthesize-contract.ts +1161 -0
  91. 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
+ }