Ctrl K
ComunidadGuías…

docker/gateguard-effectiveness/intent-eval.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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 };