The brief pre-registers "median forbedring >= 30 % pa (a) eller (b) -> adopt. < 15 % -> decline." decideVerdict() required BOTH metrics to clear the adopt bar and BOTH to fall under the decline bar, a rule introduced at execution time (plan Step 11 restated the brief's wording unchanged). The divergence is behavioural, not cosmetic: sources +90% / dimensions +10% is adopt under the brief and was inconclusive in code; sources +2% / dimensions +20% is decline under the brief and was inconclusive in code. Adopt is evaluated before decline, so the OR bars do not conflict where they overlap. docs/storm-measurement.md §2 and the test pins follow the same rule. The stricter AND rule may well be the better decision procedure — but changing it after the fact is exactly what pre-registration exists to prevent. Review finding c37bf50d. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
211 lines
8.6 KiB
JavaScript
211 lines
8.6 KiB
JavaScript
// tests/scripts/storm-measure.test.mjs
|
|
// Step 11 — the STORM adoption gate's deterministic accounting core.
|
|
//
|
|
// The gate decides ONE thing: does the bounded Phase 5 loop buy enough extra
|
|
// source/coverage breadth to be worth flipping VOYAGE_STORM_ENABLED on by
|
|
// default. Thresholds are pre-registered in docs/storm-measurement.md BEFORE
|
|
// any measurement run, so this file pins the arithmetic that turns a
|
|
// trekresearch-stats.jsonl into a verdict — not the verdict itself.
|
|
//
|
|
// Two properties carry the gate's honesty:
|
|
// - runs with empty_turns > 0 are EXCLUDED from the gain and COUNTED, so
|
|
// adoption is never decided on a broken denominator, and
|
|
// - a stats file with no `effort` field is a loud error, never a silently
|
|
// empty group that reads as "no gain".
|
|
//
|
|
// Pattern: tests/scripts/synthesis-measure.test.mjs (pure core, no fixtures on disk).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import {
|
|
median,
|
|
parseStats,
|
|
partitionEligible,
|
|
measure,
|
|
decideVerdict,
|
|
ADOPT_THRESHOLD,
|
|
DECLINE_THRESHOLD,
|
|
} from '../../scripts/storm-measure.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers — synthetic JSONL, one object per line, exactly as the orchestrator emits
|
|
// ---------------------------------------------------------------------------
|
|
function run({ effort, unique_sources, dimensions, dimensions_baseline, empty_turns = 0, conv_turns = 0 }) {
|
|
return JSON.stringify({
|
|
ts: '2026-08-12T00:00:00.000Z',
|
|
question: 'q',
|
|
mode: 'full',
|
|
scope: 'both',
|
|
engine: 'swarm',
|
|
effort,
|
|
unique_sources,
|
|
dimensions,
|
|
dimensions_baseline,
|
|
conv_turns,
|
|
empty_turns,
|
|
});
|
|
}
|
|
|
|
function jsonl(...lines) {
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
// A control arm at 10 sources / 5 dimensions, and a treatment arm at 13
|
|
// sources / 8 dimensions: +30.0% sources, +60.0% dimensions.
|
|
const STANDARD = [
|
|
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
|
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
|
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
|
];
|
|
const HIGH = [
|
|
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
|
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
|
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// median
|
|
// ---------------------------------------------------------------------------
|
|
test('median: odd, even, single', () => {
|
|
assert.equal(median([3, 1, 2]), 2);
|
|
assert.equal(median([1, 2, 3, 4]), 2.5);
|
|
assert.equal(median([7]), 7);
|
|
});
|
|
|
|
test('median: empty list is null, never 0 — 0 would read as a real measurement', () => {
|
|
assert.equal(median([]), null);
|
|
});
|
|
|
|
test('median does not mutate its input', () => {
|
|
const xs = [3, 1, 2];
|
|
median(xs);
|
|
assert.deepEqual(xs, [3, 1, 2]);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseStats — the loud-error requirement
|
|
// ---------------------------------------------------------------------------
|
|
test('parseStats: a file with no effort field throws, it does not yield empty groups', () => {
|
|
const noEffort = jsonl(
|
|
JSON.stringify({ ts: 'x', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
|
JSON.stringify({ ts: 'y', unique_sources: 11, dimensions: 5, dimensions_baseline: 5 }),
|
|
);
|
|
assert.throws(() => parseStats(noEffort), /effort/i);
|
|
});
|
|
|
|
test('parseStats: skips blank and malformed lines but keeps the good ones', () => {
|
|
const text = jsonl(STANDARD[0], '', 'not json', HIGH[0]);
|
|
const { records, malformed } = parseStats(text);
|
|
assert.equal(records.length, 2);
|
|
assert.equal(malformed, 1);
|
|
});
|
|
|
|
test('parseStats: an empty file throws rather than reporting a zero-gain verdict', () => {
|
|
assert.throws(() => parseStats(''), /no records/i);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// exclusion of broken runs
|
|
// ---------------------------------------------------------------------------
|
|
test('partitionEligible: runs with empty_turns > 0 are excluded and counted', () => {
|
|
const { records } = parseStats(jsonl(
|
|
...HIGH,
|
|
run({ effort: 'high', unique_sources: 99, dimensions: 8, dimensions_baseline: 5, empty_turns: 2 }),
|
|
));
|
|
const { eligible, excluded } = partitionEligible(records);
|
|
assert.equal(eligible.length, 3);
|
|
assert.equal(excluded, 1);
|
|
});
|
|
|
|
test('partitionEligible: empty_turns === 0 is eligible; a missing field counts as 0', () => {
|
|
const { records } = parseStats(jsonl(
|
|
STANDARD[0],
|
|
JSON.stringify({ ts: 'z', effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
|
));
|
|
const { eligible, excluded } = partitionEligible(records);
|
|
assert.equal(eligible.length, 2);
|
|
assert.equal(excluded, 0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// measure — the known-answer test
|
|
// ---------------------------------------------------------------------------
|
|
test('measure: median gains on synthetic runs give the known answer', () => {
|
|
const m = measure(parseStats(jsonl(...STANDARD, ...HIGH)).records);
|
|
assert.equal(m.control.n, 3);
|
|
assert.equal(m.treatment.n, 3);
|
|
assert.equal(m.sources.control, 10);
|
|
assert.equal(m.sources.treatment, 13);
|
|
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9, `sources gain ${m.sources.gain}`);
|
|
// (8 - 5) / 5 = 0.60 within each treatment run.
|
|
assert.ok(Math.abs(m.dimensions.gain - 0.60) < 1e-9, `dimensions gain ${m.dimensions.gain}`);
|
|
});
|
|
|
|
test('measure: an excluded run cannot move the median', () => {
|
|
const withBroken = jsonl(
|
|
...STANDARD,
|
|
...HIGH,
|
|
run({ effort: 'high', unique_sources: 900, dimensions: 8, dimensions_baseline: 5, empty_turns: 1 }),
|
|
);
|
|
const m = measure(parseStats(withBroken).records);
|
|
assert.equal(m.excluded, 1);
|
|
assert.equal(m.sources.treatment, 13, 'the 900-source broken run must not reach the median');
|
|
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9);
|
|
});
|
|
|
|
test('measure: reports null gain (not 0) when an arm has no eligible runs', () => {
|
|
const m = measure(parseStats(jsonl(...HIGH)).records);
|
|
assert.equal(m.control.n, 0);
|
|
assert.equal(m.sources.gain, null);
|
|
assert.equal(m.verdict, 'insufficient-data');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// verdict mapping — both sides of both thresholds
|
|
// ---------------------------------------------------------------------------
|
|
test('decideVerdict: at and above the adopt threshold', () => {
|
|
assert.equal(decideVerdict(ADOPT_THRESHOLD, ADOPT_THRESHOLD), 'adopt');
|
|
assert.equal(decideVerdict(0.55, 0.44), 'adopt');
|
|
});
|
|
|
|
test('decideVerdict: both metrics between the bars is inconclusive, not adopt', () => {
|
|
assert.equal(decideVerdict(ADOPT_THRESHOLD - 0.0001, 0.2), 'inconclusive');
|
|
});
|
|
|
|
test('decideVerdict: below the decline threshold on both metrics declines', () => {
|
|
assert.equal(decideVerdict(0.14, 0.05), 'decline');
|
|
assert.equal(decideVerdict(DECLINE_THRESHOLD - 0.0001, 0), 'decline');
|
|
});
|
|
|
|
test('decideVerdict: at the decline threshold is inconclusive, not decline', () => {
|
|
assert.equal(decideVerdict(DECLINE_THRESHOLD, DECLINE_THRESHOLD), 'inconclusive');
|
|
});
|
|
|
|
// The brief pre-registers "median forbedring >= 30 % på (a) eller (b) → adopt.
|
|
// < 15 % → decline." — OR on both sides, with adopt evaluated first.
|
|
test('decideVerdict: adopt needs EITHER metric — one strong metric carries a weak one', () => {
|
|
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
|
|
assert.equal(decideVerdict(0.10, 0.90), 'adopt');
|
|
});
|
|
|
|
test('decideVerdict: either metric below the decline bar declines', () => {
|
|
assert.equal(decideVerdict(0.02, 0.20), 'decline');
|
|
assert.equal(decideVerdict(0.20, 0.02), 'decline');
|
|
});
|
|
|
|
test('decideVerdict: adopt outranks decline when one metric clears and the other is under the decline bar', () => {
|
|
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
|
|
});
|
|
|
|
test('decideVerdict: a null gain is insufficient data, never a decline', () => {
|
|
assert.equal(decideVerdict(null, 0.4), 'insufficient-data');
|
|
assert.equal(decideVerdict(0.4, null), 'insufficient-data');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pre-registration — the thresholds are the doc's, and the doc is committed first
|
|
// ---------------------------------------------------------------------------
|
|
test('thresholds are the pre-registered 30% / 15%', () => {
|
|
assert.equal(ADOPT_THRESHOLD, 0.30);
|
|
assert.equal(DECLINE_THRESHOLD, 0.15);
|
|
});
|