/** * The preregistered analysis for EXP-001, runnable by anyone. * * npx tsx analysis/analyse.ts # fetches the live open data * npx tsx analysis/analyse.ts path/to/entries.csv # or analyses a saved file * * This is deliberately the *same* code the site runs: it imports the project's * statistics library rather than reimplementing it, so a number published on a * page and a number produced here cannot quietly disagree. If you want to check * our arithmetic, replacing these imports with your own implementations is the * point of the exercise. * * Analysis plan: protocols/EXP-001.md §4. Nothing here may change while the * register is open. */ import { readFile } from "node:fs/promises"; import { CHANCE, chanceBand, detectableRate, percent, wilsonInterval } from "../src/lib/stats"; import { approxBinomialP, holmAdjust } from "../src/lib/holm"; import { SIGNS } from "../src/lib/signs"; const BASE = "https://occultresearch.org/api/data"; const MILESTONES = [1_000, 5_000, 10_000, 25_000]; type Entry = { entryDate: string; participantKey: string; claimedSign: string; isHit: boolean; referral: string; }; /** Minimal CSV reader: the export quotes every field and escapes `"` as `""`. */ function parseCsv(text: string): Entry[] { const rows: Entry[] = []; for (const line of text.split(/\r?\n/)) { if (!line || line.startsWith("#") || line.startsWith("entry_date")) continue; const cells: string[] = []; let cell = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"' && line[i + 1] === '"') { cell += '"'; i++; } else if (ch === '"') { inQuotes = false; } else { cell += ch; } } else if (ch === '"') { inQuotes = true; } else if (ch === ",") { cells.push(cell); cell = ""; } else { cell += ch; } } cells.push(cell); if (cells.length < 5) continue; rows.push({ entryDate: cells[0], participantKey: cells[1], claimedSign: cells[2], isHit: cells[3] === "1", referral: cells[4], }); } return rows; } function line(label: string, value: string) { console.log(`${label.padEnd(34)} ${value}`); } async function main() { const source = process.argv[2]; const text = source ? await readFile(source, "utf8") : await fetch(`${BASE}/blind-horoscope-test`).then((r) => { if (!r.ok) throw new Error(`open data unavailable: HTTP ${r.status}`); return r.text(); }); const entries = parseCsv(text); console.log("\nEXP-001 — blind horoscope identification"); console.log("Analysis plan: protocols/EXP-001.md §4\n"); if (entries.length === 0) { console.log("No entries yet. The register has not opened, or today is the only"); console.log("day with data and same-day entries are sealed until 00:00 UTC.\n"); return; } // ---------------------------------------------------------------- primary const n = entries.length; const hits = entries.filter((e) => e.isHit).length; const rate = hits / n; const band = chanceBand(n); const ci = wilsonInterval(hits, n); const p = approxBinomialP(hits, n); console.log("PRIMARY OUTCOME"); line("Entries", n.toLocaleString("en-US")); line("Correct", hits.toLocaleString("en-US")); line("Observed rate", percent(rate)); line("Chance (1/12)", percent(CHANCE)); line("95% CI (Wilson)", `${percent(ci.low, 1)} – ${percent(ci.high, 1)}`); line("Consistent with chance", `${percent(band.low, 1)} – ${percent(band.high, 1)}`); line("p (normal approx.)", p.toFixed(4)); line("Smallest resolvable rate", percent(detectableRate(n), 1)); const inside = rate >= band.low && rate <= band.high; console.log( `\n → ${inside ? "Indistinguishable from chance." : "Outside the band of chance."}` + "\n The exact binomial test in the protocol is the one that settles it;" + "\n the normal approximation above is a fast check, not the filed test.\n", ); // Milestones exist so conclusions are drawn at prespecified points rather // than whenever the number looks interesting. const reached = MILESTONES.filter((m) => n >= m); line("Milestones reached", reached.length ? reached.join(", ") : "none yet"); const next = MILESTONES.find((m) => n < m); if (next) line("Next milestone", `${next.toLocaleString("en-US")} (${(next - n).toLocaleString("en-US")} to go)`); // --------------------------------------------------------------- per sign console.log("\nBY CLAIMED SIGN (Holm-corrected — protocol §4)\n"); const rows = SIGNS.map((sign) => { const subset = entries.filter((e) => e.claimedSign === sign); const sn = subset.length; const sh = subset.filter((e) => e.isHit).length; return { sign, n: sn, hits: sh, rate: sn ? sh / sn : 0, p: approxBinomialP(sh, sn) }; }); console.log(" sign n hits rate p(raw) p(Holm) flag"); for (const row of holmAdjust(rows)) { console.log( " " + row.sign.padEnd(13) + String(row.n).padStart(5) + String(row.hits).padStart(7) + (row.n ? percent(row.rate, 1) : "—").padStart(8) + row.p.toFixed(3).padStart(9) + row.pAdjusted.toFixed(3).padStart(9) + (row.significant ? " FLAG" : " —"), ); } console.log( "\n Twelve comparisons at .05 produce at least one spurious flag about 46%" + "\n of the time. That is why the corrected column is the one that counts.\n", ); // ------------------------------------------------------------- exploratory // Not part of the confirmatory plan. Labelled as such so it cannot be read // as a preregistered result. console.log("EXPLORATORY (not preregistered)\n"); const byReferral = new Map(); for (const entry of entries) { const acc = byReferral.get(entry.referral) ?? { n: 0, hits: 0 }; acc.n += 1; acc.hits += entry.isHit ? 1 : 0; byReferral.set(entry.referral, acc); } for (const [referral, acc] of [...byReferral.entries()].sort((a, b) => b[1].n - a[1].n)) { line(` ${referral}`, `n=${acc.n}, rate ${percent(acc.hits / acc.n, 1)}`); } const participants = new Set(entries.map((e) => e.participantKey)).size; line(" Distinct participants", participants.toLocaleString("en-US")); line(" Entries per participant", (n / participants).toFixed(2)); console.log(""); } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });