Two defects in the adoption gate, both in the direction that flatters
adoption.
1. A non-numeric empty_turns counted as an eligible run.
Number('many') is NaN, and the test was `Number.isFinite(empty) &&
empty > 0`, so NaN fell through to the eligible branch. Measured
before: 0 -> eligible, 2 -> excluded, undefined -> eligible, null ->
eligible, 'many' -> ELIGIBLE, NaN -> ELIGIBLE. The exclusion is one of
the two properties docs/storm-measurement.md names as carrying this
gate's honesty, and the run whose bookkeeping broke is the run whose
numbers deserve the least trust. Now excluded. Absent and null stay
eligible via `?? 0` - a field never written is a genuine zero on a run
where the loop never armed.
2. The printed threshold line said "adopt >= 30.0% on BOTH - decline <
15.0% on BOTH" while decideVerdict evaluates OR on both sides. S82
restored the pre-registered OR rule in the logic (c37bf50d) and left
this line describing the stricter AND gate, one line above the verdict
that OR produced. The summary is the only form of the rule most readers
ever see, so it now states EITHER on both sides and that adopt wins
ties. Found while fixing (1); not a review finding.
A test pins the printed line against the string "on BOTH" so the two
cannot drift apart again silently.
Review finding 24a76c21ffc694cd782cd449212c9502d31aeda6 (MINOR).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
// scripts/storm-measure.mjs
|
||
// Step 11 — the STORM adoption gate: deterministic Δ accounting over
|
||
// ${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl.
|
||
//
|
||
// What this decides: whether the bounded Phase 5 conversation loop buys enough
|
||
// extra source/coverage breadth to justify flipping VOYAGE_STORM_ENABLED on by
|
||
// default in lib/util/research-loop-cap.mjs. Nothing else. The thresholds live
|
||
// in docs/storm-measurement.md and were committed BEFORE any measurement run —
|
||
// that pre-registration is the whole point, so this script never invents them.
|
||
//
|
||
// What this does NOT measure: outline quality, answer correctness, or operator
|
||
// satisfaction. It measures breadth (distinct sources, dimensions covered).
|
||
// A breadth win is necessary for adoption, not sufficient on its own.
|
||
//
|
||
// Honesty properties, both load-bearing:
|
||
// - Runs with empty_turns > 0 are EXCLUDED from the gain and REPORTED. An
|
||
// empty turn means the loop spent budget and returned nothing; leaving those
|
||
// in decides adoption on a broken denominator.
|
||
// - A stats file carrying no `effort` field is a loud error. The silent
|
||
// failure this prevents is an empty treatment group reading as "no gain",
|
||
// which would decline the mechanism for a schema reason.
|
||
//
|
||
// Zero deps. Node stdlib only.
|
||
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
|
||
// Pre-registered in docs/storm-measurement.md. Do not tune to fit a result.
|
||
export const ADOPT_THRESHOLD = 0.30;
|
||
export const DECLINE_THRESHOLD = 0.15;
|
||
|
||
const STATS_FILENAME = 'trekresearch-stats.jsonl';
|
||
|
||
// ---- pure core (unit-tested) -------------------------------------------------
|
||
|
||
/** @param {number[]} xs @returns {number|null} null for an empty list — 0 would read as a measurement. */
|
||
export function median(xs) {
|
||
if (!Array.isArray(xs) || xs.length === 0) return null;
|
||
const s = [...xs].sort((a, b) => a - b);
|
||
const mid = s.length >> 1;
|
||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||
}
|
||
|
||
/**
|
||
* Parse a trekresearch-stats.jsonl body into effort-carrying records.
|
||
*
|
||
* Rows predating Step 9 have no `effort` field; they are dropped and counted as
|
||
* `legacy` rather than silently pooled into the control arm. A file where NO row
|
||
* carries `effort` throws — see the header note on silent failure.
|
||
*
|
||
* @param {string} text
|
||
* @returns {{records: object[], malformed: number, legacy: number}}
|
||
*/
|
||
export function parseStats(text) {
|
||
const lines = String(text ?? '').split('\n');
|
||
const records = [];
|
||
let malformed = 0;
|
||
let legacy = 0;
|
||
let parsed = 0;
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
let rec;
|
||
try {
|
||
rec = JSON.parse(line);
|
||
} catch {
|
||
malformed++;
|
||
continue;
|
||
}
|
||
parsed++;
|
||
if (typeof rec?.effort !== 'string' || rec.effort.length === 0) {
|
||
legacy++;
|
||
continue;
|
||
}
|
||
records.push(rec);
|
||
}
|
||
|
||
if (parsed === 0) {
|
||
throw new Error(`storm-measure: no records in stats file (${malformed} malformed line(s)).`);
|
||
}
|
||
if (records.length === 0) {
|
||
throw new Error(
|
||
`storm-measure: no records carry an \`effort\` field (${legacy} legacy row(s)). ` +
|
||
`The gate groups on \`effort\`; without it there is no treatment arm to measure. ` +
|
||
`Re-run the measurement set on a build that emits the Step 9 fields.`,
|
||
);
|
||
}
|
||
return { records, malformed, legacy };
|
||
}
|
||
|
||
/**
|
||
* Split off the runs that must not count toward a gain.
|
||
*
|
||
* A value that cannot be READ as a turn count is excluded, not treated as zero.
|
||
* `Number('many')` is NaN, and testing `Number.isFinite(empty) && empty > 0`
|
||
* sent NaN down the eligible branch — so a garbage field silently re-entered the
|
||
* denominator, in the direction that flatters adoption: the run whose bookkeeping
|
||
* broke is the run whose numbers deserve the least trust. Absent and null stay
|
||
* eligible via `?? 0`, because a field that was never written is a genuine zero
|
||
* on any run where the loop did not arm.
|
||
*
|
||
* @param {object[]} records
|
||
* @returns {{eligible: object[], excluded: number}}
|
||
*/
|
||
export function partitionEligible(records) {
|
||
const eligible = [];
|
||
let excluded = 0;
|
||
for (const r of records) {
|
||
const empty = Number(r.empty_turns ?? 0);
|
||
if (!Number.isFinite(empty) || empty > 0) excluded++;
|
||
else eligible.push(r);
|
||
}
|
||
return { eligible, excluded };
|
||
}
|
||
|
||
function numbers(records, pick) {
|
||
return records.map(pick).filter((n) => Number.isFinite(n));
|
||
}
|
||
|
||
/** Relative gain (treatment − control)/control. null when control is absent or zero. */
|
||
function relGain(control, treatment) {
|
||
if (control === null || treatment === null || control === 0) return null;
|
||
return (treatment - control) / control;
|
||
}
|
||
|
||
/**
|
||
* Full measurement over a parsed record set.
|
||
*
|
||
* Treatment arm = `effort: high` (the only effort at which the loop runs).
|
||
* Control arm = every other effort.
|
||
*
|
||
* - sources: between-arm median gain in `unique_sources`.
|
||
* - dimensions: within-run median gain of (dimensions − dimensions_baseline)
|
||
* / dimensions_baseline across the treatment arm. It is a
|
||
* within-run delta by construction, so it needs no control arm —
|
||
* the control arm's is 0, the loop being inert there.
|
||
*
|
||
* @param {object[]} records
|
||
*/
|
||
export function measure(records) {
|
||
const { eligible, excluded } = partitionEligible(records);
|
||
const treatment = eligible.filter((r) => r.effort === 'high');
|
||
const control = eligible.filter((r) => r.effort !== 'high');
|
||
|
||
const srcControl = median(numbers(control, (r) => Number(r.unique_sources)));
|
||
const srcTreatment = median(numbers(treatment, (r) => Number(r.unique_sources)));
|
||
const sourcesGain = relGain(srcControl, srcTreatment);
|
||
|
||
const dimDeltas = treatment
|
||
.map((r) => ({ d: Number(r.dimensions), b: Number(r.dimensions_baseline) }))
|
||
.filter(({ d, b }) => Number.isFinite(d) && Number.isFinite(b) && b > 0)
|
||
.map(({ d, b }) => (d - b) / b);
|
||
const dimensionsGain = median(dimDeltas);
|
||
|
||
return {
|
||
control: { n: control.length, sources: srcControl },
|
||
treatment: { n: treatment.length, sources: srcTreatment },
|
||
sources: { control: srcControl, treatment: srcTreatment, gain: sourcesGain },
|
||
dimensions: { gain: dimensionsGain, n: dimDeltas.length },
|
||
excluded,
|
||
verdict: decideVerdict(sourcesGain, dimensionsGain),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Pre-registered mapping, verbatim from the brief: "median forbedring >= 30 %
|
||
* på (a) eller (b) → adopt. < 15 % → decline." OR on both sides, adopt
|
||
* evaluated first — so a strong win on one axis is an adopt even when the
|
||
* other axis sits under the decline bar. A stricter AND rule may well be the
|
||
* better decision procedure, but changing it here is changing the
|
||
* pre-registration after the fact, which is the one thing the constraint
|
||
* exists to prevent.
|
||
*
|
||
* @param {number|null} sourcesGain
|
||
* @param {number|null} dimensionsGain
|
||
* @returns {'adopt'|'decline'|'inconclusive'|'insufficient-data'}
|
||
*/
|
||
export function decideVerdict(sourcesGain, dimensionsGain) {
|
||
if (sourcesGain === null || sourcesGain === undefined) return 'insufficient-data';
|
||
if (dimensionsGain === null || dimensionsGain === undefined) return 'insufficient-data';
|
||
if (sourcesGain >= ADOPT_THRESHOLD || dimensionsGain >= ADOPT_THRESHOLD) return 'adopt';
|
||
if (sourcesGain < DECLINE_THRESHOLD || dimensionsGain < DECLINE_THRESHOLD) return 'decline';
|
||
return 'inconclusive';
|
||
}
|
||
|
||
/**
|
||
* SC activation check: did the most recent `effort: high` run actually discover
|
||
* dimensions beyond its interview baseline?
|
||
* @param {object[]} records
|
||
*/
|
||
export function activationCheck(records) {
|
||
const high = records.filter((r) => r.effort === 'high');
|
||
if (high.length === 0) {
|
||
return { ok: false, reason: 'no `effort: high` run found in stats', discovered_dimensions: null };
|
||
}
|
||
const last = high[high.length - 1];
|
||
const d = Number(last.dimensions);
|
||
const b = Number(last.dimensions_baseline);
|
||
if (!Number.isFinite(d) || !Number.isFinite(b)) {
|
||
return { ok: false, reason: 'latest high run lacks dimensions/dimensions_baseline', discovered_dimensions: null };
|
||
}
|
||
const discovered = d - b;
|
||
return {
|
||
ok: discovered >= 1,
|
||
ts: last.ts ?? null,
|
||
dimensions: d,
|
||
dimensions_baseline: b,
|
||
discovered_dimensions: discovered,
|
||
conv_turns: Number(last.conv_turns ?? 0),
|
||
empty_turns: Number(last.empty_turns ?? 0),
|
||
};
|
||
}
|
||
|
||
// ---- CLI shim ----------------------------------------------------------------
|
||
|
||
function pct(x) {
|
||
return x === null ? 'n/a' : `${(x * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
function defaultStatsPath(env = process.env) {
|
||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||
return dir ? join(dir, STATS_FILENAME) : null;
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const o = { stats: null, json: false, activation: false, help: false };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--stats') o.stats = argv[++i];
|
||
else if (a === '--json') o.json = true;
|
||
else if (a === '--activation-check') o.activation = true;
|
||
else if (a === '--help' || a === '-h') o.help = true;
|
||
else { process.stderr.write(`Unknown argument: ${a}\n`); process.exit(2); }
|
||
}
|
||
return o;
|
||
}
|
||
|
||
function mainCli() {
|
||
const o = parseArgs(process.argv.slice(2));
|
||
if (o.help) {
|
||
process.stdout.write(
|
||
'Usage: storm-measure.mjs [--stats FILE] [--activation-check] [--json]\n' +
|
||
' Default --stats: ${CLAUDE_PLUGIN_DATA}/' + STATS_FILENAME + '\n' +
|
||
' Thresholds (pre-registered, docs/storm-measurement.md): ' +
|
||
`adopt >= ${ADOPT_THRESHOLD * 100}%, decline < ${DECLINE_THRESHOLD * 100}%\n`,
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
const statsPath = o.stats || defaultStatsPath();
|
||
if (!statsPath) {
|
||
process.stderr.write('storm-measure: CLAUDE_PLUGIN_DATA is not set and no --stats FILE was given.\n');
|
||
process.exit(2);
|
||
}
|
||
if (!existsSync(statsPath)) {
|
||
process.stderr.write(`storm-measure: stats file not found: ${statsPath}\n`);
|
||
process.exit(2);
|
||
}
|
||
|
||
let parsed;
|
||
try {
|
||
parsed = parseStats(readFileSync(statsPath, 'utf-8'));
|
||
} catch (e) {
|
||
process.stderr.write(`${e.message}\n`);
|
||
process.exit(2);
|
||
}
|
||
|
||
if (o.activation) {
|
||
const res = activationCheck(parsed.records);
|
||
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
||
process.exit(res.ok ? 0 : 1);
|
||
}
|
||
|
||
const m = measure(parsed.records);
|
||
|
||
if (o.json) {
|
||
process.stdout.write(JSON.stringify({ statsPath, ...m, malformed: parsed.malformed, legacy: parsed.legacy }, null, 2) + '\n');
|
||
process.exit(0);
|
||
}
|
||
|
||
const L = [];
|
||
L.push(`STORM adoption gate — ${statsPath}`);
|
||
L.push(` control (effort != high): n=${m.control.n} median unique_sources=${m.control.sources ?? 'n/a'}`);
|
||
L.push(` treatment (effort = high): n=${m.treatment.n} median unique_sources=${m.treatment.sources ?? 'n/a'}`);
|
||
L.push(` excluded (empty_turns > 0): ${m.excluded}`);
|
||
if (parsed.legacy) L.push(` legacy rows without \`effort\`: ${parsed.legacy}`);
|
||
if (parsed.malformed) L.push(` malformed lines: ${parsed.malformed}`);
|
||
L.push('');
|
||
L.push(` median gain, unique_sources: ${pct(m.sources.gain)}`);
|
||
L.push(` median gain, dimensions over baseline: ${pct(m.dimensions.gain)} (n=${m.dimensions.n})`);
|
||
L.push('');
|
||
L.push(` thresholds: adopt >= ${pct(ADOPT_THRESHOLD)} on EITHER · decline < ${pct(DECLINE_THRESHOLD)} on EITHER · adopt wins ties`);
|
||
L.push(` VERDICT: ${m.verdict}`);
|
||
process.stdout.write(L.join('\n') + '\n');
|
||
process.exit(0);
|
||
}
|
||
|
||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||
mainCli();
|
||
}
|