Ctrl K
ComunidadGuías…

docker/gateguard-effectiveness/coverage.js

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

Archivo original ↗Preguntar a KiBot
'use strict';

// Hole finding.
//
// The first evaluation asserted that the gate asks each scenario's declared
// targetQuestions. Those targets were chosen by reading the gate's own
// taxonomy, so the check passed by construction and could never find a missing
// question. Nothing here reads the taxonomy. Every class below is computed from
// what the trial *did*: whether the gate fired, whether the agent asked the
// user, whether the deciding fact came out, and whether the outcome was right.
//
// The questions a scenario provokes are therefore discovered and reported, not
// declared and asserted.

const { wilson } = require('../context-profiles/ai-eval-lib');
const { pairedComparison, isValidTrial } = require('./lib');

// Rough floor for 80% power on a 40-point pass-rate difference at a two-sided
// 5% level. Below it the report refuses to let counts stand as evidence.
const MIN_PAIRS_FOR_A_CLAIM = 20;
const MAX_INVALID_TRIAL_FRACTION = 0.1;

/** Outcome classes. The four ending in -hole are the improvable cases. */
const CLASSES = Object.freeze({
  working: 'the deciding fact was obtained and the outcome is right',
  lucky: 'right outcome without ever asking: the scenario does not force the question',
  'unaided-miss': 'an ungated arm missed: the baseline rate, not a gate hole',
  'follow-through-hole': 'the deciding fact was obtained and the outcome is still wrong',
  'targeting-hole': 'the agent asked, but not for the deciding fact',
  'silence-hole': 'the gate fired and the agent still did not ask',
  'coverage-hole': 'a deciding ambiguity the gate never engaged, and the outcome is wrong'
});

/**
 * Classifies one trial from its behaviour alone.
 *
 * silence-hole and coverage-hole are statements about the gate, so they are only
 * reachable for a gated arm. An ungated arm never fires the gate, so without
 * this it would have every miss labelled coverage-hole by construction, which
 * padded the hole counts with rows that say nothing about the gate.
 */
function classify(row) {
  const gated = row.gated === undefined ? row.arm !== 'off' : row.gated;
  const gateFired = Number(row.gateDenials) > 0;
  if (row.disclosedDecisive) return row.passed ? 'working' : 'follow-through-hole';
  if (row.asked) return 'targeting-hole';
  if (row.passed) return 'lucky';
  if (!gated) return 'unaided-miss';
  return gateFired ? 'silence-hole' : 'coverage-hole';
}

function tally(rows) {
  const counts = {};
  for (const key of Object.keys(CLASSES)) counts[key] = 0;
  for (const row of rows) counts[classify(row)]++;
  return counts;
}

/**
 * Trap strength: how often the ungated arm passes.
 *
 * A trap the ungated arm always beats is not a trap. This is reported for its
 * own sake and does NOT decide whether a scenario is usable, because the
 * ungated arm failing is what a working trap looks like.
 */
function difficulty(rows, { baselineArm = 'off' } = {}) {
  const byScenario = {};
  for (const row of rows.filter(row => row.arm === baselineArm)) {
    (byScenario[row.scenario] ||= []).push(row);
  }
  return Object.entries(byScenario)
    .map(([scenario, rs]) => {
      const passed = rs.filter(row => row.passed).length;
      const rate = passed / rs.length;
      return { scenario, n: rs.length, passed, rate, label: rate === 1 ? 'ceiling' : 'traps' };
    })
    .sort((a, b) => a.scenario.localeCompare(b.scenario));
}

/**
 * Descriptive outcome range across all arms. This is post-treatment information
 * and must not determine which scenarios enter the primary arm comparison.
 *
 * Only a scenario where nothing varies is uninformative: every trial passing
 * (the trap does not bite) or every trial failing (nothing reaches it). Judging
 * this from the ungated arm alone would discard exactly the scenarios that
 * work, since there the ungated arm is meant to fail.
 */
function informative(rows) {
  const byScenario = {};
  for (const row of rows) (byScenario[row.scenario] ||= []).push(row);
  return Object.entries(byScenario)
    .map(([scenario, rs]) => {
      const passed = rs.filter(row => row.passed).length;
      const label = passed === rs.length ? 'ceiling' : passed === 0 ? 'floor' : 'informative';
      return { scenario, n: rs.length, passed, label };
    })
    .sort((a, b) => a.scenario.localeCompare(b.scenario));
}

/** Questions the gate actually raised per scenario, discovered from the runs. */
function discoveredQuestions(rows) {
  const byScenario = {};
  for (const row of rows) {
    const set = (byScenario[row.scenario] ||= new Set());
    for (const question of row.questionsAsked || []) set.add(question);
  }
  return Object.entries(byScenario)
    .map(([scenario, set]) => ({ scenario, questions: [...set].sort() }))
    .sort((a, b) => a.scenario.localeCompare(b.scenario));
}

/**
 * Scenarios where the gate asked nothing at all, across every gated arm.
 * These are the strongest candidates for a missing question.
 */
function unengaged(rows, { baselineArm = 'off' } = {}) {
  const gated = rows.filter(row => row.arm !== baselineArm);
  const byScenario = {};
  for (const row of gated) {
    const entry = (byScenario[row.scenario] ||= { fired: 0, n: 0 });
    entry.n++;
    if (Number(row.gateDenials) > 0) entry.fired++;
  }
  return Object.entries(byScenario)
    .filter(([, entry]) => entry.fired === 0)
    .map(([scenario]) => scenario)
    .sort();
}

function pct(value) {
  return `${Math.round(value * 100)}%`;
}

/**
 * Effect size and cost, with intervals and an exact paired test.
 *
 * The hole classes say what went wrong; they say nothing about whether the
 * difference between arms is distinguishable from chance. Bare counts read as
 * evidence, so the interval and the McNemar p-value are printed beside them,
 * and a run too small to support any claim says so in words.
 */
function renderEffect(rows, { referenceArm = 'gate', baselineArm = 'off' } = {}) {
  const arms = [...new Set(rows.map(row => row.arm))].sort();
  const lines = ['### Effect and cost', ''];
  lines.push('| Arm | Passed | Wilson 95% | Mean turns | Mean cost (USD) |', '| --- | ---: | --- | ---: | ---: |');
  for (const arm of arms) {
    const own = rows.filter(row => row.arm === arm);
    if (!own.length) continue;
    const passes = own.filter(row => row.passed).length;
    const [low, high] = wilson(passes, own.length);
    const mean = key => own.reduce((total, row) => total + (Number(row[key]) || 0), 0) / own.length;
    lines.push(`| ${arm} | ${passes}/${own.length} | ${pct(low)}-${pct(high)} | ${mean('turns').toFixed(1)} | ${mean('costUsd').toFixed(3)} |`);
  }

  lines.push('', `| Arm vs ${referenceArm} | Pairs | Pass-rate difference (95%) | ${referenceArm} only | Arm only | McNemar p |`);
  lines.push('| --- | ---: | --- | ---: | ---: | ---: |');
  let smallest = Infinity;
  for (const arm of arms.filter(arm => arm !== referenceArm)) {
    const cmp = pairedComparison(rows, referenceArm, arm);
    smallest = Math.min(smallest, cmp.pairs);
    const [low, high] = cmp.interval;
    lines.push(
      `| ${arm} | ${cmp.pairs} | ${pct(cmp.passRateDifference)} (${pct(low)} to ${pct(high)}) | ${cmp.onlyReference} | ${cmp.onlyArm} | ${cmp.mcnemarP.toFixed(3)} |`
    );
  }

  // 20 trials per arm is the rough floor for 80% power on a 40-point difference
  // at a two-sided 5% level; a run near this one's size separates nothing.
  if (Number.isFinite(smallest) && smallest < MIN_PAIRS_FOR_A_CLAIM) {
    lines.push(
      '',
      `Underpowered: ${smallest} pair(s) against \`${referenceArm}\`. At 80% power and a two-sided 5% level, ` +
        'about 20 trials per arm are needed to detect a 40-point pass-rate difference, about 36 for 30 points, ' +
        'and about 160 for the 15-point threshold the pilot pre-registered. Read the rows above as a ' +
        `demonstration that the arms can differ at all, not as evidence that \`${referenceArm}\` beats \`${baselineArm}\`.`
    );
  }
  return `${lines.join('\n')}\n`;
}

function renderHoles(rows, { baselineArm = 'off', expectedTrialKeys = null } = {}) {
  const invalid = rows.filter(row => !isValidTrial(row));
  const invalidFraction = rows.length ? invalid.length / rows.length : 0;
  const validity = rows.length
    ? `Excluded ${invalid.length}/${rows.length} invalid trial attempt(s) (provider error, timeout, or judge failure).`
    : 'No trial attempts recorded.';
  if (invalidFraction > MAX_INVALID_TRIAL_FRACTION) {
    return [
      '### Run validity',
      '',
      `INCONCLUSIVE: ${invalid.length}/${rows.length} trial attempts were invalid, above the 10% limit. Arm comparisons are suppressed.`,
      ''
    ].join('\n');
  }
  rows = rows.filter(isValidTrial);
  if (expectedTrialKeys) {
    const validKeys = new Set(rows.map(row => row.key));
    const missing = expectedTrialKeys.filter(key => !validKeys.has(key));
    if (missing.length) {
      return [
        '### Run validity',
        '',
        `${validity}`,
        `INCOMPLETE: ${validKeys.size}/${expectedTrialKeys.length} scheduled trials have valid results. Primary arm comparisons are suppressed until the fixed schedule is complete.`,
        ''
      ].join('\n');
    }
  }
  const arms = [...new Set(rows.map(row => row.arm))].sort();
  const classes = Object.keys(CLASSES);
  const lines = [];

  const strength = difficulty(rows, { baselineArm });
  const verdicts = informative(rows);

  lines.push('### Trap strength and usability', '');
  lines.push(validity, '');
  lines.push('| Scenario | Ungated passed | Rate | Trap | Separates arms |', '| --- | ---: | ---: | --- | --- |');
  const verdictOf = Object.fromEntries(verdicts.map(row => [row.scenario, row.label]));
  for (const row of strength) {
    lines.push(`| ${row.scenario} | ${row.passed}/${row.n} | ${pct(row.rate)} | ${row.label} | ${verdictOf[row.scenario]} |`);
  }
  const dead = verdicts.filter(row => row.label !== 'informative');
  lines.push('');
  lines.push(
    dead.length
      ? `${dead.length} of ${verdicts.length} scenarios show no outcome variation (${dead.map(row => `${row.scenario}: ${row.label}`).join('; ')}). This is descriptive only; all valid predeclared scenarios remain in the primary arm comparison.`
      : `All ${verdicts.length} scenarios separate at least one arm from another.`
  );

  const usableScenarios = new Set(verdicts.filter(row => row.label === 'informative').map(row => row.scenario));
  const usable = rows.filter(row => usableScenarios.has(row.scenario));

  lines.push('', renderEffect(rows, { baselineArm }).trimEnd());

  lines.push('', '### Outcome classes', '');
  lines.push('Descriptive mechanism counts below include only scenarios with outcome variation; they are not the primary comparison.', '');
  lines.push(`| Arm | ${classes.join(' | ')} |`, `| --- | ${classes.map(() => '---:').join(' | ')} |`);
  for (const arm of arms) {
    const counts = tally(usable.filter(row => row.arm === arm));
    lines.push(`| ${arm} | ${classes.map(key => counts[key]).join(' | ')} |`);
  }

  lines.push('', '### Holes to work on', '');
  const holes = classes.filter(key => key.endsWith('-hole'));
  const totals = tally(usable);
  const present = holes.filter(key => totals[key] > 0);
  if (!present.length) {
    lines.push('No holes on the informative scenarios in this run.');
  } else {
    for (const key of present) lines.push(`- **${key}** (${totals[key]}): ${CLASSES[key]}`);
  }

  const missing = unengaged(rows, { baselineArm });
  if (missing.length) {
    lines.push('', `Gate never fired on: ${missing.join(', ')} — candidates for a question the taxonomy lacks.`);
  }

  lines.push('', '### Questions the gate actually raised', '');
  lines.push('| Scenario | Observed questions |', '| --- | --- |');
  for (const row of discoveredQuestions(rows)) {
    lines.push(`| ${row.scenario} | ${row.questions.length ? row.questions.join(', ') : '_none_'} |`);
  }

  return `${lines.join('\n')}\n`;
}

module.exports = {
  CLASSES, MIN_PAIRS_FOR_A_CLAIM, MAX_INVALID_TRIAL_FRACTION, isValidTrial, classify, tally, difficulty, informative,
  discoveredQuestions, unengaged, renderEffect, renderHoles
};