Files
Dragan Spiridonov c02e60a8f3 feat(benchmarks): ADR-109 interaction benchmark — rubric pre-registered before any data (issue #522)
RUBRIC-v1.md committed first per ADR-108: ground-truth primary outcome
(hidden acceptance test), style-neutral verbatim judge instruction (no
treatment-revealing framing in either direction), two-pass cross-family
judging with INFERRED-labeled fallback, clustered-stats reporting rule.
Provably rubric-before-data: this commit precedes any results-* artifact.

2 scenarios with seeded defects + personas + hidden tests (off-by-one
pagination; swallowed async rejection). Hidden tests verified both
directions: FAIL on seeded bug with precise reasons, PASS on the fix.

lib/stats.ts encodes the three Pattern Space harness fixes as code:
isGoodRow enforced inside aggregate() (no path to a number that skips
it), seeded per-item counterbalance (their null cell was 72% position-
skewed), exact scenario-clustered sign test (validated against the
known 27/11 → p≈0.0136 re-analysis).

Verified: 14/14 stats tests; hidden-test ground truth both directions;
tsc clean. Remaining: live conversation driver + judge integration
(API-gated, same decision as ADR-106 live mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:05:26 +00:00

128 lines
4.4 KiB
TypeScript

/**
* ADR-109 interaction benchmark — deterministic aggregation & statistics.
*
* Encodes the three fixes for the failure modes found in the Pattern Space
* harness review (issue #522):
* 1. Aggregation enforces row cleanliness IN CODE (their `_agg` trusted a
* scrub it couldn't guarantee).
* 2. X/Y counterbalance is seeded per item id (their one null cell was 72%
* position-skewed from unseeded shuffling).
* 3. The reported statistic is scenario-clustered (their pooled n=317
* treated correlated rows as independent).
*/
export interface ResultRow {
scenarioId: string;
itemId: string;
rubricHash: string;
/** 'A' | 'B' | 'tie' | null — null means judging incomplete */
winnerArm: 'A' | 'B' | 'tie' | null;
/** Ground truth: did the interactor's post-conversation fix pass the hidden test? */
fixPassesHiddenTest?: boolean;
/** Raw transcript; rows containing harness errors are excluded by isGoodRow */
transcript: string;
turnsCompleted: number;
turnsExpected: number;
}
/** Row cleanliness — enforced by aggregate(), not by manual scrubbing. */
export function isGoodRow(row: ResultRow): boolean {
return (
!row.transcript.includes('__ERROR__') &&
!row.transcript.includes('(no reply)') &&
row.turnsCompleted === row.turnsExpected &&
row.winnerArm !== null
);
}
/** Deterministic X/Y assignment from the item id — same item, same blinding, every run. */
export function counterbalance(itemId: string): { x: 'A' | 'B'; y: 'A' | 'B' } {
let hash = 0;
for (let i = 0; i < itemId.length; i++) {
hash = (hash * 31 + itemId.charCodeAt(i)) | 0;
}
return (hash & 1) === 0 ? { x: 'A', y: 'B' } : { x: 'B', y: 'A' };
}
function binomialCoefficient(n: number, k: number): number {
let result = 1;
for (let i = 0; i < k; i++) result = (result * (n - i)) / (i + 1);
return result;
}
/** Exact two-sided binomial sign test against p=0.5; ties excluded by caller. */
export function exactSignTest(wins: number, losses: number): number {
const n = wins + losses;
if (n === 0) return 1;
const k = Math.min(wins, losses);
let tail = 0;
for (let i = 0; i <= k; i++) {
tail += binomialCoefficient(n, i) * Math.pow(0.5, n);
}
return Math.min(1, 2 * tail);
}
export interface ClusteredResult {
scenarioCount: number;
scenarioWinsA: number;
scenarioWinsB: number;
scenarioTies: number;
pValue: number;
/** Pooled counts — reportable ONLY alongside the clustered result (rubric rule) */
pooled: { rows: number; winsA: number; winsB: number; ties: number };
excludedRows: number;
groundTruth: { aFixRate: number | null; bFixRate: number | null };
}
/**
* Scenario-clustered aggregation: majority winner per scenario, exact sign
* test over scenarios. Dirty rows are excluded HERE — there is no code path
* to a number that skips isGoodRow.
*/
export function aggregate(rows: ResultRow[]): ClusteredResult {
const good = rows.filter(isGoodRow);
const excludedRows = rows.length - good.length;
const byScenario = new Map<string, ResultRow[]>();
for (const row of good) {
const list = byScenario.get(row.scenarioId) ?? [];
list.push(row);
byScenario.set(row.scenarioId, list);
}
let scenarioWinsA = 0;
let scenarioWinsB = 0;
let scenarioTies = 0;
for (const scenarioRows of byScenario.values()) {
const a = scenarioRows.filter(r => r.winnerArm === 'A').length;
const b = scenarioRows.filter(r => r.winnerArm === 'B').length;
if (a > b) scenarioWinsA++;
else if (b > a) scenarioWinsB++;
else scenarioTies++;
}
const fixRate = (arm: 'A' | 'B'): number | null => {
const armRows = good.filter(r => r.winnerArm !== null && r.fixPassesHiddenTest !== undefined);
// Ground truth is per-conversation, not per-winner: rate over rows where this arm ran.
const relevant = armRows.filter(r => r.itemId.endsWith(`:${arm}`));
if (relevant.length === 0) return null;
return relevant.filter(r => r.fixPassesHiddenTest).length / relevant.length;
};
return {
scenarioCount: byScenario.size,
scenarioWinsA,
scenarioWinsB,
scenarioTies,
pValue: exactSignTest(scenarioWinsA, scenarioWinsB),
pooled: {
rows: good.length,
winsA: good.filter(r => r.winnerArm === 'A').length,
winsB: good.filter(r => r.winnerArm === 'B').length,
ties: good.filter(r => r.winnerArm === 'tie').length,
},
excludedRows,
groundTruth: { aFixRate: fixRate('A'), bFixRate: fixRate('B') },
};
}