aiki-cli 0.2.2 → 0.3.0

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 (59) hide show
  1. package/CHANGELOG.md +98 -7
  2. package/README.md +109 -30
  3. package/dist/bench/arms.js +10 -9
  4. package/dist/bench/harness.js +13 -10
  5. package/dist/bench/idea-lane-rotation.js +237 -0
  6. package/dist/bench/idea-v3-bench.js +506 -0
  7. package/dist/bench/idea-v3-rating.js +582 -0
  8. package/dist/bench/results.js +10 -3
  9. package/dist/bench/scoring/decision-insights.js +112 -0
  10. package/dist/bench/scoring/seeded-bugs.js +4 -0
  11. package/dist/cli/bench.js +180 -3
  12. package/dist/cli/doctor.js +56 -24
  13. package/dist/cli/index.js +31 -6
  14. package/dist/cli/resolve.js +7 -6
  15. package/dist/cli/resume.js +18 -0
  16. package/dist/cli/run.js +63 -8
  17. package/dist/council/view.js +378 -109
  18. package/dist/orchestration/calculations.js +97 -0
  19. package/dist/orchestration/context.js +37 -9
  20. package/dist/orchestration/decision-dossier.js +262 -0
  21. package/dist/orchestration/decision-graph.js +256 -0
  22. package/dist/orchestration/engine.js +5 -2
  23. package/dist/orchestration/evidence-pack.js +46 -0
  24. package/dist/orchestration/idea-lanes.js +20 -0
  25. package/dist/orchestration/jsonStage.js +72 -0
  26. package/dist/orchestration/legacy-idea-adapter.js +102 -0
  27. package/dist/orchestration/modes.js +33 -0
  28. package/dist/orchestration/preflight.js +183 -0
  29. package/dist/orchestration/quick-analysis.js +81 -0
  30. package/dist/orchestration/stages/cr-ladder.js +80 -0
  31. package/dist/orchestration/stages/s10-render.js +562 -150
  32. package/dist/orchestration/stages/s4-analyze.js +12 -7
  33. package/dist/orchestration/stages/s5-drift.js +9 -9
  34. package/dist/orchestration/stages/s6-positions.js +10 -0
  35. package/dist/orchestration/stages/s7-decision-graph.js +76 -0
  36. package/dist/orchestration/stages/s8-verify.js +153 -46
  37. package/dist/orchestration/stages/s8b-rebuttal.js +208 -0
  38. package/dist/orchestration/stages/s9-judge.js +329 -108
  39. package/dist/orchestration/stages/s9b-plan.js +85 -75
  40. package/dist/providers/codex.js +2 -1
  41. package/dist/providers/spawn.js +5 -0
  42. package/dist/schemas/index.js +572 -13
  43. package/dist/skills/idea-refinement/analyst.md +18 -14
  44. package/dist/skills/idea-refinement/economics-delivery.md +7 -0
  45. package/dist/skills/idea-refinement/market-adoption.md +7 -0
  46. package/dist/storage/runs.js +11 -4
  47. package/dist/tui/app.js +37 -13
  48. package/dist/tui/format.js +4 -5
  49. package/dist/tui/smart-entry.js +17 -1
  50. package/dist/tui/timeline.js +2 -4
  51. package/dist/workflows/code-review.js +4 -2
  52. package/dist/workflows/idea-refinement.js +110 -46
  53. package/package.json +12 -4
  54. package/dist/orchestration/stages/s0-grill.js +0 -79
  55. package/dist/orchestration/stages/s1-intent.js +0 -25
  56. package/dist/orchestration/stages/s2-misread.js +0 -76
  57. package/dist/orchestration/stages/s3-prompts.js +0 -55
  58. package/dist/orchestration/stages/s6-claims.js +0 -56
  59. package/dist/orchestration/stages/s7-disagreement.js +0 -134
@@ -0,0 +1,582 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { DecisionInsightAdjudication, scoreDecisionInsights, summarizeDecisionInsights, } from './scoring/decision-insights.js';
6
+ import { assertIdeaV3Set, findLatestIdeaV3Campaign, IDEA_V3_ARM_IDS, IDEA_V3_CALLS_PER_CASE, IdeaV3Campaign, IdeaV3Protocol, ideaV3FreezeHashes, loadIdeaV3Cases, } from './idea-v3-bench.js';
7
+ const RaterReportTemplate = z.object({
8
+ report_id: z.string().min(1),
9
+ claim_ratings: z.array(z.object({
10
+ report_claim_id: z.string().min(1),
11
+ claim_text: z.string().min(1),
12
+ load_bearing: z.boolean(),
13
+ correct: z.boolean(),
14
+ relevant: z.boolean(),
15
+ fact_kind: z.enum(['CURRENT_FACT', 'DURABLE_FACT', 'INFERENCE']),
16
+ evidence_status: z.enum(['SUPPORTED', 'UNSUPPORTED', 'NOT_REQUIRED']),
17
+ stance: z.enum(['SUPPORT', 'OPPOSE', 'QUALIFY', 'UNRESOLVED']),
18
+ matched_expected_claim_id: z.string().min(1).nullable(),
19
+ }).strict()),
20
+ action_ratings: z.array(z.object({
21
+ action_index: z.number().int().positive(),
22
+ complete: z.boolean(),
23
+ actionability_1_to_5: z.number().int().min(1).max(5),
24
+ }).strict()),
25
+ citation_checks: z.array(z.object({ citation: z.string().min(1), supports_exact_claim: z.boolean() }).strict()),
26
+ coverage: z.array(z.object({ dimension: z.string().min(1), status: z.enum(['ASSESSED', 'NOT_APPLICABLE', 'MISSING_EVIDENCE', 'MISSING']) }).strict()),
27
+ disagreement_checks: z.array(z.object({ item: z.string().min(1), genuine_same_proposition_opposition: z.boolean() }).strict()),
28
+ honesty_violations: z.array(z.string().min(1)),
29
+ preference_rank: z.number().int().positive().nullable(),
30
+ }).strict();
31
+ export const IdeaV3RaterFile = z.object({
32
+ benchmark: z.literal('idea-v3'),
33
+ set: z.enum(['build', 'holdout']),
34
+ rater_id: z.string().min(1),
35
+ locked: z.boolean(),
36
+ cases: z.array(z.object({
37
+ case_id: z.string().min(1),
38
+ reports: z.array(RaterReportTemplate),
39
+ }).strict()),
40
+ }).strict();
41
+ const BlindMapping = z.object({
42
+ campaign: z.string().min(1),
43
+ private: z.literal(true),
44
+ mapping: z.array(z.object({
45
+ rater_id: z.string().min(1),
46
+ case_id: z.string().min(1),
47
+ report_id: z.string().min(1),
48
+ arm: z.enum(IDEA_V3_ARM_IDS),
49
+ run_id: z.string().min(1),
50
+ order: z.number().int().positive(),
51
+ }).strict()),
52
+ }).strict();
53
+ const SecondaryGateCounts = z.object({
54
+ factual_claims: z.object({ eligible: z.number().int().nonnegative(), honest_or_supported: z.number().int().nonnegative() }).strict(),
55
+ citations: z.object({ total: z.number().int().nonnegative(), exact_support: z.number().int().nonnegative() }).strict(),
56
+ coverage: z.object({ required: z.number().int().nonnegative(), accounted: z.number().int().nonnegative() }).strict(),
57
+ disagreements: z.object({ labeled: z.number().int().nonnegative(), genuine: z.number().int().nonnegative() }).strict(),
58
+ actions: z.object({ total: z.number().int().nonnegative(), complete: z.number().int().nonnegative(), score_4_or_5: z.number().int().nonnegative() }).strict(),
59
+ honesty_violations: z.number().int().nonnegative(),
60
+ }).strict().superRefine((counts, ctx) => {
61
+ const pairs = [
62
+ [counts.factual_claims.honest_or_supported, counts.factual_claims.eligible, 'factual_claims'],
63
+ [counts.citations.exact_support, counts.citations.total, 'citations'],
64
+ [counts.coverage.accounted, counts.coverage.required, 'coverage'],
65
+ [counts.disagreements.genuine, counts.disagreements.labeled, 'disagreements'],
66
+ [counts.actions.complete, counts.actions.total, 'actions.complete'],
67
+ [counts.actions.score_4_or_5, counts.actions.total, 'actions.score_4_or_5'],
68
+ ];
69
+ for (const [part, total, path] of pairs) {
70
+ if (part > total)
71
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: path.split('.'), message: 'count cannot exceed total' });
72
+ }
73
+ });
74
+ export const IdeaV3RatingResolution = z.object({
75
+ benchmark: z.literal('idea-v3'),
76
+ set: z.enum(['build', 'holdout']),
77
+ mapping_file: z.string().min(1),
78
+ raw_rating_files: z.array(z.string().min(1)).min(3),
79
+ reports: z.array(z.object({
80
+ case_id: z.string().min(1),
81
+ arm: z.enum(IDEA_V3_ARM_IDS),
82
+ adjudication: DecisionInsightAdjudication,
83
+ secondary: SecondaryGateCounts,
84
+ }).strict()),
85
+ }).strict();
86
+ const DecisionInsightScoreSchema = z.object({
87
+ expected: z.number().int().nonnegative(),
88
+ matched: z.number().int().nonnegative(),
89
+ reported: z.number().int().nonnegative(),
90
+ true_positive_reports: z.number().int().nonnegative(),
91
+ recall: z.number().min(0).max(1),
92
+ precision: z.number().min(0).max(1),
93
+ f1: z.number().min(0).max(1),
94
+ }).strict();
95
+ export const IdeaV3ScoredCampaign = z.object({
96
+ version: z.literal(1),
97
+ at: z.string().min(1),
98
+ campaign: z.string().min(1),
99
+ set: z.enum(['build', 'holdout']),
100
+ raw_ratings: z.array(z.object({ rater_id: z.string().min(1), path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict()).min(3),
101
+ reports: z.array(z.object({
102
+ case_id: z.string().min(1),
103
+ arm: z.enum(IDEA_V3_ARM_IDS),
104
+ score: DecisionInsightScoreSchema,
105
+ secondary: SecondaryGateCounts,
106
+ }).strict()),
107
+ summary: z.array(z.object({ arm: z.enum(IDEA_V3_ARM_IDS), score: DecisionInsightScoreSchema }).strict()),
108
+ pairwise_preferences: z.array(z.object({
109
+ case_id: z.string().min(1),
110
+ left: z.enum(IDEA_V3_ARM_IDS),
111
+ right: z.enum(IDEA_V3_ARM_IDS),
112
+ left_votes: z.number().int().nonnegative(),
113
+ right_votes: z.number().int().nonnegative(),
114
+ winner: z.enum(IDEA_V3_ARM_IDS).nullable(),
115
+ }).strict()),
116
+ }).strict();
117
+ function blindId(seed) {
118
+ return createHash('sha256').update(seed).digest('hex').slice(0, 12).toUpperCase();
119
+ }
120
+ /** Remove the explicit identifiers forbidden by BENCHMARK-IDEA-V3.md §4 before human rating. §4 bars
121
+ * provider/model names, run ids, arm labels, AND costs — so the R7 dossier's §9 "Run details" cost
122
+ * block (mode, call counts, categories, per-provider calls, model time, degradation flags) and the
123
+ * inline `> ⚠ DEGRADED: <flag tokens>` callouts are redacted too; the DEGRADED marker and any prose
124
+ * note stay, since those are quality self-assessments raters legitimately read. */
125
+ export function blindIdeaV3Report(report, runId, caseDir) {
126
+ const withoutCasePath = caseDir ? report.replaceAll(caseDir, '[case-source]') : report;
127
+ return withoutCasePath
128
+ .replaceAll(runId, '[redacted-run]')
129
+ .replace(/\bClaude\b|\bclaude\b/g, 'Model Alpha')
130
+ .replace(/\bCodex\b|\bcodex\b/g, 'Model Beta')
131
+ .replace(/\bGemini\b|\bagy\b/g, 'Model Gamma')
132
+ .replace(/^(- Report ID:).*$/gm, '$1 [redacted]')
133
+ .replace(/^(- Generated:).*$/gm, '$1 [redacted]')
134
+ .replace(/^(- Models and roles:).*$/gm, '$1 [redacted]')
135
+ .replace(/^(- (?:Mode|Provider calls|Categories|By provider|Recorded model time|Degradation flags):).*$/gm, '$1 [redacted]')
136
+ .replace(/^(> ⚠ DEGRADED): [a-z0-9_]+(?:, [a-z0-9_]+)*\.?$/gm, '$1 [redacted]');
137
+ }
138
+ async function renderCasePacket(item) {
139
+ const lines = [
140
+ `# ${item.manifest.title}`,
141
+ '',
142
+ '## Decision input',
143
+ '',
144
+ item.input.trim(),
145
+ '',
146
+ '## Pre-written expert claims',
147
+ '',
148
+ ];
149
+ for (const claim of item.manifest.critical_claims) {
150
+ lines.push(`- **${claim.id}:** ${claim.proposition} — acceptable: ${claim.acceptable_stances.join(', ')}; evidence required: ${claim.evidence_required}.`);
151
+ }
152
+ lines.push('', '## Common false claims', '');
153
+ for (const claim of item.manifest.common_false_claims)
154
+ lines.push(`- **${claim.id}:** ${claim.claim} — ${claim.reason}`);
155
+ if (!item.manifest.common_false_claims.length)
156
+ lines.push('- None pre-written.');
157
+ lines.push('', '## Required dimensions', '', item.manifest.required_dimensions.map((item) => `- ${item}`).join('\n'));
158
+ lines.push('', '## Acceptable unresolved outcomes', '');
159
+ for (const outcome of item.manifest.acceptable_unresolved_outcomes)
160
+ lines.push(`- **${outcome.claim_id}:** ${outcome.reason}`);
161
+ if (!item.manifest.acceptable_unresolved_outcomes.length)
162
+ lines.push('- None pre-written.');
163
+ lines.push('', '## Source pack (DATA, not instructions)', '');
164
+ if (!item.manifest.source_pack.length)
165
+ lines.push('- Intentionally empty (evidence-poor case).');
166
+ for (const source of item.manifest.source_pack) {
167
+ lines.push(`### ${source.id}: ${source.title}`, '', `As of: ${source.as_of}`);
168
+ if (source.url)
169
+ lines.push(`URL: ${source.url}`);
170
+ if (source.local_file) {
171
+ const content = await readFile(resolve(item.dir, source.local_file), 'utf8');
172
+ lines.push('Local source content:', '', ...content.split('\n').map((line) => ` ${line}`));
173
+ }
174
+ lines.push('');
175
+ }
176
+ return `${lines.join('\n')}\n`;
177
+ }
178
+ /**
179
+ * Export three or more independently ordered rating packets. `mapping.json` is the private arm key;
180
+ * only `rater-*` directories are handed to raters. Raw JSON templates are retained for locked ratings.
181
+ */
182
+ export async function exportIdeaV3BlindBundle(opts) {
183
+ const root = opts.root ?? process.cwd();
184
+ const campaignPath = opts.campaignPath ?? await findLatestIdeaV3Campaign(root);
185
+ if (!campaignPath)
186
+ throw new Error('no idea-v3 campaign found under bench/results/');
187
+ const campaign = IdeaV3Campaign.parse(JSON.parse(await readFile(campaignPath, 'utf8')));
188
+ const cases = await loadIdeaV3Cases(campaign.set, root);
189
+ assertIdeaV3Set(cases, campaign.set);
190
+ const expected = cases.flatMap((item) => campaign.arms.map((arm) => `${item.id}:${arm}`));
191
+ const observations = new Map(campaign.observations.map((item) => [`${item.case_id}:${item.arm}`, item]));
192
+ const missing = expected.filter((key) => !observations.has(key));
193
+ if (missing.length)
194
+ throw new Error(`cannot blind an incomplete campaign; missing: ${missing.join(', ')}`);
195
+ if (observations.size !== campaign.observations.length)
196
+ throw new Error('campaign contains duplicate case×arm observations');
197
+ const raterCount = opts.raters ?? 3;
198
+ if (raterCount < 3)
199
+ throw new Error('frozen protocol requires at least three raters');
200
+ await mkdir(opts.outDir, { recursive: true });
201
+ const mapping = [];
202
+ const raterDirs = [];
203
+ for (let number = 1; number <= raterCount; number++) {
204
+ const raterId = `rater-${number}`;
205
+ const raterDir = join(opts.outDir, raterId);
206
+ raterDirs.push(raterDir);
207
+ await mkdir(raterDir, { recursive: true });
208
+ const ratingCases = [];
209
+ for (const item of cases) {
210
+ const caseDir = join(raterDir, item.id);
211
+ await mkdir(caseDir, { recursive: true });
212
+ await writeFile(join(caseDir, 'case.md'), await renderCasePacket(item), 'utf8');
213
+ const ordered = campaign.arms.map((arm) => observations.get(`${item.id}:${arm}`))
214
+ .sort((left, right) => blindId(`${campaign.at}:${raterId}:${item.id}:${left.arm}`).localeCompare(blindId(`${campaign.at}:${raterId}:${item.id}:${right.arm}`)));
215
+ const reports = [];
216
+ for (let index = 0; index < ordered.length; index++) {
217
+ const observation = ordered[index];
218
+ const reportId = blindId(`${campaign.at}:${raterId}:${item.id}:${observation.arm}:report`);
219
+ const markdown = observation.report_markdown
220
+ ? blindIdeaV3Report(observation.report_markdown, observation.run_id, item.dir)
221
+ : '# Decision Report\n\nNo usable report was produced.\n';
222
+ await writeFile(join(caseDir, `${index + 1}-${reportId}.md`), markdown, 'utf8');
223
+ mapping.push({ rater_id: raterId, case_id: item.id, report_id: reportId, arm: observation.arm, run_id: observation.run_id, order: index + 1 });
224
+ reports.push({
225
+ report_id: reportId,
226
+ claim_ratings: [],
227
+ action_ratings: [],
228
+ citation_checks: [],
229
+ coverage: [],
230
+ disagreement_checks: [],
231
+ honesty_violations: [],
232
+ preference_rank: null,
233
+ });
234
+ }
235
+ ratingCases.push({ case_id: item.id, reports });
236
+ }
237
+ const template = IdeaV3RaterFile.parse({ benchmark: 'idea-v3', set: campaign.set, rater_id: raterId, locked: false, cases: ratingCases });
238
+ await writeFile(join(raterDir, 'ratings.json'), JSON.stringify(template, null, 2), 'utf8');
239
+ }
240
+ const mappingPath = join(opts.outDir, 'mapping.json');
241
+ const privateMapping = BlindMapping.parse({ campaign: resolve(campaignPath), private: true, mapping });
242
+ await writeFile(mappingPath, JSON.stringify(privateMapping, null, 2), 'utf8');
243
+ return { outDir: opts.outDir, mappingPath, raterDirs };
244
+ }
245
+ function resolveFrom(base, path) {
246
+ return isAbsolute(path) ? path : resolve(base, path);
247
+ }
248
+ function sameExpectedClaims(left, right) {
249
+ return JSON.stringify(left) === JSON.stringify(right);
250
+ }
251
+ /** One-pass import of locked raw ratings plus their human-majority resolution. */
252
+ export async function importIdeaV3Ratings(opts) {
253
+ const root = opts.root ?? process.cwd();
254
+ const campaignPath = opts.campaignPath ?? await findLatestIdeaV3Campaign(root);
255
+ if (!campaignPath)
256
+ throw new Error('no idea-v3 campaign found under bench/results/');
257
+ const campaign = IdeaV3Campaign.parse(JSON.parse(await readFile(campaignPath, 'utf8')));
258
+ const resolutionPath = resolve(opts.resolutionPath);
259
+ const resolutionDir = dirname(resolutionPath);
260
+ const resolution = IdeaV3RatingResolution.parse(JSON.parse(await readFile(resolutionPath, 'utf8')));
261
+ if (resolution.set !== campaign.set)
262
+ throw new Error(`rating set ${resolution.set} does not match campaign set ${campaign.set}`);
263
+ const mappingPath = resolveFrom(resolutionDir, resolution.mapping_file);
264
+ const mapping = BlindMapping.parse(JSON.parse(await readFile(mappingPath, 'utf8')));
265
+ if (resolveFrom(dirname(mappingPath), mapping.campaign) !== resolve(campaignPath)) {
266
+ throw new Error('private mapping belongs to a different campaign');
267
+ }
268
+ const rawRatings = await Promise.all(resolution.raw_rating_files.map(async (path) => {
269
+ const full = resolveFrom(resolutionDir, path);
270
+ const content = await readFile(full, 'utf8');
271
+ const rating = IdeaV3RaterFile.parse(JSON.parse(content));
272
+ if (!rating.locked)
273
+ throw new Error(`${rating.rater_id}: ratings are not locked`);
274
+ if (rating.set !== campaign.set)
275
+ throw new Error(`${rating.rater_id}: rating set does not match campaign`);
276
+ return { rating, path: full, sha256: createHash('sha256').update(content).digest('hex') };
277
+ }));
278
+ const raterIds = new Set(rawRatings.map((item) => item.rating.rater_id));
279
+ if (raterIds.size !== rawRatings.length)
280
+ throw new Error('raw rating files must come from distinct raters');
281
+ const expectedRaters = new Set(mapping.mapping.map((item) => item.rater_id));
282
+ if (raterIds.size !== expectedRaters.size || [...raterIds].some((id) => !expectedRaters.has(id))) {
283
+ throw new Error('raw rater ids do not match the private mapping');
284
+ }
285
+ for (const { rating } of rawRatings) {
286
+ const expected = mapping.mapping.filter((item) => item.rater_id === rating.rater_id)
287
+ .map((item) => `${item.case_id}:${item.report_id}`).sort();
288
+ const actual = rating.cases.flatMap((item) => item.reports.map((report) => `${item.case_id}:${report.report_id}`)).sort();
289
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
290
+ throw new Error(`${rating.rater_id}: rated report ids do not match the private mapping`);
291
+ }
292
+ const cases = await loadIdeaV3Cases(campaign.set, root);
293
+ assertIdeaV3Set(cases, campaign.set);
294
+ const manifestById = new Map(cases.map((item) => [item.id, item.manifest]));
295
+ const campaignPairs = campaign.observations.map((item) => `${item.case_id}:${item.arm}`).sort();
296
+ const resolvedPairs = resolution.reports.map((item) => `${item.case_id}:${item.arm}`).sort();
297
+ if (JSON.stringify(resolvedPairs) !== JSON.stringify(campaignPairs)) {
298
+ throw new Error('resolved ratings must contain every campaign case×arm exactly once');
299
+ }
300
+ const reports = resolution.reports.map((item) => {
301
+ const manifest = manifestById.get(item.case_id);
302
+ if (!manifest)
303
+ throw new Error(`unknown rated case ${item.case_id}`);
304
+ if (!sameExpectedClaims(item.adjudication.expected_claims, manifest.critical_claims)) {
305
+ throw new Error(`${item.case_id}/${item.arm}: adjudication changed the pre-written expected claims`);
306
+ }
307
+ return {
308
+ case_id: item.case_id,
309
+ arm: item.arm,
310
+ score: scoreDecisionInsights(item.adjudication),
311
+ secondary: item.secondary,
312
+ };
313
+ });
314
+ const summary = campaign.arms.map((arm) => ({
315
+ arm,
316
+ score: summarizeDecisionInsights(reports.filter((item) => item.arm === arm).map((item) => item.score)),
317
+ }));
318
+ const pairwisePreferences = [];
319
+ for (const item of cases) {
320
+ for (let leftIndex = 0; leftIndex < campaign.arms.length; leftIndex++) {
321
+ for (let rightIndex = leftIndex + 1; rightIndex < campaign.arms.length; rightIndex++) {
322
+ const left = campaign.arms[leftIndex];
323
+ const right = campaign.arms[rightIndex];
324
+ let leftVotes = 0;
325
+ let rightVotes = 0;
326
+ for (const { rating } of rawRatings) {
327
+ const ratedCase = rating.cases.find((candidate) => candidate.case_id === item.id);
328
+ const armForReport = new Map(mapping.mapping.filter((entry) => entry.rater_id === rating.rater_id && entry.case_id === item.id)
329
+ .map((entry) => [entry.report_id, entry.arm]));
330
+ const ranks = new Map(ratedCase.reports.map((report) => [armForReport.get(report.report_id), report.preference_rank]));
331
+ const leftRank = ranks.get(left);
332
+ const rightRank = ranks.get(right);
333
+ if (leftRank === null || leftRank === undefined || rightRank === null || rightRank === undefined || leftRank === rightRank) {
334
+ throw new Error(`${rating.rater_id}/${item.id}: preference ranks must be non-null and unique across compared reports`);
335
+ }
336
+ if (leftRank < rightRank)
337
+ leftVotes++;
338
+ else
339
+ rightVotes++;
340
+ }
341
+ const majority = Math.floor(rawRatings.length / 2) + 1;
342
+ pairwisePreferences.push({
343
+ case_id: item.id,
344
+ left,
345
+ right,
346
+ left_votes: leftVotes,
347
+ right_votes: rightVotes,
348
+ winner: leftVotes >= majority ? left : rightVotes >= majority ? right : null,
349
+ });
350
+ }
351
+ }
352
+ }
353
+ const scored = IdeaV3ScoredCampaign.parse({
354
+ version: 1,
355
+ at: new Date().toISOString(),
356
+ campaign: campaignPath,
357
+ set: campaign.set,
358
+ raw_ratings: rawRatings.map((item) => ({ rater_id: item.rating.rater_id, path: item.path, sha256: item.sha256 })),
359
+ reports,
360
+ summary,
361
+ pairwise_preferences: pairwisePreferences,
362
+ });
363
+ const path = opts.outPath ?? campaignPath.replace(/\.json$/, '.scores.json');
364
+ try {
365
+ await readFile(path);
366
+ throw new Error(`scores already exist at ${path} — blind adjudication is one pass`);
367
+ }
368
+ catch (error) {
369
+ if (error.code !== 'ENOENT')
370
+ throw error;
371
+ }
372
+ const tmp = `${path}.tmp`;
373
+ await writeFile(tmp, JSON.stringify(scored, null, 2), 'utf8');
374
+ await rename(tmp, path);
375
+ return { path, scored };
376
+ }
377
+ function ratio(part, total) {
378
+ return total ? part / total : 1;
379
+ }
380
+ function median(values) {
381
+ if (!values.length)
382
+ return 0;
383
+ const sorted = [...values].sort((left, right) => left - right);
384
+ const middle = Math.floor(sorted.length / 2);
385
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
386
+ }
387
+ /** Pure frozen ship-gate calculation. Secondary wins cannot rescue either primary F1 loss. */
388
+ export function evaluateIdeaV3Gates(scored, campaign) {
389
+ scored = IdeaV3ScoredCampaign.parse(scored);
390
+ campaign = IdeaV3Campaign.parse(campaign);
391
+ if (scored.set !== 'holdout' || campaign.set !== 'holdout')
392
+ throw new Error('ship gates require the frozen holdout');
393
+ const score = (arm) => scored.summary.find((item) => item.arm === arm)?.score;
394
+ const b = score('B');
395
+ const c = score('C');
396
+ const r = score('R');
397
+ if (!b || !c || !r)
398
+ throw new Error('holdout ship gate requires B, C, and R scores');
399
+ const secondary = scored.reports.filter((item) => item.arm === 'R').map((item) => item.secondary);
400
+ const sum = (pick) => secondary.reduce((total, item) => total + pick(item), 0);
401
+ const factualEligible = sum((item) => item.factual_claims.eligible);
402
+ const factualPass = sum((item) => item.factual_claims.honest_or_supported);
403
+ const citations = sum((item) => item.citations.total);
404
+ const supportedCitations = sum((item) => item.citations.exact_support);
405
+ const dimensions = sum((item) => item.coverage.required);
406
+ const coveredDimensions = sum((item) => item.coverage.accounted);
407
+ const disagreements = sum((item) => item.disagreements.labeled);
408
+ const genuineDisagreements = sum((item) => item.disagreements.genuine);
409
+ const actions = sum((item) => item.actions.total);
410
+ const completeActions = sum((item) => item.actions.complete);
411
+ const actionable = sum((item) => item.actions.score_4_or_5);
412
+ const honestyViolations = sum((item) => item.honesty_violations);
413
+ const cases = new Set(campaign.observations.map((item) => item.case_id));
414
+ let preferenceWins = 0;
415
+ for (const caseId of cases) {
416
+ const rb = scored.pairwise_preferences.find((item) => item.case_id === caseId && new Set([item.left, item.right]).size === 2 && [item.left, item.right].includes('R') && [item.left, item.right].includes('B'));
417
+ const rc = scored.pairwise_preferences.find((item) => item.case_id === caseId && new Set([item.left, item.right]).size === 2 && [item.left, item.right].includes('R') && [item.left, item.right].includes('C'));
418
+ if (rb?.winner === 'R' && rc?.winner === 'R')
419
+ preferenceWins++;
420
+ }
421
+ const rObservations = campaign.observations.filter((item) => item.arm === 'R');
422
+ const gates = {
423
+ primary_vs_b: r.f1 > b.f1 && r.f1 >= 1.2 * b.f1,
424
+ primary_vs_c: r.f1 > c.f1 && r.f1 >= 1.1 * c.f1,
425
+ factual_precision: ratio(factualPass, factualEligible) >= 0.95,
426
+ citation_support: ratio(supportedCitations, citations) >= 0.95,
427
+ coverage: dimensions > 0 && coveredDimensions === dimensions,
428
+ disagreement_precision: ratio(genuineDisagreements, disagreements) >= 0.9,
429
+ validation_plan: actions > 0 && completeActions === actions && ratio(actionable, actions) >= 0.9,
430
+ honesty: honestyViolations === 0,
431
+ blind_preference: cases.size > 0 && preferenceWins / cases.size >= 0.7,
432
+ operational: rObservations.length === cases.size
433
+ && rObservations.every((item) => item.status === 'ok')
434
+ && rObservations.every((item) => item.calls <= IDEA_V3_CALLS_PER_CASE.R)
435
+ && median(rObservations.map((item) => item.latency_ms)) <= 8 * 60 * 1000,
436
+ };
437
+ return {
438
+ ...gates,
439
+ ship: Object.values(gates).every(Boolean),
440
+ r_preference_wins: preferenceWins,
441
+ holdout_cases: cases.size,
442
+ };
443
+ }
444
+ function mdCell(value) {
445
+ return value.replaceAll('|', '\\|').replaceAll('\n', ' ');
446
+ }
447
+ function pct(value) {
448
+ return `${(value * 100).toFixed(1)}%`;
449
+ }
450
+ /** Deterministically publish every holdout case, failure, call count, latency, metric, and frozen gate. */
451
+ export async function writeIdeaV3Results(opts) {
452
+ const root = opts.root ?? process.cwd();
453
+ const scoredPath = resolve(opts.scoredPath);
454
+ const scored = IdeaV3ScoredCampaign.parse(JSON.parse(await readFile(scoredPath, 'utf8')));
455
+ const campaignPath = resolveFrom(dirname(scoredPath), scored.campaign);
456
+ const campaign = IdeaV3Campaign.parse(JSON.parse(await readFile(campaignPath, 'utf8')));
457
+ const gates = evaluateIdeaV3Gates(scored, campaign);
458
+ const summary = new Map(scored.summary.map((item) => [item.arm, item.score]));
459
+ const gateRows = [
460
+ ['Primary: R ≥1.20× B and strictly wins', gates.primary_vs_b, `${summary.get('R').f1.toFixed(3)} vs ${summary.get('B').f1.toFixed(3)}`],
461
+ ['Primary: R ≥1.10× C and strictly wins', gates.primary_vs_c, `${summary.get('R').f1.toFixed(3)} vs ${summary.get('C').f1.toFixed(3)}`],
462
+ ['Factual precision', gates.factual_precision, '≥95%'],
463
+ ['Citation support', gates.citation_support, '≥95%'],
464
+ ['Coverage', gates.coverage, '100% accounted'],
465
+ ['Genuine-disagreement precision', gates.disagreement_precision, '≥90%'],
466
+ ['Validation plan', gates.validation_plan, 'all complete; ≥90% score 4/5+'],
467
+ ['Honesty', gates.honesty, 'zero settled unsupported/unresolved conclusions'],
468
+ ['Blind preference', gates.blind_preference, `${gates.r_preference_wins}/${gates.holdout_cases} cases`],
469
+ ['Operational', gates.operational, 'R ≤10 calls/case; median ≤8 min'],
470
+ ];
471
+ const lines = [
472
+ '# RESULTS-IDEA-V3 — frozen holdout',
473
+ '',
474
+ `**Ship gate: ${gates.ship ? 'PASS' : 'FAIL'}**`,
475
+ '',
476
+ ];
477
+ if (gates.ship)
478
+ lines.push('R passed both frozen primary comparisons and every additional product/operational gate.', '');
479
+ else if (!gates.primary_vs_b)
480
+ lines.push('R did not beat B under the frozen protocol. Default idea evaluation to quick; no cross-provider advantage is claimed.', '');
481
+ else if (!gates.primary_vs_c)
482
+ lines.push('R did not beat C under the frozen protocol. Vendor diversity did not earn its cost; keep R experimental or ship the self-consistency protocol.', '');
483
+ else
484
+ lines.push('R passed the primary F1 comparisons but failed one or more product/operational gates. This is a research result, not a ship claim.', '');
485
+ lines.push('## Primary metric', '', '| Arm | Expected | Matched | Reported | True positive | Recall | Precision | F1 |', '|---|---:|---:|---:|---:|---:|---:|---:|');
486
+ for (const arm of ['B', 'C', 'R']) {
487
+ const item = summary.get(arm);
488
+ lines.push(`| ${arm} | ${item.expected} | ${item.matched} | ${item.reported} | ${item.true_positive_reports} | ${pct(item.recall)} | ${pct(item.precision)} | ${item.f1.toFixed(3)} |`);
489
+ }
490
+ lines.push('', '## Frozen gates', '', '| Gate | Result | Evidence |', '|---|---|---|');
491
+ for (const [name, pass, evidence] of gateRows)
492
+ lines.push(`| ${name} | ${pass ? 'PASS' : 'FAIL'} | ${evidence} |`);
493
+ lines.push('', '## Every case and arm', '', '| Case | Arm | Status | Calls | Claude | Codex | Gemini | Repairs | Wall time | Flags / failure |', '|---|---|---|---:|---:|---:|---:|---:|---:|---|');
494
+ for (const item of [...campaign.observations].sort((left, right) => left.case_id.localeCompare(right.case_id) || left.arm.localeCompare(right.arm))) {
495
+ const detail = item.status === 'error' ? item.error : item.flags.join(', ') || 'none';
496
+ lines.push(`| ${item.case_id} | ${item.arm} | ${item.status} | ${item.calls} | ${item.calls_by_provider.claude ?? 0} | ${item.calls_by_provider.codex ?? 0} | ${item.calls_by_provider.agy ?? 0} | ${item.repair_calls} | ${(item.latency_ms / 1000).toFixed(1)}s | ${mdCell(detail)} |`);
497
+ }
498
+ lines.push('', '## Cost and latency', '', '| Arm | Calls | Claude | Codex | Gemini | Median wall time | Failures |', '|---|---:|---:|---:|---:|---:|---:|');
499
+ for (const arm of ['B', 'C', 'R']) {
500
+ const items = campaign.observations.filter((item) => item.arm === arm);
501
+ const total = (provider) => items.reduce((sum, item) => sum + (item.calls_by_provider[provider] ?? 0), 0);
502
+ lines.push(`| ${arm} | ${items.reduce((sum, item) => sum + item.calls, 0)} | ${total('claude')} | ${total('codex')} | ${total('agy')} | ${(median(items.map((item) => item.latency_ms)) / 1000).toFixed(1)}s | ${items.filter((item) => item.status === 'error').length} |`);
503
+ }
504
+ lines.push('', '## Rating record', '');
505
+ for (const raw of scored.raw_ratings)
506
+ lines.push(`- ${raw.rater_id}: SHA-256 \`${raw.sha256}\``);
507
+ lines.push('', 'Provider/model names, run ids, call logs, and report order were hidden during independent rating. The private mapping was opened only for majority resolution.', '');
508
+ const path = opts.outPath ?? join(root, 'RESULTS-IDEA-V3.md');
509
+ const tmp = `${path}.tmp`;
510
+ await writeFile(tmp, lines.join('\n'), 'utf8');
511
+ await rename(tmp, path);
512
+ return { path, gates };
513
+ }
514
+ export const IdeaV3ProtocolDraft = z.object({
515
+ build_scores: z.string().min(1),
516
+ baseline_provider: z.enum(['claude', 'codex', 'agy']),
517
+ models: z.object({ claude: z.string().min(1), codex: z.string().min(1), agy: z.string().min(1) }).strict(),
518
+ roles: z.object({
519
+ analyst: z.enum(['claude', 'codex', 'agy']),
520
+ judge: z.enum(['claude', 'codex', 'agy']),
521
+ verifier: z.enum(['claude', 'codex', 'agy']),
522
+ s4: z.tuple([z.enum(['claude', 'codex', 'agy']), z.enum(['claude', 'codex', 'agy'])]),
523
+ }).strict(),
524
+ lane_assignment: z.enum(['agy-market', 'codex-market']),
525
+ }).strict();
526
+ /** Freeze only a complete, already-scored B/C/D2/R build campaign; the file is one-shot. */
527
+ export async function writeFrozenIdeaV3Protocol(opts) {
528
+ const root = opts.root ?? process.cwd();
529
+ const draftPath = resolve(opts.draftPath);
530
+ const draft = IdeaV3ProtocolDraft.parse(JSON.parse(await readFile(draftPath, 'utf8')));
531
+ const scoresPath = resolveFrom(dirname(draftPath), draft.build_scores);
532
+ const scored = IdeaV3ScoredCampaign.parse(JSON.parse(await readFile(scoresPath, 'utf8')));
533
+ if (scored.set !== 'build')
534
+ throw new Error('protocol freeze requires scored build results');
535
+ const campaignPath = resolveFrom(dirname(scoresPath), scored.campaign);
536
+ const campaign = IdeaV3Campaign.parse(JSON.parse(await readFile(campaignPath, 'utf8')));
537
+ if (campaign.set !== 'build')
538
+ throw new Error('protocol freeze requires a build campaign');
539
+ if (campaign.baseline_provider !== draft.baseline_provider) {
540
+ throw new Error(`draft baseline ${draft.baseline_provider} does not match campaign ${campaign.baseline_provider}`);
541
+ }
542
+ const arms = [...IDEA_V3_ARM_IDS];
543
+ if (arms.some((arm) => !campaign.arms.includes(arm) || !scored.summary.some((item) => item.arm === arm))) {
544
+ throw new Error('protocol freeze requires scored B, C, D2, and R build arms');
545
+ }
546
+ const caseIds = new Set(campaign.observations.map((item) => item.case_id));
547
+ const pairs = new Set(campaign.observations.map((item) => `${item.case_id}:${item.arm}`));
548
+ if (caseIds.size !== 8 || pairs.size !== 8 * arms.length || campaign.observations.length !== pairs.size) {
549
+ throw new Error('protocol freeze requires the complete 8-case × B/C/D2/R build matrix');
550
+ }
551
+ const scoredPairs = new Set(scored.reports.map((item) => `${item.case_id}:${item.arm}`));
552
+ if (scoredPairs.size !== pairs.size || [...pairs].some((pair) => !scoredPairs.has(pair))) {
553
+ throw new Error('protocol freeze requires every build observation to be scored');
554
+ }
555
+ const protocol = IdeaV3Protocol.parse({
556
+ version: 1,
557
+ status: 'FROZEN',
558
+ frozen_at: new Date().toISOString(),
559
+ benchmark_commit: '680fba3',
560
+ build_scores: scoresPath,
561
+ baseline_provider: draft.baseline_provider,
562
+ models: draft.models,
563
+ roles: draft.roles,
564
+ lane_assignment: draft.lane_assignment,
565
+ r_mode: 'research',
566
+ hashes: await ideaV3FreezeHashes(root),
567
+ });
568
+ const path = opts.outPath ?? join(root, 'bench', 'idea-v3-protocol.json');
569
+ try {
570
+ await readFile(path);
571
+ throw new Error(`protocol already frozen at ${path} — refusing to overwrite`);
572
+ }
573
+ catch (error) {
574
+ if (error.code !== 'ENOENT')
575
+ throw error;
576
+ }
577
+ await mkdir(dirname(path), { recursive: true });
578
+ const tmp = `${path}.tmp`;
579
+ await writeFile(tmp, JSON.stringify(protocol, null, 2), 'utf8');
580
+ await rename(tmp, path);
581
+ return { path, protocol };
582
+ }
@@ -3,13 +3,15 @@
3
3
  // happens after the run; recall/calls/wall-clock are automatic. Aggregate recall is MICRO (grill 2026-07-04).
4
4
  import { z } from 'zod';
5
5
  export const ArmScore = z.object({
6
- arm: z.enum(['A', 'B', 'C', 'D', 'E']),
6
+ arm: z.enum(['A', 'B', 'C', 'D', 'E', 'L']),
7
7
  status: z.enum(['scored', 'skipped', 'error']),
8
8
  reason: z.string().optional(), // why skipped / the error message
9
9
  runId: z.string().optional(),
10
10
  seeded: z.number().int().nonnegative().optional(),
11
11
  matched: z.number().int().nonnegative().optional(),
12
12
  recall: z.number().optional(),
13
+ matchedRelaxed: z.number().int().nonnegative().optional(),
14
+ recallRelaxed: z.number().optional(),
13
15
  reported: z.number().int().nonnegative().optional(),
14
16
  unmatched: z.number().int().nonnegative().optional(), // candidate FPs, UNADJUDICATED
15
17
  precision: z.number().nullable().optional(), // null until FP-labelled via resolve
@@ -23,12 +25,14 @@ export const CaseResult = z.object({
23
25
  });
24
26
  /** Per-arm rollup across all cases. `recall` = micro (total matched / total seeded); `recallMacro` = mean of per-case. */
25
27
  export const SummaryRow = z.object({
26
- arm: z.enum(['A', 'B', 'C', 'D', 'E']),
28
+ arm: z.enum(['A', 'B', 'C', 'D', 'E', 'L']),
27
29
  cases: z.number().int().nonnegative(), // scored cases
28
30
  seeded: z.number().int().nonnegative(),
29
31
  matched: z.number().int().nonnegative(),
30
32
  recall: z.number(), // micro
31
33
  recallMacro: z.number(),
34
+ matchedRelaxed: z.number().int().nonnegative().optional(),
35
+ recallRelaxed: z.number().optional(),
32
36
  reported: z.number().int().nonnegative(),
33
37
  unmatched: z.number().int().nonnegative(),
34
38
  precision: z.number().nullable(), // micro precision if any labels, else null
@@ -39,7 +43,7 @@ export const BenchResult = z.object({
39
43
  suite: z.string(),
40
44
  set: z.string(),
41
45
  at: z.string(),
42
- arms: z.array(z.enum(['A', 'B', 'C', 'D', 'E'])),
46
+ arms: z.array(z.enum(['A', 'B', 'C', 'D', 'E', 'L'])),
43
47
  cases: z.array(CaseResult),
44
48
  summary: z.array(SummaryRow),
45
49
  });
@@ -53,6 +57,8 @@ export function summarize(cases, arms) {
53
57
  const reported = sum((a) => a.reported ?? 0);
54
58
  const recallMacro = scored.length ? scored.reduce((s, a) => s + (a.recall ?? 0), 0) / scored.length : 0;
55
59
  const precisions = scored.map((a) => a.precision).filter((p) => typeof p === 'number');
60
+ const relaxedComplete = scored.every((a) => a.matchedRelaxed !== undefined);
61
+ const matchedRelaxed = relaxedComplete ? sum((a) => a.matchedRelaxed ?? 0) : undefined;
56
62
  return {
57
63
  arm,
58
64
  cases: scored.length,
@@ -60,6 +66,7 @@ export function summarize(cases, arms) {
60
66
  matched,
61
67
  recall: seeded ? matched / seeded : 0,
62
68
  recallMacro,
69
+ ...(matchedRelaxed === undefined ? {} : { matchedRelaxed, recallRelaxed: seeded ? matchedRelaxed / seeded : 0 }),
63
70
  reported,
64
71
  unmatched: sum((a) => a.unmatched ?? 0),
65
72
  precision: precisions.length ? precisions.reduce((s, p) => s + p, 0) / precisions.length : null,