mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-16 05:05:30 +02:00
- csv-export-delimiter's grader adds a fourth check: the data rows are the exported values, with an optional header (the hidden intent says a header is fine either way). Reference scores 1; both decoys 0.75. - graderIsSound grades every decoy-* overlay and is sound only when each scores below 1; --check-graders and the pre-billing check print the decoy scores through formatGraderScores. - isValidTrial and completedTrialKeys move to lib.js; coverage.js and run-intent.js use them from there. - run.js resumes with completedTrialKeys, so a provider-error or timed-out key is retried, and writes one transcript per attempt (<key>__<attemptId>.jsonl, recorded on the row) so a retry never overwrites the failed attempt's record. - armSummary computes trials, passes and means over valid rows and counts the failed attempts apart as providerErrors. Docs: question-effectiveness.md states the decoy rule and that a csv-export-delimiter pass in the reported runs reflects the dialect only; its Measures section and the harness README describe retries, per-attempt transcripts and the hookless-trial stop.
159 lines
7.5 KiB
JavaScript
159 lines
7.5 KiB
JavaScript
'use strict';
|
|
|
|
// Loader and trial runner for hidden-intent scenarios.
|
|
//
|
|
// Deliberately absent from task.json: targetQuestions. The first generation of
|
|
// scenarios declared which question the gate ought to raise, chosen by reading
|
|
// the gate's own taxonomy, and the suite then asserted the gate raised it. That
|
|
// is a tautology, so it never found a missing question. Here the scenario
|
|
// declares only the trap and the user's hidden intent; which questions fire is
|
|
// observed from the run and classified in coverage.js.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { prepareWorkspace, commitWorkspace, grade, readMetrics, hookWasObserved } = require('./lib');
|
|
const { ARMS, TRIAL_FILE, trialPatch } = require('./arms');
|
|
const { runConversation } = require('./session');
|
|
const { classify } = require('./coverage');
|
|
|
|
const SCENARIO_DIR = path.join(__dirname, 'scenarios-intent');
|
|
|
|
// minus-target needs declared target questions, which these scenarios do not have.
|
|
const SUPPORTED_ARMS = Object.freeze(['off', 'gate', 'placebo', 'main']);
|
|
|
|
function loadIntentScenarios(dir = SCENARIO_DIR, ids = null) {
|
|
const scenarios = fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.filter(entry => entry.isDirectory())
|
|
.map(entry => {
|
|
const root = path.join(dir, entry.name);
|
|
const spec = JSON.parse(fs.readFileSync(path.join(root, 'task.json'), 'utf8'));
|
|
const intent = JSON.parse(fs.readFileSync(path.join(root, 'intent.json'), 'utf8'));
|
|
if (spec.id !== entry.name) throw new Error(`scenario ${entry.name} declares id ${spec.id}`);
|
|
if (spec.targetQuestions) {
|
|
throw new Error(`scenario ${spec.id} declares targetQuestions; hidden-intent scenarios must not encode gate-taxonomy targets`);
|
|
}
|
|
for (const key of ['trap', 'prompt']) {
|
|
if (typeof spec[key] !== 'string' || !spec[key]) throw new Error(`scenario ${spec.id} is missing ${key}`);
|
|
}
|
|
if (!Array.isArray(spec.evidence) || !spec.evidence.length) throw new Error(`scenario ${spec.id} has no evidence`);
|
|
if (typeof intent.ambiguity !== 'string' || !intent.ambiguity) throw new Error(`scenario ${spec.id} intent has no ambiguity`);
|
|
if (typeof intent.stonewall !== 'string' || !intent.stonewall) throw new Error(`scenario ${spec.id} intent has no stonewall`);
|
|
if (!Array.isArray(intent.facts)) throw new Error(`scenario ${spec.id} intent has no decisive fact`);
|
|
for (const fact of intent.facts) {
|
|
if (!fact.id || typeof fact.fact !== 'string' || !fact.fact) throw new Error(`scenario ${spec.id} has a malformed fact`);
|
|
}
|
|
const decisiveCount = intent.facts.filter(fact => fact.decisive === true).length;
|
|
if (decisiveCount !== 1) {
|
|
throw new Error(`scenario ${spec.id} intent must have exactly one decisive fact; found ${decisiveCount}`);
|
|
}
|
|
return {
|
|
...spec,
|
|
root,
|
|
intent,
|
|
evidence: spec.evidence.map(source => new RegExp(source, 'i')),
|
|
grader: fs.readFileSync(path.join(root, 'grader.cjs'), 'utf8')
|
|
};
|
|
})
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
if (!ids) return scenarios;
|
|
const unknown = ids.filter(id => !scenarios.some(scenario => scenario.id === id));
|
|
if (unknown.length) throw new Error(`unknown scenario: ${unknown.join(', ')}`);
|
|
return scenarios.filter(scenario => ids.includes(scenario.id));
|
|
}
|
|
|
|
/**
|
|
* Confirms the grader separates the start, the trap and a correct solution, and
|
|
* rejects every decoy: a `decoy-*` overlay that has the right shape and the wrong
|
|
* content, so a grader that checks only the form cannot pass.
|
|
*/
|
|
function graderIsSound(scenario, workRoot) {
|
|
const decoys = fs.readdirSync(scenario.root, { withFileTypes: true })
|
|
.filter(entry => entry.isDirectory() && entry.name.startsWith('decoy-'))
|
|
.map(entry => entry.name)
|
|
.sort();
|
|
const scores = {};
|
|
for (const variant of ['workspace', 'reference', 'naive', ...decoys]) {
|
|
const dest = path.join(workRoot, `check-${scenario.id}-${variant}`);
|
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
prepareWorkspace(scenario, dest, variant === 'workspace' ? null : variant);
|
|
scores[variant] = grade(scenario, dest).score;
|
|
}
|
|
const sound = scores.reference === 1 && scores.workspace < 1 && scores.naive < 1 && decoys.every(decoy => scores[decoy] < 1);
|
|
return { scores, sound };
|
|
}
|
|
|
|
/** One line of grader scores: start, trap, reference, then each decoy. */
|
|
function formatGraderScores(scores) {
|
|
const { workspace, naive, reference, ...decoys } = scores;
|
|
return [`start=${workspace}`, `trap=${naive}`, `reference=${reference}`]
|
|
.concat(Object.entries(decoys).map(([name, score]) => `${name}=${score}`))
|
|
.join(' ');
|
|
}
|
|
|
|
/** One trial: fresh workspace, a conversation with the simulated user, then the hidden grader. */
|
|
function runIntentTrial(trial, { workRoot, armSettingsByName, executable, model, judgeModel, maxTurns, timeoutMs, userTurns, execute, judge }) {
|
|
if (!SUPPORTED_ARMS.includes(trial.arm)) throw new Error(`arm ${trial.arm} is not supported for hidden-intent scenarios`);
|
|
const dir = path.join(workRoot, trial.key.replace(/\//g, '__'));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
const cwd = prepareWorkspace(trial.scenario, path.join(dir, 'repo'));
|
|
const git = commitWorkspace(cwd);
|
|
const stateDir = path.join(dir, 'state');
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
const settingsPath = path.join(dir, 'settings.json');
|
|
fs.writeFileSync(settingsPath, `${JSON.stringify(armSettingsByName[trial.arm], null, 2)}\n`);
|
|
fs.writeFileSync(path.join(dir, TRIAL_FILE), `${JSON.stringify({ stateDir, patch: trialPatch(trial.arm, trial.scenario) })}\n`);
|
|
|
|
const conversation = runConversation(
|
|
{ cwd, stateDir, settingsPath, prompt: trial.scenario.prompt, intent: trial.scenario.intent },
|
|
{ executable, model, judgeModel, maxTurns, timeoutMs, userTurns, execute, judge }
|
|
);
|
|
const metrics = readMetrics(stateDir);
|
|
const denials = metrics.filter(event => event.decision === 'deny' || event.decision === 'routine-deny');
|
|
const outcome = grade(trial.scenario, cwd);
|
|
|
|
const row = {
|
|
key: trial.key,
|
|
scenario: trial.scenario.id,
|
|
arm: trial.arm,
|
|
rep: trial.rep,
|
|
git,
|
|
gated: ARMS[trial.arm].gate,
|
|
passed: outcome.passed,
|
|
score: outcome.score,
|
|
asked: conversation.asked,
|
|
userTurns: conversation.userTurns,
|
|
disclosed: conversation.disclosed,
|
|
disclosedDecisive: conversation.disclosedDecisive,
|
|
judgeFailed: conversation.judgeFailed,
|
|
providerError: conversation.providerError,
|
|
providerMessage: conversation.providerMessage,
|
|
// Any hook-matched edit or shell call requires observable hook output.
|
|
hookObserved: hookWasObserved({
|
|
gated: ARMS[trial.arm].gate,
|
|
editCalls: conversation.editCalls,
|
|
shellCalls: conversation.shellCalls,
|
|
metrics,
|
|
gateDenials: conversation.gateDenials
|
|
}),
|
|
gateDenials: conversation.gateDenials,
|
|
denialsPerTurn: conversation.denialsPerTurn,
|
|
questionsAsked: [...new Set(denials.flatMap(event => event.questions || []))].sort(),
|
|
editCalls: conversation.editCalls,
|
|
tools: conversation.tools,
|
|
exitStatus: conversation.exitStatus,
|
|
timedOut: conversation.timedOut,
|
|
inputTokens: conversation.inputTokens,
|
|
cachedInputTokens: conversation.cachedInputTokens,
|
|
outputTokens: conversation.outputTokens,
|
|
costUsd: conversation.costUsd,
|
|
turns: conversation.turns,
|
|
durationMs: conversation.durationMs,
|
|
exchange: conversation.exchange
|
|
};
|
|
row.outcomeClass = classify(row);
|
|
return row;
|
|
}
|
|
|
|
module.exports = { loadIntentScenarios, graderIsSound, formatGraderScores, runIntentTrial, SCENARIO_DIR, SUPPORTED_ARMS };
|