mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +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.
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
'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
|
|
};
|