#!/usr/bin/env node /* false-alarms.cjs — how often does a method report a pattern that is not there? * * Method was committed before this ran: docs/distribution/study/METHOD.md * * node scripts/study/false-alarms.cjs [--diaries 100] [--out results.json] * * Synthetic diaries only. Nothing here touches a real person's record. */ 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const ROOT = path.resolve(__dirname, '..', '..'); // The two files this study leans on ship beside it in this bundle. Inside the // repository they live at pages/arkhelion-site/public/js/what-came-before.js // and scripts/read/dow.cjs, so prefer the local copy and fall back to those. function load(local, ...repo) { const here = path.join(__dirname, local); return fs.existsSync(here) ? require(here) : require(path.join(ROOT, ...repo)); } const L = load('what-came-before.js', 'pages', 'arkhelion-site', 'public', 'js', 'what-came-before.js'); const { deweekdaySeries } = load('dow.cjs', 'scripts', 'read', 'dow.cjs'); /* ── a seeded generator, so every diary in the study can be rebuilt ── */ 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); } const DRIVERS = ['steps', 'sleep hours', 'pain', 'caffeine', 'stress', 'screen time', 'minutes outdoors', 'resting heart rate']; const WEEKLY = new Set(['steps', 'caffeine', 'screen time']); // three of them have a weekend const PHI = 0.5; // day-to-day drift const MISSING = 0.15; // ragged logging /* plant: null for a pure-noise diary, or {driver, lag, strength} for the power check */ function makeDiary(days, seed, plant) { const r = rng(seed); const start = Date.UTC(2025, 0, 1); const series = {}; for (const d of DRIVERS) { const col = new Array(days); let prev = 0; for (let i = 0; i < days; i++) { const dow = new Date(start + i * 86400000).getUTCDay(); const weekend = (dow === 0 || dow === 6) ? 1 : 0; prev = PHI * prev + gauss(r); col[i] = prev + (WEEKLY.has(d) ? weekend * 0.8 : 0); } series[d] = col; } const outcome = new Array(days); let prev = 0; for (let i = 0; i < days; i++) { const dow = new Date(start + i * 86400000).getUTCDay(); const weekend = (dow === 0 || dow === 6) ? 1 : 0; prev = PHI * prev + gauss(r); let v = prev + weekend * 0.6; if (plant && i - plant.lag >= 0) v += plant.strength * series[plant.driver][i - plant.lag]; outcome[i] = v; } const head = ['date', ...DRIVERS, 'how I felt']; const rows = [head.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 ? '' : outcome[i].toFixed(3); rows.push([iso, ...cells, out].join(',')); } return rows.join('\n'); } /* ── the three arms ── */ const OUTCOME = 'how I felt'; const LAGS = [0, 1, 2, 3]; function spearman(a, b) { return L.pearson(L.ranksOf(a), L.ranksOf(b)); } /* p from the usual t approximation — what a spreadsheet or an assistant uses */ function pOf(rho, n) { if (!(n > 3) || !Number.isFinite(rho)) return 1; const t = Math.abs(rho) * Math.sqrt((n - 2) / Math.max(1e-12, 1 - rho * rho)); const df = n - 2; const x = df / (df + t * t); return betainc(x, df / 2, 0.5); // two-sided } /* regularised incomplete beta, continued fraction (Numerical Recipes shape) */ function betainc(x, a, b) { if (x <= 0) return 0; if (x >= 1) return 1; const lbeta = lgamma(a + b) - lgamma(a) - lgamma(b); const front = Math.exp(Math.log(x) * a + Math.log(1 - x) * b + lbeta) / a; let f = 1, c = 1, d = 0; for (let i = 0; i <= 300; i++) { const m = Math.floor(i / 2); let num; if (i === 0) num = 1; else if (i % 2 === 0) num = (m * (b - m) * x) / ((a + 2 * m - 1) * (a + 2 * m)); else num = -((a + m) * (a + b + m) * x) / ((a + 2 * m) * (a + 2 * m + 1)); d = 1 + num * d; if (Math.abs(d) < 1e-30) d = 1e-30; d = 1 / d; c = 1 + num / c; if (Math.abs(c) < 1e-30) c = 1e-30; f *= c * d; if (Math.abs(1 - c * d) < 1e-10) break; } const v = front * (f - 1); return x < (a + 1) / (a + b + 2) ? v : 1 - v; } function lgamma(z) { const g = [676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7]; if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - lgamma(1 - z); z -= 1; let x = 0.99999999999980993; for (let i = 0; i < g.length; i++) x += g[i] / (z + i + 1); const t = z + g.length - 0.5; return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); } function grid(series) { const y = series.columns[OUTCOME]; const tests = []; for (const d of DRIVERS) { const x = series.columns[d]; if (!x) continue; for (const lag of LAGS) { const 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); } if (A.length < 20) continue; const rho = spearman(A, B); tests.push({ driver: d, lag, rho, n: A.length, p: pOf(rho, A.length) }); } } return tests; } function armA(series) { return grid(series).filter(t => t.p < 0.05); } function armB(series) { const tests = grid(series); const q = L.adjustQ(tests.map(t => t.p), tests.length); return tests.filter((t, i) => q[i] < 0.06); } function armC(csv) { const s = L.buildSeries(L.parseCSV(csv)); deweekdaySeries(s); const res = L.analyze(s, OUTCOME, {}); return { findings: res.survivors.length, short: !!res.resolutionShort }; } /* ── run ── */ function main() { const argv = process.argv.slice(2); const di = argv.indexOf('--diaries'); const DIARIES = di >= 0 ? parseInt(argv[di + 1], 10) : 100; 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, 'results.json') : path.join(ROOT, 'docs', 'distribution', 'study', 'results.json'); const LENGTHS = [60, 90, 180, 292]; const results = { generatedAt: new Date().toISOString(), diariesPerCell: DIARIES, cells: [], power: [] }; for (const days of LENGTHS) { const tally = { days, A: 0, B: 0, C: 0, aFind: 0, bFind: 0, cFind: 0, cShort: 0 }; for (let k = 0; k < DIARIES; k++) { const csv = makeDiary(days, 20260918 + k + days * 1000, null); const s = L.buildSeries(L.parseCSV(csv)); const a = armA(s), b = armB(s), c = armC(csv); if (a.length) tally.A++; if (b.length) tally.B++; if (c.findings) tally.C++; tally.aFind += a.length; tally.bFind += b.length; tally.cFind += c.findings; if (c.short) tally.cShort++; } results.cells.push(tally); console.log(`noise ${days} days A ${(100 * tally.A / DIARIES).toFixed(0)}% B ${(100 * tally.B / DIARIES).toFixed(0)}% C ${(100 * tally.C / DIARIES).toFixed(0)}%`); } for (const days of LENGTHS) { const tally = { days, A: 0, B: 0, C: 0 }; for (let k = 0; k < DIARIES; k++) { const plant = { driver: 'steps', lag: 2, strength: 0.45 }; const csv = makeDiary(days, 77000000 + k + days * 1000, plant); const s = L.buildSeries(L.parseCSV(csv)); if (armA(s).some(t => t.driver === 'steps' && t.lag === 2)) tally.A++; if (armB(s).some(t => t.driver === 'steps' && t.lag === 2)) tally.B++; const cs = L.buildSeries(L.parseCSV(csv)); deweekdaySeries(cs); const res = L.analyze(cs, OUTCOME, {}); if (res.survivors.some(t => t.driver === 'steps' && t.lag === 2)) tally.C++; } results.power.push(tally); console.log(`plant ${days} days A ${(100 * tally.A / DIARIES).toFixed(0)}% B ${(100 * tally.B / DIARIES).toFixed(0)}% C ${(100 * tally.C / DIARIES).toFixed(0)}%`); } fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, JSON.stringify(results, null, 1)); console.log('wrote', OUT); } if (require.main === module) main(); module.exports = { makeDiary, armA, armB, armC, pOf };