Files
ECC/docker/gateguard-effectiveness/intent-eval.js
T
montjeffrey 6a7996aa70 fix(gateguard): make harness graders check content and count only valid trials
- 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.
2026-10-11 10:00:47 -04:00

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 };