#!/usr/bin/env node /* checks.cjs — the extra checks behind every number published beside the * false alarm study, in one deterministic file with no dependencies. * * node scripts/study/checks.cjs [--out checks.json] * * It reuses false-alarms.cjs (its diaries and arms A and B), the engine * what-came-before.js and the weekday step dow.cjs, loaded the same way the * harness loads them: a copy beside this file wins, else the repository path. * So it runs inside the repo and from the flat bundle folder alike. * * Every seed is fixed and the engine's permutations are seeded, so every * number comes out the same on every run. Synthetic diaries only. * Takes several minutes: about 3,600 engine runs. */ 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const ROOT = path.resolve(__dirname, '..', '..'); const LOADED = {}; function locate(local, ...repo) { const here = path.join(__dirname, local); return fs.existsSync(here) ? here : path.join(ROOT, ...repo); } function load(key, local, ...repo) { const p = locate(local, ...repo); LOADED[key] = p; return require(p); } const L = load('engine', 'what-came-before.js', 'pages', 'arkhelion-site', 'public', 'js', 'what-came-before.js'); const { deweekdaySeries, deweekdayColumn } = load('dow', 'dow.cjs', 'scripts', 'read', 'dow.cjs'); const H = load('harness', 'false-alarms.cjs', 'scripts', 'study', 'false-alarms.cjs'); /* ── the study's fixed settings (copied from the harness; section 0 proves them) ── */ const DRIVERS = ['steps', 'sleep hours', 'pain', 'caffeine', 'stress', 'screen time', 'minutes outdoors', 'resting heart rate']; const WEEKLY = new Set(['steps', 'caffeine', 'screen time']); const OUTCOME = 'how I felt'; const MISSING = 0.15; const LENGTHS = [60, 90, 180, 292]; const N = 100; // diaries per cell, as published const PLANT = { driver: 'steps', lag: 2, strength: 0.45 }; const noiseSeed = (k, days) => 20260918 + k + days * 1000; // harness noise seeds const plantSeed = (k, days) => 77000000 + k + days * 1000; // harness planted seeds // What the engine actually tests: 'pain' reads as a felt-like column and is // left out, and only lags 1-3 can make a headline. 7 x 3 = 21 tests. // Section 5 checks this against the engine's own report. const C_DRIVERS = DRIVERS.filter(d => d !== 'pain'); const C_LAGS = [1, 2, 3]; /* ── random numbers, exactly as the harness draws them ── */ function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } function gauss(r) { const u = Math.max(r(), 1e-12), v = r(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } // splitmix32-style finaliser, so consecutive seeds give unrelated streams. function hash32(x) { x = (x + 0x9e3779b9) >>> 0; x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0; x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0; return (x ^ (x >>> 16)) >>> 0; } /* ── the harness generator, with switches ── * Defaults are the published settings; section 0 proves they reproduce the * harness CSV byte for byte. Random draws keep the harness order: every day of * each driver in turn, then every day of the outcome, then per day eight * driver "missing?" draws and one outcome "missing?" draw. * * weekly default true. Weekend days add 0.8 to steps, caffeine and screen * time and 0.6 to the outcome. false: no weekend * bump anywhere. * phi default 0.5. Each series is prev = phi * prev + gauss(). 0 gives * white noise. * drift default null. null: no drift and no extra draw (the harness). * A number s: each series also carries its own random * walk, walk += s * gauss(), drawn right after that * day's AR draw and added to the value. The draw is * made even when s is 0, exactly as the review's * robust.cjs did, so drift 0 is the published model * on a different random stream. * hash default false. true: the generator is seeded with hash32(seed). * ordinal default false. true: the outcome is written as a whole number, * min(10, max(0, round(4 + 1.2 * y))), after the plant. * No random draws. * plant: null, or {driver, lag, strength} added to the outcome as in the harness. */ function makeDiaryV(days, seed, plant, opt) { const o = opt || {}; const weekly = o.weekly !== false; const phi = o.phi ?? 0.5; const drifting = o.drift !== undefined && o.drift !== null; const r = rng(o.hash ? hash32(seed) : seed); const start = Date.UTC(2025, 0, 1); const weekendOf = i => { const d = new Date(start + i * 86400000).getUTCDay(); return (d === 0 || d === 6) ? 1 : 0; }; const series = {}; for (const d of DRIVERS) { const col = new Array(days); let prev = 0, walk = 0; for (let i = 0; i < days; i++) { const weekend = weekendOf(i); prev = phi * prev + gauss(r); let base = prev; if (drifting) { walk += o.drift * gauss(r); base = prev + walk; } col[i] = base + (weekly && WEEKLY.has(d) ? weekend * 0.8 : 0); } series[d] = col; } const outcome = new Array(days); let prev = 0, walk = 0; for (let i = 0; i < days; i++) { const weekend = weekendOf(i); prev = phi * prev + gauss(r); let base = prev; if (drifting) { walk += o.drift * gauss(r); base = prev + walk; } let v = base + (weekly ? weekend * 0.6 : 0); if (plant && i - plant.lag >= 0) v += plant.strength * series[plant.driver][i - plant.lag]; outcome[i] = v; } const fmtOut = v => (o.ordinal ? String(Math.max(0, Math.min(10, Math.round(4 + 1.2 * v)))) : v.toFixed(3)); const rows = [['date', ...DRIVERS, OUTCOME].join(',')]; for (let i = 0; i < days; i++) { const iso = new Date(start + i * 86400000).toISOString().slice(0, 10); const cells = DRIVERS.map(d => (r() < MISSING ? '' : series[d][i].toFixed(3))); const out = r() < MISSING ? '' : fmtOut(outcome[i]); rows.push([iso, ...cells, out].join(',')); } return rows.join('\n'); } /* ── the harness grid, restrictable to fewer drivers or lags ── */ function seriesOf(csv, weekday) { const s = L.buildSeries(L.parseCSV(csv)); if (weekday) deweekdaySeries(s); return s; } function spearman(a, b) { return L.pearson(L.ranksOf(a), L.ranksOf(b)); } // Pairs driver(t) with outcome(t + lag), both logged, as the harness does. function pairs(series, driver, lag) { const x = series.columns[driver], y = series.columns[OUTCOME]; const A = [], B = []; if (!x) return { A, B }; for (let i = 0; i + lag < y.length; i++) { const xv = x[i], yv = y[i + lag]; if (!Number.isFinite(xv) || !Number.isFinite(yv)) continue; A.push(xv); B.push(yv); } return { A, B }; } function grid(series, drivers, lags) { const tests = []; for (const d of drivers || DRIVERS) { for (const lag of lags || [0, 1, 2, 3]) { const { A, B } = pairs(series, d, lag); if (A.length < 20) continue; const rho = spearman(A, B); tests.push({ driver: d, lag, rho, n: A.length, p: H.pOf(rho, A.length) }); } } return tests; } // Benjamini-Hochberg step-up q-values. function bh(p) { const m = p.length, o = p.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v); const q = new Array(m); let prev = 1; for (let k = m - 1; k >= 0; k--) { prev = Math.min(prev, o[k].v * m / (k + 1)); q[o[k].i] = Math.min(1, prev); } return q; } // A: p<.05. BY: Benjamini-Yekutieli via the engine's own adjustQ, as arm B. function sel(tests, rule) { const p = tests.map(t => t.p); if (rule === 'A') return tests.filter(t => t.p < 0.05); if (rule === 'BY06' || rule === 'BY05') { const q = L.adjustQ(p, tests.length), cut = rule === 'BY06' ? 0.06 : 0.05; return tests.filter((t, i) => q[i] < cut); } if (rule === 'BH05') { const q = bh(p); return tests.filter((t, i) => q[i] < 0.05); } if (rule === 'BONF05') return tests.filter(t => t.p * tests.length < 0.05); throw new Error('unknown rule ' + rule); } // Arm C as published: weekday step, then the engine with its default settings. function engine(csv, weekday) { return L.analyze(seriesOf(csv, weekday), OUTCOME, {}); } const isPlant = t => t.driver === PLANT.driver && t.lag === PLANT.lag; /* ── exact tests ── */ const LNF = [0]; function lnFact(n) { for (let i = LNF.length; i <= n; i++) LNF[i] = LNF[i - 1] + Math.log(i); return LNF[n]; } const lnChoose = (n, k) => lnFact(n) - lnFact(k) - lnFact(n - k); const pmf = (i, n, p) => Math.exp(lnChoose(n, i) + i * Math.log(p) + (n - i) * Math.log1p(-p)); function tailLE(k, n, p) { let s = 0; for (let i = 0; i <= k; i++) s += pmf(i, n, p); return s; } function tailGE(k, n, p) { let s = 0; for (let i = k; i <= n; i++) s += pmf(i, n, p); return s; } function bisect(tooHigh) { // tooHigh(p) is false below the root and true above it let lo = 0, hi = 1; for (let i = 0; i < 200; i++) { const m = (lo + hi) / 2; if (tooHigh(m)) hi = m; else lo = m; } return (lo + hi) / 2; } // Clopper-Pearson exact interval for k successes in n. function clopperPearson(k, n, alpha) { const a = (alpha || 0.05) / 2; const lower = k === 0 ? 0 : bisect(p => tailGE(k, n, p) > a); const upper = k === n ? 1 : bisect(p => tailLE(k, n, p) < a); return [lower, upper]; } // Fisher exact, two-sided, 2x2 table [[a, n1-a], [b, n2-b]]. Sums every table // no more likely than the observed one, with R's fisher.test tolerance. function fisher(a, n1, b, n2) { const K = a + b, M = n1 + n2; const lp = x => lnChoose(n1, x) + lnChoose(n2, K - x) - lnChoose(M, K); const obs = lp(a) + Math.log1p(1e-7); let s = 0; for (let x = Math.max(0, K - n2); x <= Math.min(K, n1); x++) { const v = lp(x); if (v <= obs) s += Math.exp(v); } return Math.min(1, s); } // Exact McNemar: of the discordant diaries, is the split further from 50/50 than chance? function mcnemar(onlyFirst, onlySecond) { const m = onlyFirst + onlySecond; if (m === 0) return 1; return Math.min(1, 2 * tailLE(Math.min(onlyFirst, onlySecond), m, 0.5)); } /* ── output helpers ── */ const T0 = Date.now(); const secs = () => ((Date.now() - T0) / 1000).toFixed(1); const r6 = x => (Number.isFinite(x) ? Number(x.toPrecision(6)) : x); const pct = (k, n) => (100 * k / n).toFixed(k === 0 || k === n ? 0 : 1) + '%'; const mean = a => a.reduce((s, v) => s + v, 0) / a.length; const sd = a => { const m = mean(a); return Math.sqrt(a.reduce((s, v) => s + (v - m) ** 2, 0) / (a.length - 1)); }; function heading(n, title) { console.log(`\n[${secs()} s] ${n}. ${title}`); } function line(text) { console.log(` ${text} (${secs()} s)`); } /* ── 0. the switchable generator is the harness generator ── */ function section0() { heading(0, 'the generator variant at published settings equals the harness'); const sameTests = (x, y) => JSON.stringify(x.map(t => [t.driver, t.lag, t.rho, t.n, t.p])) === JSON.stringify(y.map(t => [t.driver, t.lag, t.rho, t.n, t.p])); let compared = 0, csvSame = 0, armsSame = 0, hashSame = 0; for (const days of LENGTHS) { for (let k = 0; k < 6; k++) { for (const plant of [null, PLANT]) { const seed = plant ? plantSeed(k, days) : noiseSeed(k, days); const a = H.makeDiary(days, seed, plant); compared++; if (makeDiaryV(days, seed, plant, {}) === a) csvSame++; // This file's grid gives the harness's arm A and arm B exactly. const s = seriesOf(a, false), g = grid(s); if (sameTests(H.armA(s), sel(g, 'A')) && sameTests(H.armB(s), sel(g, 'BY06'))) armsSame++; // hash: true is the harness generator on hash32(seed). if (makeDiaryV(days, seed, plant, { hash: true }) === H.makeDiary(days, hash32(seed), plant)) hashSame++; } } } line(`identical CSV ${csvSame}/${compared}, identical arms A and B ${armsSame}/${compared}, hash switch ${hashSame}/${compared}`); if (csvSame !== compared || armsSame !== compared || hashSame !== compared) { console.error('ABORT: the generator or grid in this file does not reproduce the harness. No numbers written.'); process.exit(1); } return { diariesCompared: compared, lengths: LENGTHS, seedsPerLength: '6 noise + 6 planted, k = 0..5', identicalCsv: csvSame, identicalArmsAandB: armsSame, hashSwitchMatches: hashSame }; } /* ── one very long series of steps and outcome, same model, no CSV ── */ // Mirrors the review's corr.cjs: steps drawn first, then the outcome, no // missing days, no rounding. Only these two series; the other drivers do not // touch the steps-outcome relationship. function longRun(n, seed, planted) { const r = rng(seed); const start = Date.UTC(2025, 0, 1); const wk = i => { const d = new Date(start + i * 86400000).getUTCDay(); return (d === 0 || d === 6) ? 1 : 0; }; const steps = new Float64Array(n), y = new Float64Array(n); let p = 0; for (let i = 0; i < n; i++) { p = 0.5 * p + gauss(r); steps[i] = p + 0.8 * wk(i); } p = 0; for (let i = 0; i < n; i++) { p = 0.5 * p + gauss(r); y[i] = p + 0.6 * wk(i) + (planted && i >= 2 ? 0.45 * steps[i - 2] : 0); } return { steps, y }; } function longRho(steps, y, lag) { const A = Array.from(steps.subarray(0, steps.length - lag)), B = Array.from(y.subarray(lag)); return { spearman: r6(spearman(A, B)), pearson: r6(L.pearson(A, B)) }; } const LONG_N = 400000, LONG_SEED = 12345; /* ── 1. how big is the planted effect, as a rank correlation ── */ function section1() { heading(1, 'planted effect size: Spearman of steps(t-2) with outcome(t), planted diaries'); const byLength = []; for (const days of LENGTHS) { const raw = [], dw = []; for (let k = 0; k < N; k++) { const csv = H.makeDiary(days, plantSeed(k, days), PLANT); for (const [arr, weekday] of [[raw, false], [dw, true]]) { const { A, B } = pairs(seriesOf(csv, weekday), 'steps', 2); arr.push(spearman(A, B)); } } byLength.push({ days, diaries: N, raw: { mean: r6(mean(raw)), sd: r6(sd(raw)) }, weekdayRemoved: { mean: r6(mean(dw)), sd: r6(sd(dw)) } }); line(`${days} d mean rho raw ${mean(raw).toFixed(3)} (sd ${sd(raw).toFixed(3)}) after weekday removal ${mean(dw).toFixed(3)} (sd ${sd(dw).toFixed(3)})`); } const { steps, y } = longRun(LONG_N, LONG_SEED, true); const rawLong = longRho(steps, y, 2); deweekdayColumn(steps, 0); deweekdayColumn(y, 0); const dwLong = longRho(steps, y, 2); line(`long run, ${LONG_N} days, lag 2: Spearman raw ${rawLong.spearman.toFixed(4)} after weekday removal ${dwLong.spearman.toFixed(4)}`); return { seedRule: '77000000 + k + days*1000, k = 0..99', plant: PLANT, byLength, longRun: { days: LONG_N, seed: LONG_SEED, lag: 2, raw: rawLong, weekdayRemoved: dwLong } }; } /* ── 2. how much the calendar alone correlates things, noise diaries ── */ function section2() { heading(2, 'calendar correlation: same-day Spearman with the outcome, noise diaries, 292 days'); const out = {}; for (const weekday of [false, true]) { const wk = [], other = []; for (let k = 0; k < N; k++) { const s = seriesOf(H.makeDiary(292, noiseSeed(k, 292), null), weekday); for (const d of DRIVERS) { const { A, B } = pairs(s, d, 0); (WEEKLY.has(d) ? wk : other).push(spearman(A, B)); } } const key = weekday ? 'weekdayRemoved' : 'raw'; out[key] = { weeklyDrivers: { mean: r6(mean(wk)), se: r6(sd(wk) / Math.sqrt(wk.length)), values: wk.length }, otherDrivers: { mean: r6(mean(other)), se: r6(sd(other) / Math.sqrt(other.length)), values: other.length } }; line(`${key.padEnd(14)} weekly drivers mean ${mean(wk).toFixed(4)} other drivers mean ${mean(other).toFixed(4)}`); } const { steps, y } = longRun(LONG_N, LONG_SEED, false); const rawLong = longRho(steps, y, 0); deweekdayColumn(steps, 0); deweekdayColumn(y, 0); const dwLong = longRho(steps, y, 0); line(`long run, ${LONG_N} days, steps same day: Spearman raw ${rawLong.spearman.toFixed(4)} after weekday removal ${dwLong.spearman.toFixed(4)}`); return { days: 292, seedRule: '20260918 + k + 292000, k = 0..99', weeklyDrivers: [...WEEKLY], ...out, longRunSteps: { days: LONG_N, seed: LONG_SEED, lag: 0, raw: rawLong, weekdayRemoved: dwLong } }; } /* ── 3. where arm A's false alarms come from, 292 days ── */ function section3() { heading(3, "decomposition of arm A's false alarms, noise diaries, 292 days"); const variants = [ ['white noise (no weekly, no AR)', { weekly: false, phi: 0 }], ['AR only', { weekly: false }], ['weekly only', { phi: 0 }], ['both (published)', {}], ]; const rows = []; for (const [name, opt] of variants) { let flagged = 0, findings = 0, tests = 0; for (let k = 0; k < N; k++) { const g = grid(seriesOf(makeDiaryV(292, noiseSeed(k, 292), null, opt), false)); const a = sel(g, 'A'); if (a.length) flagged++; findings += a.length; tests += g.length; } rows.push({ variant: name, switches: opt, flagged, diaries: N, findings, tests, perTestRate: r6(findings / tests) }); line(`${name.padEnd(32)} flagged ${flagged}/${N} per-test false rate ${(100 * findings / tests).toFixed(2)}%`); } return rows; } /* ── 4. other corrections on the same grid ── */ // Which diaries B and C flagged, per length, for the paired tests in section 10. const perDiary = { B: {}, C: {} }; function section4() { heading(4, 'other corrections on the 32-test grid, noise diaries, harness seeds'); const rows = []; for (const days of LENGTHS) { const c = { days, diaries: N, A: 0, aFind: 0, BY06: 0, bFind: 0, BY05: 0, BH05: 0, BONF05: 0, BY06_weekdayRemoved: 0, BONF05_weekdayRemoved: 0, BH05_weekdayRemoved: 0, noWeekly: { BY06: 0, BH05: 0, BONF05: 0 } }; perDiary.B[days] = []; for (let k = 0; k < N; k++) { const csv = H.makeDiary(days, noiseSeed(k, days), null); const s = seriesOf(csv, false), g = grid(s); const a = H.armA(s), b = H.armB(s); // the published arms, called directly if (a.length) c.A++; if (b.length) c.BY06++; c.aFind += a.length; c.bFind += b.length; if (sel(g, 'BY05').length) c.BY05++; if (sel(g, 'BH05').length) c.BH05++; if (sel(g, 'BONF05').length) c.BONF05++; const gw = grid(seriesOf(csv, true)); if (sel(gw, 'BY06').length) c.BY06_weekdayRemoved++; if (sel(gw, 'BONF05').length) c.BONF05_weekdayRemoved++; if (sel(gw, 'BH05').length) c.BH05_weekdayRemoved++; // The same diary with the weekend bump switched off (same random stream), // so every one of the 32 nulls is true and Bonferroni's promise applies. const gn = grid(seriesOf(makeDiaryV(days, noiseSeed(k, days), null, { weekly: false }), false)); for (const rule of ['BY06', 'BH05', 'BONF05']) if (sel(gn, rule).length) c.noWeekly[rule]++; perDiary.B[days][k] = b.length > 0; } rows.push(c); line(`${days} d A ${c.A} BY q<.06 (B) ${c.BY06} BY q<.05 ${c.BY05} BH q<.05 ${c.BH05} Bonferroni .05 ${c.BONF05} B + weekday ${c.BY06_weekdayRemoved} | no weekend bump: BY.06 ${c.noWeekly.BY06} BH.05 ${c.noWeekly.BH05} Bonf ${c.noWeekly.BONF05} | weekday removed: BH.05 ${c.BH05_weekdayRemoved} Bonf ${c.BONF05_weekdayRemoved}`); } return rows; } /* ── 5. like for like: A and B on the 21 tests arm C runs ── */ function section5() { heading(5, "like for like: A and B on arm C's 21 tests (no pain, lags 1-3), noise diaries"); const rows = []; for (const days of LENGTHS) { const c = { days, diaries: N, A21: 0, B21: 0, A21_weekdayRemoved: 0, B21_weekdayRemoved: 0, C: 0, C_inclSameDay: 0, C_sameDayOnly: 0, cFind: 0, cShort: 0, engineHeadlineTests: new Set(), engineLeftOut: new Set() }; for (let k = 0; k < N; k++) { const csv = H.makeDiary(days, noiseSeed(k, days), null); const g = grid(seriesOf(csv, false), C_DRIVERS, C_LAGS); const gw = grid(seriesOf(csv, true), C_DRIVERS, C_LAGS); if (sel(g, 'A').length) c.A21++; if (sel(g, 'BY06').length) c.B21++; if (sel(gw, 'A').length) c.A21_weekdayRemoved++; if (sel(gw, 'BY06').length) c.B21_weekdayRemoved++; const res = engine(csv, true); const head = res.survivors.length > 0, same = res.sameDay.length > 0; if (head) c.C++; if (head || same) c.C_inclSameDay++; if (same && !head) c.C_sameDayOnly++; c.cFind += res.survivors.length; if (res.resolutionShort) c.cShort++; c.engineHeadlineTests.add(res.comparisons); c.engineLeftOut.add((res.notDrivers || []).join('|')); (perDiary.C[days] = perDiary.C[days] || [])[k] = head; } c.engineHeadlineTests = [...c.engineHeadlineTests]; c.engineLeftOut = [...c.engineLeftOut]; rows.push(c); line(`${days} d A21 ${c.A21} B21 ${c.B21} A21+wkday ${c.A21_weekdayRemoved} B21+wkday ${c.B21_weekdayRemoved} C ${c.C} C incl same day ${c.C_inclSameDay} (engine tests ${c.engineHeadlineTests.join('/')}, left out: ${c.engineLeftOut.join('/')})`); } return rows; } /* ── 6. the engine alone, without the weekday step ── */ function section6() { heading(6, 'engine without the weekday step (the public JS alone)'); const rows = []; for (const days of LENGTHS) { const c = { days, diaries: N, noiseFlagged: 0, noiseInclSameDay: 0, plantedFound: 0 }; for (let k = 0; k < N; k++) { const r = engine(H.makeDiary(days, noiseSeed(k, days), null), false); if (r.survivors.length) c.noiseFlagged++; if (r.survivors.length || r.sameDay.length) c.noiseInclSameDay++; const p = engine(H.makeDiary(days, plantSeed(k, days), PLANT), false); if (p.survivors.some(isPlant)) c.plantedFound++; } rows.push(c); line(`${days} d noise flagged ${c.noiseFlagged} (incl same day ${c.noiseInclSameDay}) planted found ${c.plantedFound}`); } return rows; } /* ── 7. clean hits on planted diaries ── */ // found: steps at lag 2 is reported. clean: found, and no driver other than // steps is reported (steps at another lag is allowed). wrongDriver: any // driver other than steps is reported. C counts its headline list only. function section7() { heading(7, 'clean hits on planted diaries: steps lag 2 found and no other driver flagged'); const rows = []; for (const days of LENGTHS) { const blank = () => ({ found: 0, clean: 0, wrongDriver: 0 }); // B21: arm B held to C's 21 tests (no pain, lags 1-3), so all three are // scored on the same checks C can report. B21w: the same after weekday removal. const c = { days, diaries: N, A: blank(), B: blank(), B21: blank(), B21w: blank(), C: { ...blank(), headlineIsPlant: 0 } }; const tally = (t, list) => { const hit = list.some(isPlant), other = list.some(x => x.driver !== PLANT.driver); if (hit) t.found++; if (hit && !other) t.clean++; if (other) t.wrongDriver++; }; for (let k = 0; k < N; k++) { const csv = H.makeDiary(days, plantSeed(k, days), PLANT); const s = seriesOf(csv, false); tally(c.A, H.armA(s)); tally(c.B, H.armB(s)); tally(c.B21, sel(grid(s, C_DRIVERS, C_LAGS), 'BY06')); tally(c.B21w, sel(grid(seriesOf(csv, true), C_DRIVERS, C_LAGS), 'BY06')); const res = engine(csv, true); tally(c.C, res.survivors); if (res.survivors[0] && isPlant(res.survivors[0])) c.C.headlineIsPlant++; } rows.push(c); line(`${days} d clean A ${c.A.clean} B ${c.B.clean} B21 ${c.B21.clean} B21+wkday ${c.B21w.clean} C ${c.C.clean} wrong driver A ${c.A.wrongDriver} B ${c.B.wrongDriver} C ${c.C.wrongDriver} found A ${c.A.found} B ${c.B.found} B21 ${c.B21.found} C ${c.C.found}`); } return rows; } /* ── 8. fresh seeds, twice as many diaries ── */ const FRESH = 200; function section8() { heading(8, `fresh seeds: ${FRESH} noise diaries per length, seed hash32(20260918 + 500000 + k + days*1000)`); const rows = []; for (const days of LENGTHS) { const c = { days, diaries: FRESH, A: 0, B: 0, C: 0, C_inclSameDay: 0 }; for (let k = 0; k < FRESH; k++) { const csv = H.makeDiary(days, hash32(20260918 + 500000 + k + days * 1000), null); const s = seriesOf(csv, false); if (H.armA(s).length) c.A++; if (H.armB(s).length) c.B++; const r = engine(csv, true); if (r.survivors.length) c.C++; if (r.survivors.length || r.sameDay.length) c.C_inclSameDay++; } rows.push(c); line(`${days} d A ${c.A}/${FRESH} B ${c.B}/${FRESH} C ${c.C}/${FRESH} C incl same day ${c.C_inclSameDay}/${FRESH}`); } return { seedRule: 'hash32(20260918 + 500000 + k + days*1000), k = 0..199, harness makeDiary', byLength: rows }; } /* ── 9. stress the null outside the generator's comfort zone ── */ // "review stream" rows make the drift draw even at drift 0, exactly like the // review's robust.cjs, so they are comparable with it. "harness stream" rows // make no extra draw, so they are the published generator with one switch moved. function section9() { heading(9, 'stress tests, 292 days, 100 noise diaries each, harness noise seeds'); const variants = [ ['drift step sd 0.03', { drift: 0.03 }, 'drift'], ['drift step sd 0.05', { drift: 0.05 }, 'drift'], ['drift step sd 0.10', { drift: 0.10 }, 'drift'], ['drift step sd 0.15', { drift: 0.15 }, 'drift'], ['phi 0.8', { phi: 0.8, drift: 0 }, 'review stream'], ['phi 0.9', { phi: 0.9, drift: 0 }, 'review stream'], ['outcome whole numbers 0-10', { ordinal: true, drift: 0 }, 'review stream'], ['published settings, hashed seeds', { hash: true, drift: 0 }, 'review stream'], ['phi 0.8', { phi: 0.8 }, 'harness stream'], ['phi 0.9', { phi: 0.9 }, 'harness stream'], ['outcome whole numbers 0-10', { ordinal: true }, 'harness stream'], ['published settings, hashed seeds', { hash: true }, 'harness stream'], ]; const rows = []; for (const [name, opt, stream] of variants) { const c = { variant: name, stream, switches: opt, diaries: N, A: 0, B: 0, C: 0, C_inclSameDay: 0 }; for (let k = 0; k < N; k++) { const csv = makeDiaryV(292, noiseSeed(k, 292), null, opt); const s = seriesOf(csv, false); if (H.armA(s).length) c.A++; if (H.armB(s).length) c.B++; const r = engine(csv, true); if (r.survivors.length) c.C++; if (r.survivors.length || r.sameDay.length) c.C_inclSameDay++; } rows.push(c); line(`${(name + ', ' + stream).padEnd(50)} A ${c.A} B ${c.B} C ${c.C} C incl same day ${c.C_inclSameDay}`); } return rows; } /* ── 10. exact intervals and tests ── */ function section10(s4, s5, s7, published) { heading(10, 'exact 95% intervals (Clopper-Pearson) and exact tests'); // This run's own count for each published cell, to prove results.json again. const rerun = { noise: { A: s4.map(c => c.A), B: s4.map(c => c.BY06), C: s5.map(c => c.C) }, power: { A: s7.map(c => c.A.found), B: s7.map(c => c.B.found), C: s7.map(c => c.C.found) }, }; const src = published || { diariesPerCell: N, cells: LENGTHS.map((days, i) => ({ days, A: rerun.noise.A[i], B: rerun.noise.B[i], C: rerun.noise.C[i] })), power: LENGTHS.map((days, i) => ({ days, A: rerun.power.A[i], B: rerun.power.B[i], C: rerun.power.C[i] })) }; const n = src.diariesPerCell; const cells = []; for (const [kind, list] of [['noise', src.cells], ['power', src.power]]) { for (const arm of ['A', 'B', 'C']) { list.forEach((cell, i) => { const k = cell[arm], [lo, hi] = clopperPearson(k, n); cells.push({ kind, arm, days: cell.days, k, n, rate: r6(k / n), ci95: [r6(lo), r6(hi)], thisRun: rerun[kind][arm][i] }); }); line(`${kind} ${arm} ` + list.map(c => { const [lo, hi] = clopperPearson(c[arm], n); return `${c.days}d ${c[arm]}/${n} [${(100 * lo).toFixed(1)}, ${(100 * hi).toFixed(1)}]%`; }).join(' ')); } } // results.json in full, recomputed: noise cells and power cells. const cellsNow = s4.map((c, i) => ({ days: c.days, A: c.A, B: c.BY06, C: s5[i].C, aFind: c.aFind, bFind: c.bFind, cFind: s5[i].cFind, cShort: s5[i].cShort })); const powerNow = s7.map(c => ({ days: c.days, A: c.A.found, B: c.B.found, C: c.C.found })); const reproduced = published ? JSON.stringify(published.cells) === JSON.stringify(cellsNow) && JSON.stringify(published.power) === JSON.stringify(powerNow) : null; line(`results.json reproduced exactly by this run: ${reproduced === null ? 'results.json not found' : reproduced}`); const sum = a => a.reduce((s, v) => s + v, 0); const cK = sum(src.cells.map(c => c.C)), bK = sum(src.cells.map(c => c.B)), pooledN = n * src.cells.length; const [cLo, cHi] = clopperPearson(cK, pooledN), [bLo, bHi] = clopperPearson(bK, pooledN); const fisherP = fisher(cK, pooledN, bK, pooledN); line(`pooled C ${cK}/${pooledN} = ${pct(cK, pooledN)} [${(100 * cLo).toFixed(1)}, ${(100 * cHi).toFixed(1)}]% pooled B ${bK}/${pooledN} [${(100 * bLo).toFixed(1)}, ${(100 * bHi).toFixed(1)}]%`); line(`Fisher exact two-sided, C ${cK}/${pooledN} vs B ${bK}/${pooledN}: p = ${fisherP.toExponential(2)}`); // Paired: B and C ran on the same diaries, so McNemar is the right test. // Once at 292 days, and once pooled over all four lengths (400 diaries). const paired = lengths => { let onlyB = 0, onlyC = 0, both = 0, neither = 0; for (const days of lengths) for (let k = 0; k < N; k++) { const b = perDiary.B[days][k], c = perDiary.C[days][k]; if (b && !c) onlyB++; else if (c && !b) onlyC++; else if (b && c) both++; else neither++; } return { onlyB, onlyC, both, neither, p: mcnemar(onlyB, onlyC) }; }; const m292 = paired([292]), mAll = paired(LENGTHS); const { onlyB, onlyC, both, neither } = m292, mcP = m292.p; line(`McNemar exact, 292 days, same diaries: B only ${onlyB}, C only ${onlyC}, both ${both}, neither ${neither}: p = ${mcP.toPrecision(3)}`); line(`McNemar exact, all 400, same diaries: B only ${mAll.onlyB}, C only ${mAll.onlyC}, both ${mAll.both}, neither ${mAll.neither}: p = ${mAll.p.toPrecision(3)}`); return { source: published ? 'results.json' : 'this run (results.json not found)', resultsJsonReproduced: reproduced, cells, pooledNoiseC: { k: cK, n: pooledN, rate: r6(cK / pooledN), ci95: [r6(cLo), r6(cHi)] }, pooledNoiseB: { k: bK, n: pooledN, rate: r6(bK / pooledN), ci95: [r6(bLo), r6(bHi)] }, fisherPooledCvsB: { test: 'Fisher exact, two-sided', p: r6(fisherP) }, mcnemar292CvsB: { test: 'McNemar exact (binomial), two-sided', onlyB, onlyC, both, neither, p: r6(mcP) }, mcnemarPooledCvsB: { test: 'McNemar exact (binomial), two-sided, all four lengths', onlyB: mAll.onlyB, onlyC: mAll.onlyC, both: mAll.both, neither: mAll.neither, p: r6(mAll.p) }, }; } /* ── 11. the older fix: prewhiten each column, then the textbook test ── */ // The standard time series answer to "both columns lean on yesterday": take // out each column's own lag 1 leaning first, then correlate what is left. // Per column: mean m and lag 1 coefficient phi from the logged pairs, then // e[i] = (x[i] - m) - phi * (x[i-1] - m) wherever both days are logged. // Then the harness grid with its textbook p values, and Benjamini-Yekutieli // at q < .06 over either all 32 tests or C's 21. No resampling anywhere. function prewhiten(series) { for (const name of Object.keys(series.columns)) { const x = series.columns[name]; let sum = 0, n = 0; for (let i = 0; i < x.length; i++) if (Number.isFinite(x[i])) { sum += x[i]; n++; } const m = n ? sum / n : 0; let num = 0, den = 0; for (let i = 0; i + 1 < x.length; i++) { if (Number.isFinite(x[i]) && Number.isFinite(x[i + 1])) { num += (x[i] - m) * (x[i + 1] - m); den += (x[i] - m) ** 2; } } const phi = den > 0 ? num / den : 0; const e = new Float64Array(x.length).fill(NaN); for (let i = 1; i < x.length; i++) if (Number.isFinite(x[i]) && Number.isFinite(x[i - 1])) e[i] = (x[i] - m) - phi * (x[i - 1] - m); series.columns[name] = e; } return series; } function section11() { heading(11, 'prewhitening: AR(1) residuals, textbook p, BY q<.06, with and without the weekday step'); const rows = []; for (const weekday of [false, true]) { for (const days of LENGTHS) { const c = { days, weekdayStep: weekday, diaries: N, noise32: 0, noise21: 0, noiseBonf32: 0, planted21: { found: 0, clean: 0, wrongDriver: 0 }, planted32found: 0 }; for (let k = 0; k < N; k++) { const g = grid(prewhiten(seriesOf(H.makeDiary(days, noiseSeed(k, days), null), weekday))); if (sel(g, 'BY06').length) c.noise32++; if (sel(g, 'BONF05').length) c.noiseBonf32++; if (sel(g.filter(t => t.driver !== 'pain' && t.lag > 0), 'BY06').length) c.noise21++; const gp = grid(prewhiten(seriesOf(H.makeDiary(days, plantSeed(k, days), PLANT), weekday))); const s21 = sel(gp.filter(t => t.driver !== 'pain' && t.lag > 0), 'BY06'); const hit = s21.some(isPlant), other = s21.some(t => t.driver !== PLANT.driver); if (hit) c.planted21.found++; if (hit && !other) c.planted21.clean++; if (other) c.planted21.wrongDriver++; if (sel(gp, 'BY06').some(isPlant)) c.planted32found++; } rows.push(c); line(`${weekday ? 'weekday step, ' : 'no weekday, '}${days} d noise flagged: 32 tests ${c.noise32}, 21 tests ${c.noise21}, Bonferroni 32 ${c.noiseBonf32} planted on 21: found ${c.planted21.found}, clean ${c.planted21.clean} planted on 32 found ${c.planted32found}`); } } return { method: 'per column AR(1) prewhitening (lag 1 coefficient from logged pairs), then harness grid + textbook p + BY q<.06', rows }; } /* ── run ── */ function sha256(p) { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } function shown(p) { const rel = path.relative(ROOT, p); return rel.startsWith('..') ? path.basename(p) : rel; } function main() { const argv = process.argv.slice(2); const oi = argv.indexOf('--out'); const OUT = oi >= 0 ? argv[oi + 1] : fs.existsSync(path.join(__dirname, 'README.md')) // running from the bundle ? path.join(__dirname, 'checks.json') : path.join(ROOT, 'docs', 'distribution', 'study', 'checks.json'); const resultsPath = locate('results.json', 'docs', 'distribution', 'study', 'results.json'); const published = fs.existsSync(resultsPath) ? JSON.parse(fs.readFileSync(resultsPath, 'utf8')) : null; console.log(`checks.cjs, node ${process.version}`); const out = { generatedAt: new Date().toISOString(), node: process.version, command: ['node', shown(__filename), ...argv].join(' ') }; out.inputs = Object.fromEntries([...Object.entries(LOADED), ['checks', __filename]].concat(published ? [['results', resultsPath]] : []) .map(([k, p]) => [k, { file: shown(p), sha256: sha256(p) }])); out.settings = { lengths: LENGTHS, diariesPerCell: N, noiseSeed: '20260918 + k + days*1000', plantedSeed: '77000000 + k + days*1000', plant: PLANT, armA: 'Spearman, 8 drivers x lags 0-3, p < .05', armB: 'same grid, Benjamini-Yekutieli q < .06', armC: 'dow.cjs deweekdaySeries, then what-came-before.js analyze() defaults; flagged = headline list non-empty' }; out.section0_generatorIdentity = section0(); out.section1_plantedEffectSize = section1(); out.section2_calendarCorrelation = section2(); out.section3_decompositionArmA292 = section3(); const s4 = section4(); out.section4_otherCorrections = s4; const s5 = section5(); out.section5_likeForLike21 = s5; out.section6_engineWithoutWeekday = section6(); const s7 = section7(); out.section7_cleanHitsPlanted = s7; out.section8_freshSeeds = section8(); out.section9_stress292 = section9(); out.section10_exact = section10(s4, s5, s7, published); out.section11_prewhitening = section11(); out.runtimeSeconds = Number(secs()); fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, JSON.stringify(out, null, 1) + '\n'); console.log(`\n[${secs()} s] wrote ${OUT}`); } if (require.main === module) main(); module.exports = { makeDiaryV, hash32, grid, sel, clopperPearson, fisher, mcnemar };