skillfid 0.1.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.
@@ -0,0 +1,486 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { access, mkdir, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { assertBaselineCoverage, findCompatibleBaseline, writeBaseline } from './baseline.js';
6
+ import { AsyncLimiter, mapConcurrent } from './concurrency.js';
7
+ import { CopilotSdkRunner } from './copilot-sdk.js';
8
+ import { loadDataset } from './dataset.js';
9
+ import { buildSkillAuditPrompt, parseSkillAudit } from './diagnosis.js';
10
+ import { copyDirectory, hashDirectory, writeJson, writeJsonl } from './files.js';
11
+ import { buildJudgePrompt, parseJudgeResponse } from './judge.js';
12
+ import { operationId, OperationJournal } from './journal.js';
13
+ import { skillPrompt, subjectPrompt } from './prompts.js';
14
+ import { runStructured } from './structured.js';
15
+
16
+ export class EvaluationError extends Error {}
17
+ const PERFECT_SCORE_TOLERANCE = 1e-9;
18
+ const SKILL_INVOCATION_MODES = new Set(['auto', 'explicit']);
19
+ export const EVALUATOR_VERSION = '0.8.0';
20
+
21
+ export async function evaluateDataset({ datasetPath, skillPath, baselineRoot = 'baselines', outputRoot = 'runs', workRoot = '.work/eval', options = {}, subjectRunner, judgeRunner, progress }) {
22
+ const settings = { model: undefined, judgeModel: undefined, reasoningEffort: undefined, skillInvocation: 'auto', trialsPerQuestion: 3, maxAiCredits: 100, timeoutSeconds: 600, timeoutRetries: 1, maxAttempts: 2, diagnoseFailures: true, concurrency: 10, resume: true, ...options };
23
+ if (!Number.isInteger(settings.trialsPerQuestion) || settings.trialsPerQuestion < 1) throw new Error('trialsPerQuestion must be at least 1');
24
+ if (!SKILL_INVOCATION_MODES.has(settings.skillInvocation)) throw new Error(`skillInvocation must be one of: ${[...SKILL_INVOCATION_MODES].join(', ')}`);
25
+ progress?.({ type: 'start', workflow: 'evaluation', title: 'Evaluating skill', current: 'Reading calibrated questions', progress: { done: 0, total: 0, label: 'questions evaluated' } });
26
+ const dataset = await loadDataset(datasetPath);
27
+ settings.model ??= dataset.manifest.calibration.model;
28
+ settings.judgeModel ??= dataset.manifest.calibration.judgeModel;
29
+ settings.reasoningEffort ??= dataset.manifest.calibration.reasoningEffort;
30
+ assertDatasetCalibrated(dataset.manifest);
31
+ const dashboard = { questions: dataset.questions.length, trials: settings.trialsPerQuestion, completed: 0, completedQuestions: 0, freshQuestions: 0, resumed: 0, diagnoses: 0, answering: 0, judging: 0, diagnosing: 0, trackCalls: false, limiter: undefined, scores: { closedBook: [], skill: [] } };
32
+ progress?.({ type: 'update', workflow: 'evaluation', title: 'Evaluating skill', current: 'Preparing isolated workspaces', progress: evaluationProgress(dashboard) });
33
+ const skillSource = path.resolve(skillPath);
34
+ try { await access(path.join(skillSource, 'SKILL.md')); } catch { throw new EvaluationError(`Skill directory has no SKILL.md: ${skillPath}`); }
35
+
36
+ const runId = `run_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
37
+ const runWork = path.resolve(workRoot, runId);
38
+ const home = path.join(runWork, 'home');
39
+ const closedWorkspace = path.join(runWork, 'isolation-check', 'closed');
40
+ const skillWorkspace = path.join(runWork, 'isolation-check', 'skill');
41
+ await mkdir(closedWorkspace, { recursive: true });
42
+ await mkdir(skillWorkspace, { recursive: true });
43
+ await copyDirectory(skillSource, path.join(skillWorkspace, '.github', 'skills', path.basename(skillSource)));
44
+ const generatedSubject = !subjectRunner;
45
+ const generatedJudge = !judgeRunner;
46
+ const limiter = new AsyncLimiter(settings.concurrency, () => {
47
+ if (dashboard.trackCalls) progress?.({ type: 'update', workflow: 'evaluation', current: evaluationAction(dashboard), progress: evaluationProgress(dashboard) });
48
+ });
49
+ dashboard.limiter = limiter;
50
+ const baseSubject = subjectRunner ?? new CopilotSdkRunner({ model: settings.model, reasoningEffort: settings.reasoningEffort, timeoutSeconds: settings.timeoutSeconds, maxTimeoutRetries: settings.timeoutRetries, isolatedHome: path.join(home, 'subject'), progress });
51
+ const baseJudge = judgeRunner ?? new CopilotSdkRunner({ model: settings.judgeModel, reasoningEffort: settings.reasoningEffort, timeoutSeconds: settings.timeoutSeconds, maxTimeoutRetries: settings.timeoutRetries, isolatedHome: path.join(home, 'judge'), progress });
52
+ const answers = [];
53
+ const judgments = [];
54
+ const diagnoses = [];
55
+ const evidenceById = new Map(dataset.evidence.map((item) => [item.evidenceId, item]));
56
+ let journal;
57
+ try {
58
+ progress?.({ type: 'update', workflow: 'evaluation', current: 'Checking skill isolation', progress: evaluationProgress(dashboard) });
59
+ const closedSkills = await baseSubject.listSkills(closedWorkspace);
60
+ const skillSkills = await baseSubject.listSkills(skillWorkspace);
61
+ assertEvaluationIsolation(closedSkills, skillSkills);
62
+ const projectSkillName = projectSkillCommandName(skillSkills);
63
+ const copilotCliVersion = await baseSubject.version();
64
+ const compatibility = baselineCompatibility(dataset.datasetId, settings, copilotCliVersion);
65
+ const baseline = await findCompatibleBaseline({ outputRoot: baselineRoot, compatibility });
66
+ if (!baseline) throw new EvaluationError(`No compatible closed-book baseline found in ${path.resolve(baselineRoot)}.\n\nRun:\n${baselineCreationCommand(datasetPath, baselineRoot, settings)}`);
67
+ assertBaselineCoverage(baseline, dataset.questions, settings.trialsPerQuestion);
68
+ const skillHash = await hashDirectory(skillSource);
69
+ const operationInputs = { datasetId: dataset.datasetId, baselineId: baseline.manifest.baselineId, skillHash, evaluatorVersion: EVALUATOR_VERSION, copilotCliVersion, model: settings.model, judgeModel: settings.judgeModel, reasoningEffort: settings.reasoningEffort, skillInvocation: settings.skillInvocation, trialsPerQuestion: settings.trialsPerQuestion, diagnoseFailures: settings.diagnoseFailures };
70
+ const baseOperationId = operationId('evaluation', operationInputs);
71
+ const journalPath = path.resolve(workRoot, 'operations.sqlite');
72
+ journal = await OperationJournal.open(journalPath);
73
+ const activeOperationId = settings.resume ? journal.findResumableOperation('evaluation', operationInputs)?.operationId ?? baseOperationId : `${baseOperationId}_${randomUUID().slice(0, 8)}`;
74
+ journal.startOperation({ operationId: activeOperationId, kind: 'evaluation', inputs: operationInputs, config: { concurrency: settings.concurrency, timeoutRetries: settings.timeoutRetries, fresh: !settings.resume } });
75
+ progress?.({ type: 'operation', workflow: 'evaluation', message: `Evaluation operation ${activeOperationId}; concurrency ${settings.concurrency}; journal ${journalPath}`, details: { operationId: activeOperationId, concurrency: settings.concurrency, journalPath } });
76
+ dashboard.trackCalls = true;
77
+ progress?.({ type: 'update', workflow: 'evaluation', current: 'Starting question evaluations', progress: evaluationProgress(dashboard) });
78
+ const conditionPipelinesPerQuestion = settings.trialsPerQuestion * 2;
79
+ const schedulingWindow = settings.concurrency + settings.trialsPerQuestion;
80
+ const baselineJudgments = new Map(baseline.judgments.map((judgment) => [`${judgment.testId}:${judgment.trial}`, judgment]));
81
+ const questionStates = new Map(dataset.questions.map((question) => {
82
+ const calibration = dataset.calibrations.find((item) => item.testId === question.testId);
83
+ const documentIds = [...new Set(question.evidenceIds.map((id) => evidenceById.get(id).documentId))].sort();
84
+ const conditionResults = baseline.answers.filter((answer) => answer.testId === question.testId).map((answer) => ({ answer, judgment: baselineJudgments.get(`${answer.testId}:${answer.trial}`), reused: true }));
85
+ return [question.testId, { question, calibration, source: documentIds.map((id) => dataset.documents[id]), conditionResults, diagnosis: undefined }];
86
+ }));
87
+ const conditionUnits = dataset.questions.flatMap((question) => Array.from({ length: settings.trialsPerQuestion }, (_, trial) => ({ question, trial, condition: 'skill', prompt: skillPrompt(question.question, settings.skillInvocation, projectSkillName) })));
88
+ const diagnosisTasks = [];
89
+ let asynchronousFailure;
90
+ try {
91
+ await mapConcurrent(conditionUnits, schedulingWindow, async ({ question, trial, condition, prompt }) => {
92
+ if (asynchronousFailure) throw asynchronousFailure;
93
+ const state = questionStates.get(question.testId);
94
+ const output = await limiter.run(() => runConditionPipeline({ journal, operationId: activeOperationId, runWork, question, trial, condition, prompt, source: state.source, skillSource, subjectRunner: baseSubject, judgeRunner: baseJudge, maxAttempts: settings.maxAttempts, progress, dashboard }));
95
+ state.conditionResults.push(output);
96
+ if (state.conditionResults.length !== conditionPipelinesPerQuestion) return;
97
+ const conditionScores = { closedBook: [], skill: [] };
98
+ const trialAnswers = { closedBook: [], skill: [] };
99
+ const trialJudgments = { closedBook: [], skill: [] };
100
+ for (const result of state.conditionResults) {
101
+ conditionScores[result.answer.condition].push(result.answer.score);
102
+ trialAnswers[result.answer.condition].push(result.answer);
103
+ trialJudgments[result.judgment.condition].push(result.judgment);
104
+ }
105
+ for (const conditionName of Object.keys(trialAnswers)) {
106
+ trialAnswers[conditionName].sort((left, right) => left.trial - right.trial);
107
+ trialJudgments[conditionName].sort((left, right) => left.trial - right.trial);
108
+ }
109
+ const averages = { ...Object.fromEntries(Object.entries(conditionScores).map(([conditionName, scores]) => [conditionName, scores.reduce((sum, score) => sum + score, 0) / scores.length])), calibrationScore: state.calibration.score };
110
+ let diagnosisTask = Promise.resolve(undefined);
111
+ if (averages.skill < 1 - PERFECT_SCORE_TOLERANCE && settings.diagnoseFailures) {
112
+ diagnosisTask = limiter.run(async () => {
113
+ dashboard.diagnosing += 1;
114
+ progress?.({ type: 'update', workflow: 'evaluation', current: evaluationAction(dashboard), progress: evaluationProgress(dashboard) });
115
+ try { return await runDiagnosisJob({ journal, operationId: activeOperationId, question, source: state.source, averages, trialAnswers, trialJudgments, skillSource, diagnosisRoot: path.join(runWork, 'diagnoses', question.testId), subjectRunner: baseSubject, judgeRunner: baseJudge, maxAttempts: settings.maxAttempts, skillInvocation: settings.skillInvocation }); }
116
+ finally { dashboard.diagnosing -= 1; }
117
+ });
118
+ dashboard.diagnoses += 1;
119
+ }
120
+ diagnosisTasks.push(diagnosisTask.then((diagnosis) => {
121
+ state.diagnosis = diagnosis;
122
+ dashboard.completedQuestions += 1;
123
+ if (state.conditionResults.some((result) => !result.reused)) dashboard.freshQuestions += 1;
124
+ progress?.({ type: 'update', workflow: 'evaluation', current: evaluationAction(dashboard), progress: evaluationProgress(dashboard) });
125
+ return { status: 'fulfilled' };
126
+ }, (error) => {
127
+ asynchronousFailure ??= error;
128
+ return { status: 'rejected', reason: error };
129
+ }));
130
+ });
131
+ const diagnosisResults = await Promise.all(diagnosisTasks);
132
+ const diagnosisFailure = diagnosisResults.find((result) => result.status === 'rejected');
133
+ if (diagnosisFailure) throw diagnosisFailure.reason;
134
+ }
135
+ catch (error) {
136
+ await Promise.all(diagnosisTasks);
137
+ progress?.({ type: 'error', workflow: 'evaluation', message: error.message, progress: evaluationProgress(dashboard) });
138
+ throw error;
139
+ }
140
+ const questionResults = dataset.questions.map((question) => {
141
+ const state = questionStates.get(question.testId);
142
+ return { conditionResults: state.conditionResults, diagnosis: state.diagnosis };
143
+ });
144
+ for (const result of questionResults) {
145
+ for (const conditionResult of result.conditionResults) { answers.push(conditionResult.answer); judgments.push(conditionResult.judgment); }
146
+ if (result.diagnosis) diagnoses.push(result.diagnosis);
147
+ }
148
+ journal.assertAllJobsCompleted(activeOperationId);
149
+ const finalCounts = journal.jobCounts(activeOperationId);
150
+ progress?.({ type: 'checkpoint', workflow: 'evaluation', current: 'Writing evaluation results', progress: evaluationProgress(dashboard), details: { jobs: finalCounts } });
151
+ const resolvedOutputRoot = path.resolve(outputRoot);
152
+ await mkdir(resolvedOutputRoot, { recursive: true });
153
+ const destination = path.join(resolvedOutputRoot, runId);
154
+ await mkdir(destination, { recursive: false });
155
+ await writeJsonl(path.join(destination, 'answers.jsonl'), answers);
156
+ await writeJsonl(path.join(destination, 'judgments.jsonl'), judgments);
157
+ await writeJsonl(path.join(destination, 'diagnoses.jsonl'), diagnoses);
158
+ const summary = buildSummary(dataset, answers, diagnoses);
159
+ await writeJson(path.join(destination, 'summary.json'), summary);
160
+ await writeJson(path.join(destination, 'manifest.json'), { runId, createdAt: new Date().toISOString(), datasetId: dataset.datasetId, baselineId: baseline.manifest.baselineId, skillHash, evaluatorVersion: EVALUATOR_VERSION, copilotCliVersion, model: settings.model, judgeModel: settings.judgeModel, reasoningEffort: settings.reasoningEffort, skillInvocation: settings.skillInvocation, trialsPerQuestion: settings.trialsPerQuestion, timeoutSeconds: settings.timeoutSeconds, timeoutRetries: settings.timeoutRetries, diagnoseFailures: settings.diagnoseFailures });
161
+ journal.completeOperation(activeOperationId, destination);
162
+ progress?.({ type: 'complete', workflow: 'evaluation', title: 'Evaluation complete', summary: [`Closed book ${formatScore(summary.conditionScores.closedBook)}, skill ${formatScore(summary.conditionScores.skill)}`, `Uplift ${formatSignedScore(summary.skillUplift).replace('%', ' percentage points')}`, `${formatCount(finalCounts.completed, 'job')} completed, ${finalCounts.failed} failed`], details: { destination, operationId: activeOperationId } });
163
+ return destination;
164
+ } finally {
165
+ journal?.close();
166
+ if (generatedSubject) await baseSubject.close();
167
+ if (generatedJudge) await baseJudge.close();
168
+ await rm(runWork, { recursive: true, force: true });
169
+ if (generatedSubject) await rm(home, { recursive: true, force: true });
170
+ }
171
+ }
172
+
173
+ export async function evaluateBaseline({ datasetPath, outputRoot = 'baselines', workRoot = '.work/baseline', options = {}, subjectRunner, judgeRunner, progress }) {
174
+ const settings = { model: undefined, judgeModel: undefined, reasoningEffort: undefined, trialsPerQuestion: 3, timeoutSeconds: 600, timeoutRetries: 1, maxAttempts: 2, concurrency: 10, resume: true, ...options };
175
+ if (!Number.isInteger(settings.trialsPerQuestion) || settings.trialsPerQuestion < 1) throw new Error('trialsPerQuestion must be at least 1');
176
+ progress?.({ type: 'start', workflow: 'baseline', title: 'Evaluating closed-book baseline', current: 'Reading calibrated questions', progress: { done: 0, total: 0, label: 'questions evaluated' } });
177
+ const dataset = await loadDataset(datasetPath);
178
+ settings.model ??= dataset.manifest.calibration.model;
179
+ settings.judgeModel ??= dataset.manifest.calibration.judgeModel;
180
+ settings.reasoningEffort ??= dataset.manifest.calibration.reasoningEffort;
181
+ assertDatasetCalibrated(dataset.manifest);
182
+ const dashboard = { questions: dataset.questions.length, trials: settings.trialsPerQuestion, completed: 0, completedQuestions: 0, freshQuestions: 0, resumed: 0, diagnoses: 0, answering: 0, judging: 0, diagnosing: 0, trackCalls: false, limiter: undefined, scores: { closedBook: [] } };
183
+ const workId = `baseline_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
184
+ const runWork = path.resolve(workRoot, workId);
185
+ const home = path.join(runWork, 'home');
186
+ const closedWorkspace = path.join(runWork, 'isolation-check', 'closed');
187
+ await mkdir(closedWorkspace, { recursive: true });
188
+ const generatedSubject = !subjectRunner;
189
+ const generatedJudge = !judgeRunner;
190
+ const limiter = new AsyncLimiter(settings.concurrency, () => {
191
+ if (dashboard.trackCalls) progress?.({ type: 'update', workflow: 'baseline', current: evaluationAction(dashboard), progress: evaluationProgress(dashboard) });
192
+ });
193
+ dashboard.limiter = limiter;
194
+ const baseSubject = subjectRunner ?? new CopilotSdkRunner({ model: settings.model, reasoningEffort: settings.reasoningEffort, timeoutSeconds: settings.timeoutSeconds, maxTimeoutRetries: settings.timeoutRetries, isolatedHome: path.join(home, 'subject'), progress });
195
+ const baseJudge = judgeRunner ?? new CopilotSdkRunner({ model: settings.judgeModel, reasoningEffort: settings.reasoningEffort, timeoutSeconds: settings.timeoutSeconds, maxTimeoutRetries: settings.timeoutRetries, isolatedHome: path.join(home, 'judge'), progress });
196
+ let journal;
197
+ try {
198
+ assertClosedBookIsolation(await baseSubject.listSkills(closedWorkspace));
199
+ const copilotCliVersion = await baseSubject.version();
200
+ const compatibility = baselineCompatibility(dataset.datasetId, settings, copilotCliVersion);
201
+ const operationInputs = compatibility;
202
+ const baseOperationId = operationId('baseline', operationInputs);
203
+ const journalPath = path.resolve(workRoot, 'operations.sqlite');
204
+ journal = await OperationJournal.open(journalPath);
205
+ const activeOperationId = settings.resume ? journal.findResumableOperation('baseline', operationInputs)?.operationId ?? baseOperationId : `${baseOperationId}_${randomUUID().slice(0, 8)}`;
206
+ journal.startOperation({ operationId: activeOperationId, kind: 'baseline', inputs: operationInputs, config: { concurrency: settings.concurrency, timeoutRetries: settings.timeoutRetries, fresh: !settings.resume } });
207
+ progress?.({ type: 'operation', workflow: 'baseline', message: `Baseline operation ${activeOperationId}; concurrency ${settings.concurrency}; journal ${journalPath}`, details: { operationId: activeOperationId, concurrency: settings.concurrency, journalPath } });
208
+ dashboard.trackCalls = true;
209
+ const evidenceById = new Map(dataset.evidence.map((item) => [item.evidenceId, item]));
210
+ const units = dataset.questions.flatMap((question) => Array.from({ length: settings.trialsPerQuestion }, (_, trial) => ({ question, trial, condition: 'closedBook', prompt: subjectPrompt(question.question) })));
211
+ const resultsByQuestion = new Map(dataset.questions.map((question) => [question.testId, 0]));
212
+ const results = await mapConcurrent(units, settings.concurrency + settings.trialsPerQuestion, async ({ question, trial, condition, prompt }) => {
213
+ const documentIds = [...new Set(question.evidenceIds.map((id) => evidenceById.get(id).documentId))].sort();
214
+ const output = await limiter.run(() => runConditionPipeline({ journal, operationId: activeOperationId, runWork, question, trial, condition, prompt, source: documentIds.map((id) => dataset.documents[id]), subjectRunner: baseSubject, judgeRunner: baseJudge, maxAttempts: settings.maxAttempts, progress, dashboard }));
215
+ const completed = resultsByQuestion.get(question.testId) + 1;
216
+ resultsByQuestion.set(question.testId, completed);
217
+ if (completed === settings.trialsPerQuestion) { dashboard.completedQuestions += 1; dashboard.freshQuestions += 1; }
218
+ return output;
219
+ });
220
+ const answers = results.map((result) => result.answer);
221
+ const judgments = results.map((result) => result.judgment);
222
+ assertBaselineCoverage({ answers, judgments }, dataset.questions, settings.trialsPerQuestion);
223
+ journal.assertAllJobsCompleted(activeOperationId);
224
+ const finalCounts = journal.jobCounts(activeOperationId);
225
+ const destination = await writeBaseline({ outputRoot, compatibility, answers, judgments });
226
+ journal.completeOperation(activeOperationId, destination);
227
+ const score = answers.reduce((sum, answer) => sum + answer.score, 0) / answers.length;
228
+ progress?.({ type: 'complete', workflow: 'baseline', title: 'Baseline complete', summary: [`Closed book ${formatScore(score)}`, `${formatCount(finalCounts.completed, 'job')} completed, ${finalCounts.failed} failed`], details: { destination, operationId: activeOperationId } });
229
+ return destination;
230
+ } finally {
231
+ journal?.close();
232
+ if (generatedSubject) await baseSubject.close();
233
+ if (generatedJudge) await baseJudge.close();
234
+ await rm(runWork, { recursive: true, force: true });
235
+ if (generatedSubject) await rm(home, { recursive: true, force: true });
236
+ }
237
+ }
238
+
239
+ async function runConditionPipeline({ journal, operationId: id, runWork, question, trial, condition, prompt, source, skillSource, subjectRunner, judgeRunner, maxAttempts, progress, dashboard }) {
240
+ const entityId = `${question.testId}:${trial}:${condition}`;
241
+ dashboard.answering += 1;
242
+ progress?.({ type: 'update', workflow: 'evaluation', current: evaluationAction(dashboard), progress: evaluationProgress(dashboard) });
243
+ let stage = 'answering';
244
+ try {
245
+ const workspace = path.join(runWork, 'trials', question.testId, String(trial), condition, condition === 'skill' ? 'skill' : condition);
246
+ await mkdir(workspace, { recursive: true });
247
+ if (condition === 'skill') await copyDirectory(skillSource, path.join(workspace, '.github', 'skills', path.basename(skillSource)));
248
+ const answerResult = await runAnswerJob({ journal, operationId: id, entityId, question, trial, condition, prompt, workspace, subjectRunner });
249
+ dashboard.answering -= 1; dashboard.judging += 1;
250
+ stage = 'judging';
251
+ progress?.({ type: 'update', workflow: 'evaluation', current: evaluationAction(dashboard), progress: evaluationProgress(dashboard) });
252
+ const judgeWorkspace = path.join(runWork, 'trials', question.testId, String(trial), condition, 'judge');
253
+ await mkdir(judgeWorkspace, { recursive: true });
254
+ const judgmentResult = await runJudgmentJob({ journal, operationId: id, entityId, question, trial, condition, source, answer: answerResult.answer, workspace: judgeWorkspace, judgeRunner, maxAttempts });
255
+ const answer = { ...answerResult.answer, score: judgmentResult.judgment.score };
256
+ dashboard.judging -= 1; dashboard.completed += 1; dashboard.scores[condition].push(judgmentResult.judgment.score);
257
+ stage = 'completed';
258
+ if (answerResult.reused && judgmentResult.reused) dashboard.resumed += 1;
259
+ return { answer, judgment: judgmentResult.judgment, reused: answerResult.reused && judgmentResult.reused };
260
+ } catch (error) {
261
+ if (stage === 'judging') dashboard.judging -= 1;
262
+ else if (stage === 'answering') dashboard.answering -= 1;
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ async function runAnswerJob({ journal, operationId, entityId, question, trial, condition, prompt, workspace, subjectRunner }) {
268
+ const job = journal.ensureJob({ operationId, stage: 'answer', entityId, inputs: { question, trial, condition, prompt } });
269
+ if (job.status === 'completed') return { answer: job.output, reused: true };
270
+ return runJournalJob({ journal, job, label: `answer job ${entityId}`, run: async () => {
271
+ const result = await subjectRunner.run(workspace, prompt);
272
+ return { testId: question.testId, trial, condition, answer: result.answer, durationSeconds: result.durationSeconds };
273
+ }, resultKey: 'answer' });
274
+ }
275
+
276
+ async function runJudgmentJob({ journal, operationId, entityId, question, trial, condition, source, answer, workspace, judgeRunner, maxAttempts }) {
277
+ const job = journal.ensureJob({ operationId, stage: 'judgment', entityId, inputs: { question, trial, condition, source, answer } });
278
+ if (job.status === 'completed') return { judgment: job.output, reused: true };
279
+ return runJournalJob({ journal, job, label: `judgment job ${entityId}`, run: async () => {
280
+ const { score, judgment } = await judgeAnswer({ runner: judgeRunner, workspace, question, source, answer: answer.answer, maxAttempts });
281
+ return { testId: question.testId, trial, condition, criterionResults: judgment.criterionResults, unsupportedClaims: judgment.unsupportedClaims, score };
282
+ }, resultKey: 'judgment' });
283
+ }
284
+
285
+ async function runJournalJob({ journal, job, label, run, resultKey }) {
286
+ const workerId = randomUUID();
287
+ if (!journal.claimJob(job.jobId, workerId)) throw new EvaluationError(`Could not claim ${label}`);
288
+ const lease = setInterval(() => journal.renewLease(job.jobId, workerId), 5 * 60 * 1000);
289
+ lease.unref();
290
+ try {
291
+ const output = await run();
292
+ journal.completeJob(job.jobId, workerId, output);
293
+ return { [resultKey]: output, reused: false };
294
+ } catch (error) {
295
+ journal.failJob(job.jobId, workerId, error);
296
+ throw error;
297
+ } finally { clearInterval(lease); }
298
+ }
299
+
300
+ async function runDiagnosisJob({ journal, operationId: id, question, ...inputs }) {
301
+ const job = journal.ensureJob({ operationId: id, stage: 'diagnosis', entityId: question.testId, inputs: { question, averages: inputs.averages, trialAnswers: inputs.trialAnswers, trialJudgments: inputs.trialJudgments, skillInvocation: inputs.skillInvocation } });
302
+ if (job.status === 'completed') return job.output;
303
+ const workerId = randomUUID();
304
+ if (!journal.claimJob(job.jobId, workerId)) throw new EvaluationError(`Could not claim diagnosis job ${question.testId}`);
305
+ const lease = setInterval(() => journal.renewLease(job.jobId, workerId), 5 * 60 * 1000);
306
+ lease.unref();
307
+ try { const output = await diagnoseFailure({ question, ...inputs }); journal.completeJob(job.jobId, workerId, output); return output; }
308
+ catch (error) { journal.failJob(job.jobId, workerId, error); throw error; }
309
+ finally { clearInterval(lease); }
310
+ }
311
+
312
+ async function settleAll(promises) {
313
+ const settled = await Promise.allSettled(promises);
314
+ const failure = settled.find((result) => result.status === 'rejected');
315
+ if (failure) throw failure.reason;
316
+ return settled.map((result) => result.value);
317
+ }
318
+
319
+ function evaluationProgress(state) {
320
+ return { done: state.completedQuestions, total: state.questions, etaDone: state.freshQuestions, label: 'questions evaluated' };
321
+ }
322
+
323
+ function evaluationAction(state) {
324
+ const actions = [];
325
+ if (state.limiter?.stats.queued) actions.push(`${formatCount(state.limiter.stats.queued, 'job')} queued`);
326
+ if (state.answering) actions.push(`${formatCount(state.answering, 'answer')} in progress`);
327
+ if (state.judging) actions.push(`${formatCount(state.judging, 'judgment')} in progress`);
328
+ if (state.diagnosing) actions.push(`${formatCount(state.diagnosing, 'diagnosis', 'diagnoses')} in progress`);
329
+ if (!actions.length && state.resumed) return `Reused ${formatCount(state.resumed, 'completed evaluation')}`;
330
+ return actions.join(' · ') || 'Preparing evaluations';
331
+ }
332
+
333
+ function averageScore(scores) {
334
+ return scores.length ? formatScore(scores.reduce((sum, score) => sum + score, 0) / scores.length) : '--';
335
+ }
336
+
337
+ export function formatScore(score) {
338
+ return `${formatPercentage(score)}%`;
339
+ }
340
+
341
+ function formatSignedScore(score) {
342
+ const value = formatPercentage(score);
343
+ return `${score >= 0 ? '+' : ''}${value}%`;
344
+ }
345
+
346
+ function formatPercentage(score) {
347
+ if (score === 0 || Math.abs(score) === 1) return String(score * 100);
348
+ const percentage = score * 100;
349
+ let precision = 3;
350
+ let formatted = percentage.toFixed(precision);
351
+ while (Number(formatted) === Math.sign(score) * 100 && precision < 10) {
352
+ precision += 1;
353
+ formatted = percentage.toFixed(precision);
354
+ }
355
+ return formatted.replace(/\.0+$|(\.\d*?)0+$/, '$1');
356
+ }
357
+
358
+ function formatCount(count, singular, plural = `${singular}s`) {
359
+ return `${count} ${count === 1 ? singular : plural}`;
360
+ }
361
+
362
+ function conditionLabel(condition) {
363
+ return condition === 'closedBook' ? 'closed book' : 'skill';
364
+ }
365
+
366
+ function humanize(value) {
367
+ return value.replaceAll('_', ' ');
368
+ }
369
+
370
+ async function judgeAnswer({ runner, workspace, question, source, answer, maxAttempts }) {
371
+ const judgment = await runStructured({ runner, workspace, prompt: buildJudgePrompt({ question: question.question, source, candidateAnswer: answer, rubric: question.rubric }), validator: (response) => parseJudgeResponse(response, question.rubric.length), maxAttempts });
372
+ const score = judgment.criterionResults.reduce((sum, result) => sum + question.rubric[result.criterionIndex].weight * result.score, 0);
373
+ return { score, judgment };
374
+ }
375
+
376
+ async function diagnoseFailure({ question, source, averages, trialJudgments, skillSource, diagnosisRoot, subjectRunner, judgeRunner, maxAttempts, skillInvocation }) {
377
+ const skillWorkspace = path.join(diagnosisRoot, 'skill');
378
+ const judgeWorkspace = path.join(diagnosisRoot, 'judge');
379
+ await mkdir(skillWorkspace, { recursive: true });
380
+ await mkdir(judgeWorkspace, { recursive: true });
381
+ await copyDirectory(skillSource, path.join(skillWorkspace, '.github', 'skills', path.basename(skillSource)));
382
+ const criterionFailures = summarizeCriterionFailures(question.rubric, trialJudgments.skill);
383
+ const skillTrialScores = trialJudgments.skill.map((judgment) => judgment.score);
384
+ const record = { testId: question.testId, failedCriteria: criterionFailures.map((item) => item.criterionIndex), criterionFailures, trialScores: skillTrialScores, category: 'unknown', confidence: 0, evidence: [`closedBook score: ${averages.closedBook.toFixed(3)}`, `skill score: ${averages.skill.toFixed(3)}`, `dataset calibration score: ${averages.calibrationScore.toFixed(3)}`], diagnosticReruns: [], fixTargets: [] };
385
+ if (averages.calibrationScore < 1 - PERFECT_SCORE_TOLERANCE) { record.category = 'test_or_model_limitation'; record.confidence = 1; record.evidence.push('Stored dataset calibration is not fully correct.'); return record; }
386
+ const audit = await runStructured({ runner: judgeRunner, workspace: judgeWorkspace, prompt: await buildSkillAuditPrompt({ skillPath: skillSource, question: question.question, source }), validator: parseSkillAudit, maxAttempts });
387
+ record.evidence.push(`Skill content audit: ${audit.rationale}`);
388
+ if (!audit.present || !audit.complete || audit.contradictory) {
389
+ record.category = 'skill_knowledge_gap'; record.confidence = 0.95;
390
+ record.fixTargets = (audit.files.length ? audit.files : ['SKILL.md']).map((file) => ({ file, recommendation: audit.rationale }));
391
+ return record;
392
+ }
393
+ if (skillInvocation === 'auto') {
394
+ const forced = await subjectRunner.run(skillWorkspace, [`You must use the project skill in ${path.basename(skillSource)} to answer.`, subjectPrompt(question.question)].join('\n'));
395
+ const forcedJudgment = await judgeAnswer({ runner: judgeRunner, workspace: judgeWorkspace, question, source, answer: forced.answer, maxAttempts });
396
+ record.diagnosticReruns.push({ kind: 'forcedActivation', answer: forced.answer, score: forcedJudgment.score, criterionResults: forcedJudgment.judgment.criterionResults, unsupportedClaims: forcedJudgment.judgment.unsupportedClaims });
397
+ if (forcedJudgment.score === 1) {
398
+ record.category = 'skill_activation'; record.confidence = 0.9;
399
+ record.fixTargets = [{ file: 'SKILL.md', recommendation: 'Improve the skill name and description so this question activates it.' }];
400
+ return record;
401
+ }
402
+ }
403
+ if (audit.files.length) {
404
+ const direct = await subjectRunner.run(skillWorkspace, [`Use the following project skill files before answering: ${audit.files.join(', ')}`, subjectPrompt(question.question)].join('\n'));
405
+ const directJudgment = await judgeAnswer({ runner: judgeRunner, workspace: judgeWorkspace, question, source, answer: direct.answer, maxAttempts });
406
+ record.diagnosticReruns.push({ kind: 'directRetrieval', answer: direct.answer, score: directJudgment.score, criterionResults: directJudgment.judgment.criterionResults, unsupportedClaims: directJudgment.judgment.unsupportedClaims });
407
+ if (directJudgment.score === 1) {
408
+ record.category = 'skill_retrieval'; record.confidence = 0.9;
409
+ record.fixTargets = audit.files.map((file) => ({ file, recommendation: 'Make this content easier for the skill to locate from the main instructions.' }));
410
+ return record;
411
+ }
412
+ }
413
+ const unsupported = unique(trialJudgments.skill.flatMap((judgment) => judgment.unsupportedClaims));
414
+ if (unsupported.length) { record.category = 'grounding'; record.confidence = 0.75; record.evidence.push(`Unsupported claims: ${unsupported.join('; ')}`); }
415
+ else if (new Set(skillTrialScores).size > 1) { record.category = 'answer_variability'; record.confidence = 1; record.evidence.push(`Skill trial scores varied from ${Math.min(...skillTrialScores).toFixed(3)} to ${Math.max(...skillTrialScores).toFixed(3)}.`); }
416
+ else record.evidence.push('Knowledge exists and targeted reruns still failed; interpretation and application could not be isolated.');
417
+ return record;
418
+ }
419
+
420
+ export function summarizeCriterionFailures(rubric, judgments) {
421
+ return rubric.map((_, criterionIndex) => {
422
+ const results = judgments.map((judgment) => judgment.criterionResults.find((result) => result.criterionIndex === criterionIndex)).filter(Boolean);
423
+ const failed = results.filter((result) => result.score < 1);
424
+ return {
425
+ criterionIndex,
426
+ averageScore: failed.length ? results.reduce((sum, result) => sum + result.score, 0) / results.length : 1,
427
+ failedTrials: failed.length,
428
+ totalTrials: results.length,
429
+ rationales: unique(failed.map((result) => result.rationale)),
430
+ };
431
+ }).filter((result) => result.failedTrials > 0);
432
+ }
433
+
434
+ function unique(values) {
435
+ return [...new Set(values)];
436
+ }
437
+
438
+ function assertEvaluationIsolation(closedSkills, skillSkills) {
439
+ for (const [name, skills] of [['closed-book', closedSkills], ['skill', skillSkills]]) {
440
+ const unexpected = skills.filter((item) => ['personal', 'plugin'].includes(item.source)).map((item) => String(item.name)).sort();
441
+ if (unexpected.length) throw new EvaluationError(`${name} workspace exposes unexpected skills: ${unexpected.join(', ')}`);
442
+ }
443
+ if (closedSkills.some((item) => item.source === 'project')) throw new EvaluationError('Closed-book workspace exposes project skills');
444
+ if (!skillSkills.some((item) => item.source === 'project')) throw new EvaluationError('Skill workspace did not discover a project skill');
445
+ }
446
+
447
+ function assertClosedBookIsolation(skills) {
448
+ const unexpected = skills.filter((item) => ['personal', 'plugin', 'project'].includes(item.source)).map((item) => String(item.name)).sort();
449
+ if (unexpected.length) throw new EvaluationError(`Closed-book workspace exposes unexpected skills: ${unexpected.join(', ')}`);
450
+ }
451
+
452
+ function projectSkillCommandName(skills) {
453
+ const projectSkills = skills.filter((item) => item.source === 'project');
454
+ if (projectSkills.length !== 1) throw new EvaluationError(`Skill workspace must expose exactly one project skill, found ${projectSkills.length}`);
455
+ const name = projectSkills[0].name;
456
+ if (typeof name !== 'string' || !name.trim()) throw new EvaluationError('Project skill has no callable name');
457
+ return name.trim();
458
+ }
459
+
460
+ function assertDatasetCalibrated(manifest) {
461
+ const calibration = manifest.calibration;
462
+ if (!calibration || calibration.requiredScore !== 1) throw new EvaluationError('Dataset does not contain strict oracle calibration metadata');
463
+ }
464
+
465
+ function baselineCompatibility(datasetId, settings, copilotCliVersion) {
466
+ return { datasetId, evaluatorVersion: EVALUATOR_VERSION, copilotCliVersion, model: settings.model, judgeModel: settings.judgeModel, reasoningEffort: settings.reasoningEffort, trialsPerQuestion: settings.trialsPerQuestion };
467
+ }
468
+
469
+ function baselineCreationCommand(datasetPath, baselineRoot, settings) {
470
+ const args = ['eval', 'baseline', '--dataset', datasetPath, '--output-dir', baselineRoot, '--model', settings.model, '--judge-model', settings.judgeModel, '--reasoning-effort', settings.reasoningEffort, '--trials', String(settings.trialsPerQuestion)];
471
+ return `npm start -- ${args.map(shellArgument).join(' ')}`;
472
+ }
473
+
474
+ function shellArgument(value) {
475
+ return /^[A-Za-z0-9_./:@=+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
476
+ }
477
+
478
+ function buildSummary(dataset, answers, diagnoses) {
479
+ const grouped = {};
480
+ for (const answer of answers) (grouped[answer.condition] ??= []).push(answer.score);
481
+ const conditionScores = Object.fromEntries(Object.entries(grouped).map(([condition, scores]) => [condition, scores.reduce((sum, score) => sum + score, 0) / scores.length]));
482
+ const datasetCeiling = dataset.calibrations.reduce((sum, item) => sum + item.score, 0) / dataset.calibrations.length;
483
+ return { datasetId: dataset.datasetId, questions: dataset.questions.length, conditionScores, datasetCeiling, skillUplift: conditionScores.skill - conditionScores.closedBook, diagnosedFailures: diagnoses.length };
484
+ }
485
+
486
+ function formatJobCounts(counts) { return `completed ${counts.completed}, running ${counts.running}, pending ${counts.pending}, failed ${counts.failed}`; }
package/src/files.js ADDED
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { cp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { stableStringify } from './json.js';
6
+
7
+ export async function readJson(filePath) {
8
+ try { return JSON.parse(await readFile(filePath, 'utf8')); }
9
+ catch (error) { throw new Error(`Could not read ${filePath}: ${error.message}`); }
10
+ }
11
+
12
+ export async function readJsonl(filePath) {
13
+ try {
14
+ return (await readFile(filePath, 'utf8')).split('\n').filter(Boolean).map((line) => JSON.parse(line));
15
+ } catch (error) { throw new Error(`Could not read ${filePath}: ${error.message}`); }
16
+ }
17
+
18
+ export async function writeJson(filePath, value) {
19
+ await writeFile(filePath, `${stableStringify(value, 2)}\n`, 'utf8');
20
+ }
21
+
22
+ export async function writeJsonl(filePath, values) {
23
+ await writeFile(filePath, values.map((value) => stableStringify(value)).join('\n') + (values.length ? '\n' : ''), 'utf8');
24
+ }
25
+
26
+ export async function copyDirectory(source, destination) {
27
+ await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
28
+ }
29
+
30
+ export async function hashDirectory(root) {
31
+ const digest = createHash('sha256');
32
+ for (const filePath of await allFiles(root)) {
33
+ digest.update(path.relative(root, filePath).split(path.sep).join('/'), 'utf8');
34
+ digest.update('\0');
35
+ digest.update(await readFile(filePath));
36
+ digest.update('\0');
37
+ }
38
+ return digest.digest('hex');
39
+ }
40
+
41
+ export async function replaceDirectory(staging, destination) {
42
+ await mkdir(path.dirname(destination), { recursive: true });
43
+ await rename(staging, destination);
44
+ }
45
+
46
+ export { mkdir, rm };
47
+
48
+ async function allFiles(root) {
49
+ const files = [];
50
+ for (const entry of (await readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
51
+ const entryPath = path.join(root, entry.name);
52
+ if (entry.isDirectory()) files.push(...await allFiles(entryPath));
53
+ else if (entry.isFile()) files.push(entryPath);
54
+ }
55
+ return files;
56
+ }
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { InvalidStructuredResponse, parseJsonObject } from './structured.js';
4
+ import { stableStringify } from './json.js';
5
+
6
+ export const KNOWLEDGE_KINDS = new Set(['fact', 'rule', 'procedure', 'constraint', 'exception', 'warning', 'default', 'relationship', 'trend']);
7
+ export const IMPORTANCE_LEVELS = new Set(['high', 'medium', 'low']);
8
+
9
+ export function buildInventoryPrompt(section, existingItems = []) {
10
+ const existing = existingItems.map(({ knowledgeId, kind, statement }) => ({ knowledgeId, kind, statement }));
11
+ const classificationInstruction = existingItems.length
12
+ ? 'For this residual pass, classification describes whether missing testable knowledge remains. Return non_informational with an empty items array when the existing inventory already covers the section. Do not return paraphrases or alternate classifications of existing items.'
13
+ : 'Return non_informational with an empty items array only when the section contains no testable knowledge.';
14
+ return [
15
+ 'Inventory every independently testable piece of knowledge in the source section. Include facts, rules, procedures, constraints, exceptions, warnings, defaults, relationships, and trends. Do not summarize multiple distinct items into one. If existing items are supplied, return only substantive items they missed.',
16
+ '',
17
+ 'Return only one JSON object with this shape:',
18
+ '{"classification":"informational","reason":"...","items":[{"kind":"rule","statement":"...","importance":"high","importanceReason":"...","quote":"exact unique source quote"}]}',
19
+ 'classification must be informational or non_informational. kind must be one of: fact, rule, procedure, constraint, exception, warning, default, relationship, or trend.',
20
+ classificationInstruction,
21
+ 'Every quote must appear exactly once in the supplied section and fully support the statement. Importance must be high, medium, or low.',
22
+ '',
23
+ `SECTION ID: ${section.sectionId}`,
24
+ `HEADING: ${section.heading}`,
25
+ 'EXISTING ITEMS:',
26
+ stableStringify(existing),
27
+ 'SOURCE SECTION:',
28
+ section.content,
29
+ ].join('\n');
30
+ }
31
+
32
+ export function parseInventoryResponse(response, { section, document }) {
33
+ const data = parseJsonObject(response);
34
+ if (!['informational', 'non_informational'].includes(data.classification)) throw new InvalidStructuredResponse('classification must be informational or non_informational');
35
+ const reason = requiredString(data, 'reason');
36
+ if (!Array.isArray(data.items)) throw new InvalidStructuredResponse('items must be an array');
37
+ if (data.classification === 'non_informational' && data.items.length) throw new InvalidStructuredResponse('A non_informational section must have an empty items array');
38
+ if (data.classification === 'informational' && !data.items.length) throw new InvalidStructuredResponse('An informational section must contain at least one item');
39
+
40
+ const items = [];
41
+ const evidence = [];
42
+ const statements = new Set();
43
+ for (const rawItem of data.items) {
44
+ if (rawItem === null || Array.isArray(rawItem) || typeof rawItem !== 'object') throw new InvalidStructuredResponse('Each inventory item must be an object');
45
+ const kind = requiredString(rawItem, 'kind');
46
+ const statement = requiredString(rawItem, 'statement');
47
+ const importance = requiredString(rawItem, 'importance');
48
+ const importanceReason = requiredString(rawItem, 'importanceReason');
49
+ const quote = requiredString(rawItem, 'quote');
50
+ if (!KNOWLEDGE_KINDS.has(kind)) throw new InvalidStructuredResponse(`Unsupported knowledge kind: ${kind}`);
51
+ if (!IMPORTANCE_LEVELS.has(importance)) throw new InvalidStructuredResponse(`Unsupported importance: ${importance}`);
52
+ if (statements.has(statement)) throw new InvalidStructuredResponse(`Duplicate knowledge statement: ${statement}`);
53
+ if (findAll(section.content, quote).length !== 1) throw new InvalidStructuredResponse(`Evidence quote must occur exactly once in section: ${JSON.stringify(quote)}`);
54
+ const start = document.content.indexOf(quote, section.start);
55
+ if (start < 0 || start >= section.end) throw new InvalidStructuredResponse(`Evidence quote could not be located in document: ${JSON.stringify(quote)}`);
56
+ const end = start + quote.length;
57
+ const evidenceId = `ev_${digest(`${document.revision}:${start}:${end}:${quote}`).slice(0, 16)}`;
58
+ const knowledgeId = `ki_${digest(`${kind}:${statement}:${evidenceId}`).slice(0, 16)}`;
59
+ evidence.push({ evidenceId, documentId: document.documentId, revision: document.revision, sectionId: section.sectionId, quote, start, end });
60
+ items.push({ knowledgeId, sectionId: section.sectionId, kind, statement, importance, importanceReason, evidenceIds: [evidenceId] });
61
+ statements.add(statement);
62
+ }
63
+ return { sectionId: section.sectionId, classification: data.classification, reason, items, evidence };
64
+ }
65
+
66
+ export function requiredString(value, key) {
67
+ if (typeof value[key] !== 'string' || !value[key].trim()) throw new InvalidStructuredResponse(`${key} must be a non-empty string`);
68
+ return value[key].trim();
69
+ }
70
+
71
+ function findAll(content, value) {
72
+ const matches = [];
73
+ for (let start = 0; ; start += 1) {
74
+ const match = content.indexOf(value, start);
75
+ if (match < 0) return matches;
76
+ matches.push(match);
77
+ start = match;
78
+ }
79
+ }
80
+
81
+ function digest(value) {
82
+ return createHash('sha256').update(value, 'utf8').digest('hex');
83
+ }