/* Arkhelion — Lookback * * Reads a table of days the visitor already has, and reports associations * between each column and a chosen "how I felt" column at 1-3 DAYS BEFORE, * with the same day checked separately. A same-row comparison cannot see a * delayed effect at all, so it gets reported as "no pattern found". * (Corrected 2026-09-11: this used to say every shipping tracker compares only * the same row. At least one, Within, now checks delayed effects too, so the * page makes no claim to be the only tool that does.) * * NETWORK: this file makes zero network calls, by design. The page's promise * is "your file never leaves this page", and that promise is only worth * anything if the code can be read and checked. Do not add fetch/XHR/beacon * here. If a future version needs to send something, it must be behind an * explicit button whose label says what is sent. * * CSP: the site is script-src 'self' / style-src 'self'. No inline handlers, * no inline styles, no CDN libraries. Everything is hand-rolled on purpose. * * STATISTICS, and why each piece is here: * - Spearman (rank) rather than Pearson: one catastrophic day must not be * able to manufacture a correlation on its own. * - Circular BLOCK resampling rather than plain shuffling, with the block * length scaled to the pair's own persistence: symptom data is * autocorrelated — good and bad spells come in runs — and a plain shuffle * destroys that, which makes the null far too easy to beat and turns * ordinary persistence into a "finding". * - Benjamini-YEKUTIELI within each FAMILY: the 1-3 days before (3 lags x k * drivers), and separately the same day, which is corrected against the * whole grid (4 lags x k) so it is never looser than one joint correction. * A subset picked out of one joint correction is not itself controlled, * which is why the days before get their own. The tests are * arbitrarily dependent rather than independent. Reporting the best of * forty without correction is the single most common way a tool like this * lies to someone. * - Only the days before can be the headline, and a column that reads like * another way of saying how you felt is not checked as a cause. Both are * announced on the page. See analyze() and driversFor(). * - An honest null is printed when nothing survives, including the effect * size that would have been needed. A tool that can never say "nothing" * is not measuring anything. */ (function () { 'use strict'; // ────────────────────────────────────────────────────────── constants var LAGS = [0, 1, 2, 3]; // every lag that is tested // Only these can be the headline, and they are corrected as their own family. // See analyze() for why the same day is kept apart. var BEFORE_LAGS = [1, 2, 3]; var PERMUTATIONS = 2000; // floor; analyze() raises it with the test count var MAX_PERMUTATIONS = 24000; // ceiling on resamples per pairing // Ceiling on TOTAL work (resamples x drivers), which is what the visitor // actually waits for. Capping only MAX_PERMUTATIONS is not enough: at 60 // driver columns, 24000 resamples each measured 18.3 seconds on a fast Mac, // which is a minute or more of an apparently frozen tab on the phone this // page is most often opened on. 300k holds the worst case near 4 seconds // here. When this cap binds, resolutionShort goes true and the page SAYS the // bar was out of reach rather than printing a confident "nothing found". var MAX_WORK = 300000; var BLOCK_DAYS = 7; var MIN_PAIRS = 20; // EXCLUSIVE: a pairing is reported when q < Q_CUTOFF. The q is printed to three // decimals, so an inclusive cutoff let "q 0.100" through beside a sentence // saying it had passed a bar of 0.10. // // 0.06, NOT 0.10, SINCE 2026-09-11. The page tells the reader that on made-up // data with no pattern in it this "invents one in 2 to 5" runs out of 100, at // four levels of persistence. At 0.10 that was not true: measured at the page's // own resampling, 1000 runs per level, the worst level showed something in // 5.3% of runs (95% upper bound 6.9). At 0.06, over two seed sets of 1000 runs // per level, every level sits at 2.7 to 3.4% (worst level's upper bound 4.2), // and the planted lag in the audit export still headlines in 39 of 50 seeds // (42 at 0.10). Lane A2c in scripts/test-what-came-before.sh pins this bar to // that measurement. Loosening it means measuring again and changing the page. var Q_CUTOFF = 0.06; var DAY_MS = 86400000; // Column-name hints for guessing which column means "how I felt". var OUTCOME_HINT = /feel|felt|symptom|severity|pain|fatigue|crash|flare|energy|mood|score|wellbeing|well-being|pem/i; // Which way a scale runs, guessed from its name (always shown and correctable // on the page). Defined up here, above the document guard, because // chooseOutcome() prefers a higher-is-worse column and runs in the test runner. var HIGHER_IS_BETTER = /energy|mood|wellbeing|well-being|score|steps|sleep/i; var HIGHER_IS_WORSE = /pain|fatigue|severity|crash|flare|symptom|pem|ache|nausea/i; // A column that reads like another way of saying how you felt, rather than // something you did. Not checked as a cause (see driversFor). NOT_A_FEELING // rescues columns that borrow those words for something you do or take: // "Active energy", "Pain relief (mg)", "Mood stabiliser", "Energy drink". // WIDENED 2026-09-16 against a real Visible export of 366 days. // // The old list held the words a healthy person uses for illness: pain, // fatigue, nausea, mood. A real tracker from someone who is unwell in // several ways at once names depression, blurred vision, lightheadedness, // noise sensitivity, memory issues, shortness of breath, diarrhoea. Every // one of those fell through, so with "Stomach pain" as the outcome the page // reported sixteen findings that were almost all one illness measured twice, // led by "on days when Depression was higher than usual, you tended to feel // better 2 days later". Two symptoms move together because the illness // moves; the sign of that is an artefact of which scale runs which way. // // Each addition is a word that appears in a real export's item list. Shapes // that could name an ACTIVITY are deliberately written narrowly: // "shortness of breath" rather than "breath" (breathwork is a thing people // do), "\bgut\b" rather than "gut". var FELT_LIKE = /feel|felt|symptom|severity|pain|fatigue|crash|flare|\bpem\b|malaise|ache|nausea|dizz|brain ?fog|migraine|mood|energy|wellbeing|well-being|depress|anxi|panic|vision|photophob|(light|noise|sound|smell) sensitiv|tinnitus|lighthead|faint|vertigo|memory|concentrat|cognit|shortness of breath|breathless|palpitat|diarrh|constipat|bloat|reflux|stomach|\bgut\b|nausea|throat|swollen|lymph|fever|chill|night sweat|insomnia|unrefresh|weakness|tremor|numb|tingl|neuropath|itch|hive|headache|sore/i; var NOT_A_FEELING = /medic|\bmeds?\b|pill|tablet|capsule|\bmg\b|dose|killer|relie[fv]|stabili[sz]|burn|active|kcal|calorie|expend|\bkj\b|drink/i; var DATE_HINT = /date|day|time|when/i; // A weekday name. Text that repeats, so pivotLongFormat must rule it out as a // list of items. Mon, Tues, Wednesday, Thurs., Sat and so on. var WEEKDAY = /^(mon|tues?|wed(nes)?|thu(rs?)?|fri|sat(ur)?|sun)(day)?\.?$/i; // A time-of-day slot or a clock time, as Bearable writes them: "pre", "am", // "mid", "pm", "all day", "23:03". Checked against the STRUCTURE of the real // exports (that column's distinct tokens), never their values. Ruled out as a // pivot key for the same reason as WEEKDAY. var TIME_OF_DAY = /^(pre|am|mid|pm|all ?day|a\.m\.|p\.m\.|midday|noon|morning|afternoon|evening|night|overnight|bedtime|any ?time|\d{1,2}:\d{2}(:\d{2})?(\s*[ap]\.?m\.?)?|\d{1,2}\s*[ap]\.?m\.?)$/i; // ────────────────────────────────────────────────────────── tiny helpers function isNum(v) { return typeof v === 'number' && isFinite(v); } // Deterministic PRNG (mulberry32). Seeded so the example data and the // self-test are reproducible — an example that changes every reload cannot // be used as evidence of anything. function rng(seed) { var a = seed >>> 0; return function () { a = (a + 0x6D2B79F5) >>> 0; var t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function gauss(rand) { var u = 1 - rand(), v = rand(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } // ────────────────────────────────────────────────────────── CSV // RFC4180-ish: quoted fields, doubled quotes inside them, CR/LF, and a // delimiter sniff so tab- and semicolon-separated exports also work. function sniffDelimiter(text) { var head = text.slice(0, 4000); var best = ',', bestN = -1; [',', '\t', ';', '|'].forEach(function (d) { var n = head.split(d).length; if (n > bestN) { bestN = n; best = d; } }); return best; } function parseCSV(text) { text = String(text).replace(/^/, ''); var delim = sniffDelimiter(text); var rows = [], field = '', row = [], inQuotes = false; for (var i = 0; i < text.length; i++) { var c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else { field += c; } continue; } if (c === '"') { inQuotes = true; continue; } if (c === delim) { row.push(field); field = ''; continue; } if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); field = ''; if (row.length > 1 || row[0] !== '') rows.push(row); row = []; continue; } field += c; } row.push(field); if (row.length > 1 || row[0] !== '') rows.push(row); if (!rows.length) return { header: [], rows: [] }; // SKIP A TITLE ROW ABOVE THE HEADERS. Google Sheets and Excel both emit // one, padded with delimiters to the column count. Taking row 0 as the // header unconditionally meant the columns became "column 2", "column 3", // "column 4"; OUTCOME_HINT cannot match those, so the outcome silently // became whichever column happened to be last, the real header row was // absorbed as one unreadable value out of ninety, and the reader got a // confident lag report about columns they could not identify, keyed to an // outcome they never chose. // // ANNOUNCED, never silent. This whole file's doctrine — written out at // length above detectDateOrder — is that a quiet guess about the shape of // someone's data is the enemy. So the rule is deliberately narrow: skip a // row only when it is almost entirely empty AND the row beneath it is // almost entirely full. Anything less certain is left alone. var titleRow = null; while (rows.length > 1) { var cand = rows[0], next = rows[1]; var candFilled = cand.filter(function (v) { return String(v).trim() !== ''; }).length; var nextFilled = next.filter(function (v) { return String(v).trim() !== ''; }).length; if (candFilled <= 1 && nextFilled >= 2 && nextFilled > candFilled) { titleRow = cand.filter(function (v) { return String(v).trim() !== ''; })[0] || '(blank row)'; rows.shift(); } else break; } // Distinct names, because `columns` is keyed by name downstream. Two // columns called "pain" used to collapse to one array (last writer wins), // so the first one's values never reached the analysis — while the driver // list still held "pain" twice, so the survivor was tested twice at every // lag. That doubled the multiplicity penalty against a test count that was // not real, printed two identical cards, and falsely fired the "appears at // more than one lag" caveat. // // Object.create(null), not {}: a column named "toString" or "constructor" // is truthy on a plain object before it is ever set. The while-loop rather // than a single pass: a file with pain, pain, "pain (2)" must not produce // two columns called "pain (2)". var seen = Object.create(null); var header = rows.shift().map(function (h, idx) { var base = String(h).trim() || ('column ' + (idx + 1)); var name = base, n = 1; while (seen[name]) { n++; name = base + ' (' + n + ')'; } seen[name] = true; return name; }); return { header: header, rows: rows, titleRow: titleRow }; } // ────────────────────────────────────────────────────────── value coercion var TRUEY = /^(y|yes|true|t|1|x|✓|done|had)$/i; var FALSEY = /^(n|no|false|f|0|)$/i; function toNumber(raw) { if (raw === null || raw === undefined) return NaN; var s = String(raw).trim(); if (s === '') return NaN; if (TRUEY.test(s)) return 1; if (FALSEY.test(s)) return 0; if (looksLikeDate(s)) return NaN; // see looksLikeDate — a date is not a measurement // Strip thousands separators and a trailing unit, tolerate a comma decimal. var cleaned = s.replace(/[\s,](?=\d{3}\b)/g, '').replace(/[^0-9eE+\-.,]/g, ''); if (cleaned.indexOf(',') !== -1 && cleaned.indexOf('.') === -1) { cleaned = cleaned.replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } var n = parseFloat(cleaned); return isFinite(n) ? n : NaN; } // Dates are handled in UTC throughout. Parsing "2026-01-04" with the local // Date constructor lands on the previous day west of Greenwich, which would // silently shift every lag by one for anyone in the Americas — i.e. exactly // the founder, and exactly the number the whole page is about. var SLASH_DATE = /^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})/; // Lower-cased three-letter month prefixes, indexed 0-11. Used only by the // ordinal-date branch in toDayIndex; kept here beside SLASH_DATE so every // date-shape constant lives in one place. var MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; // A DATE IS NOT A MEASUREMENT, and toNumber used to disagree. // // toNumber strips every character outside [0-9eE+-.,] and hands the rest to // parseFloat. `-` survives that filter, so parseFloat("2024-06-28") is 2024 // and an ISO date column counted as a column of numbers. Measured against the // real exports, not a fixture: two of the three real Bearable files carry a // second date column called "date formatted" alongside the raw "28th Jun // 2024" one, and the effect was not cosmetic. // // - pivotLongFormat requires EXACTLY ONE numeric column to recognise a long // export. With the date counted, that test saw two and returned null, so // the pivot never ran and a 184-day Bearable file was reported as having // nothing in it. The one tracker this page names by name, refused. // - In any wide file with a spare date column, the date then appears in the // column list as a driver the reader can correlate against — and, being // first, as the pre-selected "how I felt" column. A monotonic ramp // correlates with anything that drifts, so this is not merely useless; it // is the machine for manufacturing a finding out of the calendar. // // Deliberately mirrors the three EXPLICIT branches of toDayIndex below and // nothing more. It does NOT mirror that function's gated Date.parse last // resort: Date.parse('7.5') is a valid date, and rejecting every value a // permissive parser might read as one would throw away real 0-to-12 scales — // which is the bug the gate on line ~298 exists to prevent, arriving from the // other direction. The two functions are coupled by construction and the // real-export lane in scripts/test-what-came-before.sh asserts they agree. function looksLikeDate(s) { if (/^\d{4}-\d{1,2}-\d{1,2}(?:[T\s]|$)/.test(s)) return true; // 2024-06-28 if (SLASH_DATE.test(s)) return true; // 28/06/2024 return /^\d{1,2}(?:st|nd|rd|th)?[\s.-]+[A-Za-z]{3,}[\s.,-]+\d{2,4}$/.test(s); // 28th Jun 2024 } // Decide D/M vs M/D ONCE FOR THE WHOLE FILE, never per row. // // THIS FUNCTION EXISTS BECAUSE THE PER-ROW VERSION SILENTLY DESTROYED // NON-US FILES. The first implementation resolved each row on its own: // "if the first number is over 12 it must be the day". That is right for // 19/03/2026 and catastrophically wrong for 01/03/2026, which it read as // January 3rd. In a British file, days 1-12 of every month were read as // months and days 13+ were read correctly. Measured: a clean 60-day UK file // spread across 336 days, with 60 days logged, NO error, NO warning and an // empty skipped list — then produced a confident lag analysis on a time axis // that no longer existed. // // That is precisely the failure this whole page was built to attack, aimed // at the readers most likely to see it first: the ME/CFS and Long Covid // communities are heavily UK and Australian. // // Rule: one order per file, decided by evidence from every row, and if the // evidence is absent we say so instead of guessing. function detectDateOrder(rows, col) { var firstOver12 = false, secondOver12 = false, sawSlashDate = false; for (var i = 0; i < rows.length; i++) { var v = rows[i][col]; if (v === undefined || v === null) continue; var m = String(v).trim().match(SLASH_DATE); if (!m) continue; sawSlashDate = true; if (+m[1] > 12) firstOver12 = true; if (+m[2] > 12) secondOver12 = true; } // NONE means the column holds no slash dates at all — an ISO file, which // is unambiguous by construction. Distinguishing this from AMBIGUOUS // matters: without it, every clean 2026-01-04 file got told its dates // "could be read either way round", which is both false and alarming. if (!sawSlashDate) return 'NONE'; if (firstOver12 && secondOver12) return 'MIXED'; if (firstOver12) return 'DMY'; if (secondOver12) return 'MDY'; return 'AMBIGUOUS'; } function toDayIndex(raw, order) { if (raw === null || raw === undefined) return NaN; var s = String(raw).trim(); if (!s) return NaN; var m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3]) / DAY_MS; m = s.match(SLASH_DATE); if (m) { var a = +m[1], b = +m[2]; var day, mon; if (order === 'DMY') { day = a; mon = b; } else { mon = a; day = b; } // MDY, and the US-first default if (mon < 1 || mon > 12 || day < 1 || day > 31) return NaN; return Date.UTC(+m[3], mon - 1, day) / DAY_MS; } // "22nd Jan 2022", "31st December 2025", "3rd Feb 26". // // Parsed STRUCTURALLY, in UTC, and deliberately NOT by stripping the // ordinal suffix and handing the rest to Date.parse. Date.parse on a bare // date uses LOCAL time, so every reader east of Greenwich lands one day // early — and a uniform one-day shift is invisible to every guard in this // file: the smear tripwire sees identical sparsity and span, and // detectDateOrder never sees a slash date. It would move every lag by one // for most of the audience this page was built for, silently. That is the // exact class of bug the D/M-vs-M/D work exists to prevent. // // This branch is here because Bearable — the tracker this audience // actually uses — exports dates in exactly this shape, and without it the // page tells someone staring at a column of dates that it found no dates. m = s.match(/^(\d{1,2})(?:st|nd|rd|th)?[\s.-]+([A-Za-z]{3,})[\s.,-]+(\d{2,4})$/); if (m) { var mi = MONTH_NAMES.indexOf(m[2].slice(0, 3).toLowerCase()); if (mi !== -1) { var yr = +m[3]; if (yr < 100) yr += yr < 70 ? 2000 : 1900; var dd = +m[1]; if (dd >= 1 && dd <= 31) return Date.UTC(yr, mi, dd) / DAY_MS; } return NaN; } // Date.parse on a bare string is locale- and engine-dependent, so it is // the last resort and only for formats the branches above missed // (ISO with a time, "Jan 4, 2026", and similar). // // GATED, and the gate is load-bearing. Date.parse('4') is 2001-04-01 and // Date.parse('7.5') is 2001-07-05 — so before this line existed, EVERY // value in a 0-to-10 pain column, an hours-slept column, or any 0-to-12 // scale parsed as a valid date. The date-column contest below picks // whichever column parses most often, with only a 0.05 tie-break for the // name, so a real date column with 13% blank rows (0.87 + 0.05 = 0.92) // LOST to "hours slept" (1.00). The calendar was then rebuilt as four days // in 2001 and the page told someone holding a hundred days that they did // not have twenty. // // One separator is not enough to distinguish these — "7.5" has one. Require // a letter, or two separators. if (!/[A-Za-z]/.test(s) && (s.match(/[-/.]/g) || []).length < 2) return NaN; var t = Date.parse(s); if (!isNaN(t)) return Math.floor(t / DAY_MS); return NaN; } function dayIndexToISO(idx) { return new Date(idx * DAY_MS).toISOString().slice(0, 10); } // ──────────────────────────────────────────────── long format → wide format // // Bearable is the tracker this audience actually uses, and its export is LONG: // one row per entry, with the item name in a text column ("detail") and every // number in a single column ("rating/amount"). Same shape for several other // exports. Before this function existed the page counted one numeric column, // said "I need at least two columns of numbers alongside the dates", and // turned away the person most likely to have usable data — someone already // tracking daily, in the app this community recommends most. // // The pivot is one row per day, one column per item, values averaged when a // day has several entries for the same item (the same rule the day buckets // already use downstream: summing would turn "logged twice" into a spike). // // DELIBERATELY NARROW, and ANNOUNCED. Reshaping someone's table is a decision // about their data, and this file's whole doctrine — set out at length above // detectDateOrder — is that a silent guess is the enemy. So all five // conditions must hold, and the caller states what happened in the status // line. When it is not obviously long format, nothing happens. function pivotLongFormat(header, rows, dateCol, order) { if (header.length < 3) return null; // 1. Exactly one column is numeric-shaped. Long format has one value column. var numericIdx = []; for (var c = 0; c < header.length; c++) { if (c === dateCol) continue; var n = 0, present = 0; for (var r = 0; r < rows.length; r++) { var v = rows[r][c]; if (v === undefined || String(v).trim() === '') continue; present++; if (!isNaN(toNumber(v))) n++; } if (present > 0 && n / present >= 0.9) numericIdx.push(c); } if (numericIdx.length !== 1) return null; var valueCol = numericIdx[0]; // 2. Dates repeat — several rows share a day. Wide format has one row a day. var perDay = {}, days = 0; for (var r2 = 0; r2 < rows.length; r2++) { var d = toDayIndex(rows[r2][dateCol], order); if (isNaN(d)) continue; if (!perDay[d]) { perDay[d] = 0; days++; } perDay[d]++; } if (days < MIN_PAIRS || rows.length / days < 2) return null; // 3. Pick the key column: a text column whose values repeat. The item name // reappears every day, so distinct values are far fewer than rows and // more than one. Prefer the column with the most distinct values that // still repeats — "detail" over "category", which is the finer grain and // the one the reader recognises. // A SECOND DATE COLUMN IS NOT A KEY, and it wins this contest on merit // unless it is excluded. Bearable exports a raw "28th Jun 2024" AND an // ISO "date formatted"; only one of them can be dateCol. Over 184 days // the other has 184 distinct values that repeat within the day — the // highest count of any candidate — so it took the key slot and the pivot // produced 184 columns named after dates, each with a single day in it. // The reader was then told "your fullest column has 1 day filled in" and // that the file was too short. It was 184 days long. Measured on // bearable_real1.csv; excluding date-shaped columns turns it back into // real item names. var keyCol = -1, keyDistinct = 0; for (var c2 = 0; c2 < header.length; c2++) { if (c2 === dateCol || c2 === valueCol) continue; var seenVals = Object.create(null), distinct = 0, filled = 0, dateish = 0, weekdayish = 0, timeish = 0; for (var r3 = 0; r3 < rows.length; r3++) { var s = String(rows[r3][c2] === undefined ? '' : rows[r3][c2]).trim(); if (!s) continue; filled++; if (looksLikeDate(s)) dateish++; if (WEEKDAY.test(s)) weekdayish++; if (TIME_OF_DAY.test(s)) timeish++; if (!seenVals[s]) { seenVals[s] = 1; distinct++; } } if (filled < rows.length * 0.8) continue; // must be present on most rows if (dateish > filled / 2) continue; // a date column, not an item name // A WEEKDAY COLUMN IS NOT A KEY EITHER. Bearable writes one, and its seven // repeating values out-count the real item column whenever a file tracks // fewer than seven items. Measured 2026-09-11 on a five-item export: the // pivot made columns named Mon to Sun, each holding one day in seven, and // the file was refused as too short. if (weekdayish > filled / 2) continue; // NOR IS A TIME-OF-DAY COLUMN. Bearable's holds five slots plus clock times, // and with two or three tracked items it out-counts the item column, or // ties it and wins because it comes first. Measured 2026-09-11: a two-item // export pivoted into columns named am, mid and pm. if (timeish > filled / 2) continue; if (distinct < 2 || distinct > 200) continue; // 1 is not a key; 200+ is free text if (distinct > filled / 3) continue; // must genuinely repeat if (distinct > keyDistinct) { keyDistinct = distinct; keyCol = c2; } } if (keyCol === -1) return null; // 4. Build it. Column name is the item name, verbatim, so the reader sees // the words they chose in their own app. var byDay = Object.create(null), names = [], nameSeen = Object.create(null); for (var r4 = 0; r4 < rows.length; r4++) { var day = toDayIndex(rows[r4][dateCol], order); if (isNaN(day)) continue; var key = String(rows[r4][keyCol] === undefined ? '' : rows[r4][keyCol]).trim(); if (!key) continue; var val = toNumber(rows[r4][valueCol]); if (isNaN(val)) continue; if (!nameSeen[key]) { nameSeen[key] = 1; names.push(key); } if (!byDay[day]) byDay[day] = Object.create(null); if (!byDay[day][key]) byDay[day][key] = []; byDay[day][key].push(val); } // 5. At least two items, or the pivot has not solved the problem it exists // to solve and the reader is better off with the original error. if (names.length < 2) return null; var outHeader = [header[dateCol]].concat(names); var outRows = Object.keys(byDay).map(Number).sort(function (a, b) { return a - b; }) .map(function (day) { var row = [dayIndexToISO(day)]; names.forEach(function (nm) { var vals = byDay[day][nm]; if (!vals || !vals.length) { row.push(''); return; } var sum = 0; for (var i = 0; i < vals.length; i++) sum += vals[i]; row.push(String(sum / vals.length)); }); return row; }); return { header: outHeader, rows: outRows, note: 'That looked like an export with one row per entry rather than one row per day, so I turned it into ' + names.length + ' columns across ' + outRows.length + ' days, using "' + header[keyCol] + '" as the column names and "' + header[valueCol] + '" as the values.' }; } // ────────────────────────────────────────────────────────── table → series function buildSeries(parsed) { var header = parsed.header, rows = parsed.rows; if (!header.length || !rows.length) { return { error: 'That file has no rows I can read.' }; } // The date column is the one that parses as a date most often. A name // hint only breaks ties — a column called "date" full of unparseable // text should still lose to a column of real dates called something else. var dateCol = -1, dateBest = 0; for (var c = 0; c < header.length; c++) { var hits = 0; var probeOrder = detectDateOrder(rows, c); for (var r = 0; r < rows.length; r++) { if (!isNaN(toDayIndex(rows[r][c], probeOrder))) hits++; } var score = hits / rows.length + (DATE_HINT.test(header[c]) ? 0.05 : 0); if (hits / rows.length >= 0.8 && score > dateBest) { dateBest = score; dateCol = c; } } if (dateCol === -1) { return { error: 'I could not find a column of dates. There needs to be one column where most rows are a date, like 2026-01-04.' }; } var order = detectDateOrder(rows, dateCol); if (order === 'MIXED') { return { error: 'The dates in that file are not all written the same way round — some look like day/month and others like month/day. I will not guess, because guessing would move your days and quietly change the answer. Re-export with dates like 2026-01-04.' }; } // Long format (Bearable and friends) is reshaped here, once the date column // and its order are known, and before anything counts numeric columns — // because the count is exactly what long format gets wrong. var pivotNote = ''; var pivoted = pivotLongFormat(header, rows, dateCol, order); if (pivoted) { header = pivoted.header; rows = pivoted.rows; dateCol = 0; order = 'NONE'; // the pivot writes ISO dates itself pivotNote = pivoted.note; } var dateNote = ''; if (order === 'DMY') { dateNote = 'Read your dates as day/month/year.'; } else if (order === 'AMBIGUOUS') { // Every row is 12-or-under on both sides, so nothing in the file // distinguishes 03/04 from 04/03. Default to US order because the // product is US-first, but SAY SO — a silent guess here shifts every // lag and is undetectable downstream. dateNote = 'Every date in that file could be read either way round (no day above the 12th appears), so I read them as month/day/year. If you write dates day-first, the result below is wrong — re-export with dates like 2026-01-04.'; } // Numeric columns. // // TWO separate questions, deliberately not collapsed into one test: // 1. is this column SHAPED like numbers? (present > 0 && n/present >= 0.8) // 2. does it have ENOUGH days to measure? (present >= MIN_PAIRS) // // They used to be one condition, and that is a lie to the reader. A 19-day // diary with four clean numeric columns failed the >=MIN_PAIRS half, lost // every column, and fell into the "fewer than two numeric columns" branch — // so the page told someone holding four columns of numbers that it needed // two columns of numbers. That advice is actionable and wrong: they add // columns, fail identically, and leave. Row count is not evidence about // whether a column holds numbers. // // A column that fails EITHER test is also recorded in `dropped` with the // reason. Silently removing someone's symptom column — the one they came // here about — and then analysing sleep instead is the quietest way this // page can be wrong, because the reader is looking straight at that column // in their own spreadsheet. var numericCols = []; var numericShaped = []; var dropped = []; var bestPresent = 0; for (var c2 = 0; c2 < header.length; c2++) { if (c2 === dateCol) continue; var n = 0, present = 0, unreadable = []; for (var r2 = 0; r2 < rows.length; r2++) { var raw = rows[r2][c2]; if (raw === undefined || String(raw).trim() === '') continue; present++; if (!isNaN(toNumber(raw))) n++; else if (unreadable.length < 3 && unreadable.indexOf(String(raw).trim()) === -1) { unreadable.push(String(raw).trim()); } } if (present === 0) continue; if (n / present >= 0.8) { numericShaped.push(c2); if (present > bestPresent) bestPresent = present; if (present >= MIN_PAIRS) { numericCols.push(c2); } else { dropped.push({ name: header[c2], why: 'only ' + present + (present === 1 ? ' day has' : ' days have') + ' a value in it, and I need at least ' + MIN_PAIRS }); } } else { dropped.push({ name: header[c2], why: 'I could not read these as numbers: ' + unreadable.join(', ') }); } } if (numericShaped.length < 2) { return { error: 'I need at least two columns of numbers alongside the dates — one for how you felt, and at least one thing to check against it.', dropped: dropped }; } if (numericCols.length < 2) { return { error: 'Your fullest column has ' + bestPresent + (bestPresent === 1 ? ' day' : ' days') + ' filled in. I need at least ' + MIN_PAIRS + ' before anything I found would mean anything, and it works properly from about three months. Nothing is wrong with your file — there is just not enough of it yet.', dropped: dropped }; } // Collect per-day values. Several rows for one day are averaged rather // than summed: summing would turn "logged twice today" into a spike. var buckets = {}; for (var r3 = 0; r3 < rows.length; r3++) { var d = toDayIndex(rows[r3][dateCol], order); if (isNaN(d)) continue; if (!buckets[d]) buckets[d] = {}; for (var k = 0; k < numericCols.length; k++) { var ci = numericCols[k]; var v = toNumber(rows[r3][ci]); if (isNaN(v)) continue; if (!buckets[d][ci]) buckets[d][ci] = []; buckets[d][ci].push(v); } } var days = Object.keys(buckets).map(Number).sort(function (a, b) { return a - b; }); if (!days.length) return { error: 'No usable dates in that file.' }; var first = days[0], last = days[days.length - 1]; var span = last - first + 1; if (span > 4000) return { error: 'That covers more than ten years of dates, which usually means the date column was read wrong.' }; // SMEAR TRIPWIRE — defence in depth behind detectDateOrder(). // // A misread date format does not throw; it scatters rows across a calendar // that never existed, and every number downstream stays plausible. The // per-file order check above catches the D/M vs M/D case specifically. // This catches the general shape of the same bug — any future format I // have not thought of — by noticing that logged days are far too sparse // across their own span to be a real diary. // // Calibrated deliberately loose: the UK case that motivated it read 60 // logged days over a 336-day span (5.6x). Someone who genuinely logs once // a week for a year sits near 7x, so the threshold is 8x with a floor of // 30 logged days, and it WARNS rather than refusing — a real sparse // diarist must not be locked out by a heuristic. var sparsity = span / days.length; var smearWarning = ''; if (days.length >= 30 && sparsity > 8) { smearWarning = 'Those dates look wrong to me. You have ' + days.length + ' days with entries spread across ' + span + ' days of calendar, which is far more spread out than a diary usually is.' + ' The usual cause is a date format I read the wrong way round.' + ' Check the result below against a date you remember before you trust it.'; } var columns = {}; numericCols.forEach(function (ci) { var arr = new Float64Array(span); for (var i = 0; i < span; i++) arr[i] = NaN; columns[header[ci]] = arr; }); days.forEach(function (d) { var slot = d - first; numericCols.forEach(function (ci) { var vals = buckets[d][ci]; if (!vals || !vals.length) return; var s = 0; for (var i = 0; i < vals.length; i++) s += vals[i]; columns[header[ci]][slot] = s / vals.length; }); }); return { first: first, span: span, daysLogged: days.length, names: numericCols.map(function (ci) { return header[ci]; }), columns: columns, dateColumn: header[dateCol], dateOrder: order, dateNote: dateNote, smearWarning: smearWarning, dropped: dropped, titleRow: parsed.titleRow, pivotNote: pivotNote }; } // ────────────────────────────────────────────────────────── statistics // Average ranks for ties. Ties are the normal case here: a 0-10 symptom // score over 500 days is mostly ties, and midranks are what make Spearman // behave on it. function ranksOf(values) { var n = values.length; var idx = new Array(n); for (var i = 0; i < n; i++) idx[i] = i; idx.sort(function (a, b) { return values[a] - values[b]; }); var out = new Float64Array(n); var i2 = 0; while (i2 < n) { var j = i2; while (j + 1 < n && values[idx[j + 1]] === values[idx[i2]]) j++; var mid = (i2 + j) / 2 + 1; for (var k = i2; k <= j; k++) out[idx[k]] = mid; i2 = j + 1; } return out; } function pearson(x, y) { var n = x.length; if (n < 3) return NaN; var sx = 0, sy = 0; for (var i = 0; i < n; i++) { sx += x[i]; sy += y[i]; } var mx = sx / n, my = sy / n; var num = 0, dx = 0, dy = 0; for (var j = 0; j < n; j++) { var a = x[j] - mx, b = y[j] - my; num += a * b; dx += a * a; dy += b * b; } if (dx <= 0 || dy <= 0) return NaN; return num / Math.sqrt(dx * dy); } // Lag-1 autocorrelation, used only to choose a block length. function lag1(v) { var n = v.length; if (n < 4) return 0; var s = 0, i; for (i = 0; i < n; i++) s += v[i]; var m = s / n, num = 0, den = 0; for (i = 0; i < n - 1; i++) num += (v[i] - m) * (v[i + 1] - m); for (i = 0; i < n; i++) den += (v[i] - m) * (v[i] - m); if (den <= 0) return 0; return num / den; } // Block length, chosen from how persistent the two series actually are. // // MEASURED, not guessed. A fixed 7-day block was the first implementation // and it under-corrected badly: on pure noise with AR(1) phi = 0.8 — which // is what a real run of good weeks and bad weeks looks like — 17-23% of runs // produced at least one "finding", against a nominal 10%. That is the exact // failure this page exists to criticise in other tools, so it could not // ship. Blocks are now scaled to the decorrelation time of the pair, which // brings the false-find rate back to nominal across phi = 0 to 0.8. // // tau = -1 / ln(phi_pair) is the e-folding time of the pair's shared // persistence; four of those is long enough that neighbouring blocks are // effectively independent. Capped at n/6 so there are always at least six // blocks to shuffle — with fewer, the null collapses onto a handful of // arrangements and the p-value stops meaning anything. function chooseBlock(x, y) { var px = Math.max(0, Math.min(0.98, lag1(x))); var py = Math.max(0, Math.min(0.98, lag1(y))); var pair = Math.sqrt(px * py); var b = BLOCK_DAYS; if (pair > 0.05) { var tau = -1 / Math.log(pair); b = Math.ceil(4 * tau); } return Math.max(BLOCK_DAYS, Math.min(b, Math.floor(x.length / 6))); } // Circular block resample of a rank vector. Blocks (not single days) are // drawn so that the run structure of the series survives into the null; // wrapping around the end keeps every day equally likely to be picked, // which a non-circular version does not. function blockResample(source, out, blockLen, rand) { var n = source.length; var pos = 0; while (pos < n) { var start = (rand() * n) | 0; var take = Math.min(blockLen, n - pos); for (var i = 0; i < take; i++) out[pos + i] = source[(start + i) % n]; pos += take; } return out; } function quantile(sortedAsc, p) { if (!sortedAsc.length) return NaN; var pos = (sortedAsc.length - 1) * p; var lo = Math.floor(pos), hi = Math.ceil(pos); if (lo === hi) return sortedAsc[lo]; return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (pos - lo); } // Benjamini-YEKUTIELI step-up, returning q-values in the input order. // // Yekutieli rather than plain Benjamini-Hochberg, and the difference is // load-bearing. BH assumes the tests are independent or positively // dependent. These are neither: the same outcome column is reused across // every test, the four lags of one driver are near-copies of each other, // and two drivers can be related with opposite sign — which is exactly the // arbitrary-dependence case BH is not valid under. // // MEASURED on pure noise, 60 runs per cell, nominal 10%: // BH — 13% / 12% / 17% / 13% false-find rate at AR(1) phi 0/0.5/0.8/0.9 // BY — 3% / 3% / 3% / 3% // BY costs a factor of sum(1/i) ~ 3.4 at 16 tests. The planted example // signal still clears it at q = 0.014, so the power lost is power we did // not need. Telling someone with a chronic illness that their sleep drives // their crashes when it does not is a much worse error than staying quiet. // // familySize, when given and larger than the number of p-values, corrects // them as members of a family that big whose other members all came back // empty (p = 1). Those would sort after every real p-value and cap at 1, so // this is exactly Yekutieli over the larger family. analyze() uses it to hold // the same day to the whole grid. function adjustQ(pvals, familySize) { var m = pvals.length; var M = Math.max(m, familySize || 0); var c = 0; for (var h = 1; h <= M; h++) c += 1 / h; var order = pvals.map(function (p, i) { return { p: p, i: i }; }) .sort(function (a, b) { return a.p - b.p; }); var q = new Array(m); var prev = 1; for (var k = m - 1; k >= 0; k--) { var val = Math.min(prev, order[k].p * M * c / (k + 1)); prev = val; q[order[k].i] = Math.min(1, val); } return q; } // ────────────────────────────────────────────────────────── the analysis // ─────────────────────────────────────────── which columns are which // "How I felt" written another way: mood beside fatigue, brain fog beside pain. function isFeltLike(name) { return FELT_LIKE.test(name) && !NOT_A_FEELING.test(name); } // The default "how I felt" column. It used to be the FIRST column whose name // matched OUTCOME_HINT, and on a Bearable export that is Mood, because Mood is // the first item Bearable writes. Measured on the audit's 90-day export: Mood // was pre-selected in 50 of 50 seeds, a higher-is-better column the reader did // not come here about. Preference now: a column the reader literally called // how they felt; then a symptom, where a higher number is a worse day; then // anything else that reads like a rating. Null when nothing does, and the page // keeps its own fallback for that case. function chooseOutcome(names) { var usable = (names || []).filter(function (n) { return !NOT_A_FEELING.test(n); }); function first(re) { return usable.filter(function (n) { return re.test(n); })[0]; } return first(/feel|felt/i) || first(HIGHER_IS_WORSE) || first(OUTCOME_HINT) || null; } // Which columns are checked as things that came before. A column that reads // like another way of saying how you felt is left out, BY NAME, and the page // says so. Mood beside fatigue is the same bad day written down twice: it ties // at the permutation floor wherever it touches the outcome and wins on // strength, which is how the shareable card came to read "the same day / Mood" // in 50 of 50 audit runs. Leaving it out is a decision about the reader's data, // so it is announced, never silent. // // If that would leave NOTHING to check (a file of symptoms only), everything is // checked after all and the page says it compared symptoms with each other. // Telling that reader there was nothing to check would be false. function driversFor(names, outcome) { var others = (names || []).filter(function (n) { return n !== outcome; }); // Only when the outcome is itself a how-you-felt column. Someone asking what // comes before their step count is asking a different question, and for // them fatigue the day before is a fair candidate, not a twin. if (!isFeltLike(outcome || '')) { return { drivers: others, notDrivers: [], symptomsAsDrivers: false }; } var done = others.filter(function (n) { return !isFeltLike(n); }); if (!done.length) { return { drivers: others, notDrivers: [], symptomsAsDrivers: others.length > 0 }; } return { drivers: done, notDrivers: others.filter(isFeltLike), symptomsAsDrivers: false }; } function harmonic(m) { var c = 0; for (var h = 1; h <= Math.max(1, m); h++) c += 1 / h; return c; } function analyze(series, outcomeName, opts) { opts = opts || {}; var seed = opts.seed || 20260901; var rand = rng(seed); var outcome = series.columns[outcomeName]; if (!outcome) return { error: 'That column is not in the file.' }; var pick = driversFor(series.names, outcomeName); var drivers = pick.drivers; // RESAMPLING RESOLUTION MUST SCALE WITH THE NUMBER OF TESTS, or the bar // becomes literally unreachable and the page prints "nothing" over a // perfect signal. // // The smallest p a permutation test can produce is (0 + 1) / (B + 1). // Benjamini-Yekutieli then multiplies it by m·c(m), where m = 4 lags × // drivers and c(m) is the harmonic number. With B fixed at 2000 the floor // is 1/2001 = 0.0005, so at m = 48 — that is TWELVE number columns — // 0.0005 × 48 × 4.45 = 0.107 already exceeds the 0.10 cutoff, and NO // finding of ANY strength can clear it. // // Measured before this fix: a planted lag-2 relationship with rho = 1.000, // in 365 days of data, was reported at 11 drivers (q = 0.096) and reported // as "Nothing here stands out from chance" at 12 (q = 0.107) — while the // page went on to tell the reader an association "would have needed to be // roughly 0.10", about the association of 1.00 it had just discarded. // // Twelve columns is not an edge case. It is what every real tracker export // looks like. It hurts a SHARP single-lag effect worst, which is precisely // the crash-two-days-after-exertion shape this page exists to find. // // So: choose B from m, with headroom, rather than fixing it. The cap keeps // a phone responsive; beyond it the page SAYS the bar was out of reach // instead of printing a confident null. // Since 2026-09-11 there are two families (see below). The days before are // corrected over 3 lags x drivers. The same day is corrected against the // whole grid, 4 lags x drivers, which demands more and is exactly what every // pairing needed before the split, so B is what it was. resolutionShort // speaks for the HEADLINE family only, because that is the bar the null card // and its caution describe. var mBefore = BEFORE_LAGS.length * drivers.length; var mGrid = LAGS.length * drivers.length; var needBefore = Math.ceil((mBefore * harmonic(mBefore) * 3) / Q_CUTOFF); // 3x headroom var needB = Math.max(needBefore, Math.ceil((mGrid * harmonic(mGrid) * 3) / Q_CUTOFF)); var affordB = Math.max(PERMUTATIONS, Math.floor(MAX_WORK / Math.max(1, drivers.length))); var capB = Math.min(MAX_PERMUTATIONS, affordB); var B = opts.permutations || Math.min(capB, Math.max(PERMUTATIONS, needB)); var resolutionShort = !opts.permutations && needBefore > capB; var tests = []; var skipped = []; drivers.forEach(function (name) { var driver = series.columns[name]; LAGS.forEach(function (lag) { var dx = [], dy = []; for (var i = lag; i < series.span; i++) { var a = driver[i - lag], b = outcome[i]; if (isNum(a) && isNum(b)) { dx.push(a); dy.push(b); } } if (dx.length < MIN_PAIRS) { skipped.push({ driver: name, lag: lag, pairs: dx.length, why: 'not enough overlapping days' }); return; } var rx = ranksOf(dx), ry = ranksOf(dy); var rho = pearson(rx, ry); if (!isNum(rho)) { skipped.push({ driver: name, lag: lag, pairs: dx.length, why: 'that column never changes' }); return; } // Null: resample the DRIVER's ranks in blocks, leave the outcome // alone. That breaks the link between the two while keeping each // series' own persistence intact. var blockLen = opts.blockDays || chooseBlock(rx, ry); var scratch = new Float64Array(rx.length); var absNull = new Float64Array(B); var hits = 0, obs = Math.abs(rho); for (var b = 0; b < B; b++) { blockResample(rx, scratch, blockLen, rand); var r = pearson(scratch, ry); var ar = isNum(r) ? Math.abs(r) : 0; absNull[b] = ar; if (ar >= obs) hits++; } var p = (hits + 1) / (B + 1); var sorted = Array.prototype.slice.call(absNull).sort(function (a, c) { return a - c; }); var needed = quantile(sorted, 0.95); tests.push({ driver: name, lag: lag, family: lag === 0 ? 'same' : 'before', pairs: dx.length, rho: rho, p: p, block: blockLen, needed: needed, // How far this pairing stands above ITS OWN chance bar. Used only to // order pairings whose q is tied (see byClarity in analyze). margin: obs / Math.max(needed, 1e-9) }); }); }); var before = tests.filter(function (t) { return t.family === 'before'; }); var same = tests.filter(function (t) { return t.family === 'same'; }); if (!before.length) { // THE COUNTS TRAVEL WITH EVERY RETURN, INCLUDING THIS ONE. // // This early return used to omit daysLogged, span, comparisons and // permutations, and it is reached on a real file: bearable_real2.csv, // 2026-09-16. The caller reads those four fields unconditionally, so the // page a person receives read "I looked at undefined days you logged, // across a span of undefined" — on the one artifact this whole lane // exists to produce, in the case (nothing found) that most people will // get. A return that answers a different question must still answer the // questions every caller asks of it. return { outcome: outcomeName, tests: [], survivors: [], sameDay: [], skipped: skipped, notDrivers: pick.notDrivers, symptomsAsDrivers: pick.symptomsAsDrivers, comparisons: 0, sameDayComparisons: same.length, allComparisons: tests.length, driversChecked: drivers.length, daysLogged: series.daysLogged, span: series.span, firstDay: series.first, permutations: 0, resolutionShort: false, error: 'There were not enough days where two columns were both filled in. This needs about ' + MIN_PAIRS + ' overlapping days at minimum, and works properly from about three months.' }; } // TWO FAMILIES, EACH CORRECTED ON ITS OWN, AND ONLY THE FIRST CAN LEAD. // // THE HEADLINE WAS A SAME-DAY DECOY 50 TIMES OUT OF 50. Measured 2026-09-11 // on a synthetic 90-day Bearable export where exertion raises fatigue one and // two days later and does nothing on the same day. All four lags were one // family, ranked by q and then strength. Mood, a second way of recording the // same bad day, tied the real lag at the permutation floor and beat it on // strength, so the shareable card read "the same day / Mood" every time. // // Why the same day cannot lead: a same-day link runs either way (a bad day // lowers your steps as surely as a busy day might bring one on), and anything // that records the day twice moves with it by construction. The page is // called what came before. // // Why SEPARATE corrections rather than one over everything: Yekutieli bounds // the false-discovery rate over ALL of a family's rejections, and a subset // picked out of one joint correction is not bounded. Strong same-day // rejections raise the step-up threshold for everything else, so the // days-before list could carry false findings well above the stated rate in // exactly the case that broke: strong same-day twins. The days before are // their own family, so the headline's rate is the stated one. // // THE SAME DAY IS HELD TO THE WHOLE GRID. Its p-values are corrected as if // every pairing tested (all four lags) were in its family and the others had // come back empty. That is never looser than this page was before the split, // when every pairing sat in one joint correction, so nothing reaches the // same-day list that the old page would not also have shown. // Measured on pure noise at the page's own resampling, 960 runs over three // seed sets and four persistence levels. Runs showing ANYTHING: 38 before // this change. After, the headline alone: 39. Adding the same day as its // own family at 0.05: 62; at 0.025: 49; held to the whole grid at 0.10: 45. // (All at the 0.10 bar then in use. Q_CUTOFF says why it moved to 0.06.) // Same-day power on the audit export (steps falling on bad days), 50 seeds: // 29, 26 and 23. The page promises "2 to 5 in 100" about what the reader // sees, so the secondary list cannot be the thing that breaks it. var qBefore = adjustQ(before.map(function (t) { return t.p; })); before.forEach(function (t, i) { t.q = qBefore[i]; }); if (same.length) { var qSame = adjustQ(same.map(function (t) { return t.p; }), tests.length); same.forEach(function (t, i) { t.q = qSame[i]; }); } // ORDER WHEN q TIES, which it does whenever several pairings sit at the // permutation floor. Strength alone favours a column that merely drifts with // the outcome: a shared trend or a long run widens that column's own chance // spread, so the same strength means less for it. Ranking by how far each // pairing stands above ITS OWN 95th-percentile chance value picks the one // chance could least have produced. function byClarity(a, b) { return a.q - b.q || b.margin - a.margin || Math.abs(b.rho) - Math.abs(a.rho); } var survivors = before.filter(function (t) { return t.q < Q_CUTOFF; }).sort(byClarity); var sameDay = same.filter(function (t) { return t.q < Q_CUTOFF; }).sort(byClarity); var neededSorted = before.map(function (t) { return t.needed; }).sort(function (a, b) { return a - b; }); var medianPairs = before.map(function (t) { return t.pairs; }).sort(function (a, b) { return a - b; })[Math.floor(before.length / 2)]; return { outcome: outcomeName, tests: tests, survivors: survivors, // the days before, clearest first; [0] is the headline sameDay: sameDay, // the same day, reported apart and never as the headline skipped: skipped, comparisons: before.length, sameDayComparisons: same.length, allComparisons: tests.length, // what each same-day q was corrected for driversChecked: drivers.length, notDrivers: pick.notDrivers, symptomsAsDrivers: pick.symptomsAsDrivers, typicalNeeded: quantile(neededSorted, 0.5), medianPairs: medianPairs, daysLogged: series.daysLogged, span: series.span, firstDay: series.first, permutations: B, resolutionShort: resolutionShort }; } // ────────────────────────────────────────────────────────── example data // 18 months. One planted association at a two-day lag, several decoys with // realistic persistence, and nothing else. The decoys are the point: they // are what proves the correction is doing its job when they do not survive. function exampleCSV() { var rand = rng(424242); var n = 548; var start = Date.UTC(2025, 2, 2) / DAY_MS; var upright = [], slept = [], steps = [], screen = [], felt = []; var uPrev = 5, sPrev = 7, scPrev = 4; for (var i = 0; i < n; i++) { // Persistent (AR-1) series — real life is not white noise, and a // white-noise example would make the block resampling look pointless. uPrev = 0.55 * uPrev + 0.45 * (4 + 3 * rand()) + 0.6 * gauss(rand); sPrev = 0.45 * sPrev + 0.55 * (7 + 0.8 * gauss(rand)); scPrev = 0.5 * scPrev + 0.5 * (4 + 1.5 * gauss(rand)); upright.push(Math.max(0, uPrev)); slept.push(Math.max(3, Math.min(11, sPrev))); screen.push(Math.max(0, scPrev)); steps.push(Math.max(0, Math.round(900 * upright[i] + 700 * gauss(rand)))); } for (var j = 0; j < n; j++) { var driven = j >= 2 ? upright[j - 2] : upright[0]; var v = 2.2 + 0.42 * driven + 1.05 * gauss(rand); felt.push(Math.max(0, Math.min(10, Math.round(v)))); } var lines = ['date,hours upright,hours slept,steps,screen hours,how I felt (0-10 worse)']; for (var k = 0; k < n; k++) { lines.push([ dayIndexToISO(start + k), upright[k].toFixed(1), slept[k].toFixed(1), steps[k], screen[k].toFixed(1), felt[k] ].join(',')); } return lines.join('\n'); } function noiseCSV() { var rand = rng(99); var n = 400; var start = Date.UTC(2025, 0, 1) / DAY_MS; var lines = ['date,a,b,c,how I felt']; var a = 0, b = 0, c = 0, f = 0; for (var i = 0; i < n; i++) { a = 0.5 * a + gauss(rand); b = 0.5 * b + gauss(rand); c = 0.5 * c + gauss(rand); f = 0.5 * f + gauss(rand); lines.push([dayIndexToISO(start + i), a.toFixed(3), b.toFixed(3), c.toFixed(3), f.toFixed(3)].join(',')); } return lines.join('\n'); } // ────────────────────────────────────────────────────────── self-test // Two assertions, and they are the only reason to trust anything above: // a planted two-day lag must be found AT LAG 2, and pure noise must find // nothing. Run it at /what-came-before/?selftest=1. function selfTest() { var out = []; var planted = analyze(buildSeries(parseCSV(exampleCSV())), 'how I felt (0-10 worse)', { permutations: 600 }); var top = planted.survivors[0]; var okPlanted = !!top && top.driver === 'hours upright' && top.lag === 2; out.push({ name: 'a planted two-day lag is found, at lag 2', pass: okPlanted, detail: top ? (top.driver + ' at lag ' + top.lag + ', rho ' + top.rho.toFixed(2) + ', q ' + top.q.toFixed(4)) : 'nothing survived' }); var noise = analyze(buildSeries(parseCSV(noiseCSV())), 'how I felt', { permutations: 600 }); out.push({ name: 'pure noise survives nothing', pass: noise.survivors.length === 0 && noise.sameDay.length === 0, detail: noise.survivors.length + ' survivor(s) of ' + noise.comparisons + ' comparisons, and ' + noise.sameDay.length + ' of ' + noise.sameDayComparisons + ' on the same day' }); return out; } // ══════════════════════════════════════════════════════ THE RECEIPT // // A picture of the result that a person can save and post themselves. // // WHY IT EXISTS. The forums where this page's readers live forbid US from // promoting anything. They do not forbid a MEMBER posting a picture of their // own data. That asymmetry is the only distribution path this tool has, and // it is one Canvas call wide. Until now the output could not travel: you run // it, you read it, you close the tab, and nothing you saw can be shown to // anyone. // // FOUR RULES, and each of them is a decision that could have gone the other // way: // // 1. DRAWN FROM THE RESULT OBJECT, NEVER FROM THE FILE. The card can only // say what analyze() returned: counts, a rank correlation, a corrected // q-value, and column names the reader has approved. There is no path // from a row of someone's diary onto this image. // // 2. NO SUGGESTED CAPTION. EVER. A pre-written sentence handed to a hundred // readers is astroturf arriving in a hundred voices, and it would burn // the one channel left in a week. The card carries no call to action, no // hashtag, and no words for the reader to repeat. If they post it, the // sentence around it is theirs. `noCaption` in the guard is the mechanism. // // 3. THE NULL CARD IS THE SAME CARD. Same size, same skeleton, same number // of lines at every weight — asserted in the guard, not promised here. If // "nothing stood out" were a smaller or sadder artefact than a finding, // only findings would ever be posted, and a tool whose shareable output // is selected for positives is a tool that manufactures positives. The // honest null is the product; it has to be as postable as the finding. // // 4. THE READER SEES THE COLUMN NAMES BEFORE THEY RENDER, AND CAN CHANGE // THEM. Their own words go on a public image. "Amitriptyline 25mg" is a // column name to this code and a disclosure to them. So the labels are // editable, pre-filled, and shown before the picture exists. // // WHAT IS DELIBERATELY ABSENT: dates, the date range, day counts tied to a // calendar, the file name, any raw value, anything that could locate a person // in time. A count is not a date. The guard checks for date shapes in every // line. var RECEIPT_W = 1080; var RECEIPT_H = 1350; // The label the reader approved, falling back to the column's own name and // then to a neutral phrase. A blank box must never silently print the // original — that would defeat the point of offering the box. function receiptLabel(chosen, original, fallback) { var s = String(chosen === undefined || chosen === null ? '' : chosen).trim(); if (s) return s.slice(0, 64); var o = String(original === undefined || original === null ? '' : original).trim(); return o ? o.slice(0, 64) : fallback; } // The card's content, as weighted lines. ONE function feeds both the PNG and // the plain-text twin, so the picture and the text can never drift apart — // and both are testable without a browser. // // Weights: 'label' | 'flag' | 'lead' | 'headline' | 'body' | 'micro' | 'rule' function receiptLines(res, opts) { opts = opts || {}; var outcome = receiptLabel(opts.outcomeLabel, res && res.outcome, 'how I felt'); var L = []; L.push({ w: 'label', t: 'WHAT CAME BEFORE' }); if (opts.isExample) L.push({ w: 'flag', t: 'EXAMPLE DATA — NOT MY OWN' }); L.push({ w: 'rule' }); var found = res && res.survivors && res.survivors.length ? res.survivors[0] : null; if (found) { var driver = receiptLabel(opts.driverLabel, found.driver, 'something I tracked'); L.push({ w: 'lead', t: lagWords(found.lag) }); L.push({ w: 'headline', t: driver }); L.push({ w: 'lead', t: directionWords(found.rho, res.higherIsWorse) }); L.push({ w: 'body', t: plainOdds(found.q, res.comparisons) }); L.push({ w: 'body', t: 'measured against "' + outcome + '"' }); L.push({ w: 'rule' }); L.push({ w: 'micro', t: 'strength ' + Math.abs(found.rho).toFixed(2) + ' · ' + found.pairs + ' day pairs' + ' · q ' + found.q.toFixed(3) + ' after correcting for ' + res.comparisons + ' comparisons' }); // A driver surviving at several lags is one pattern seen twice, not two // findings, and the full report says so. Saying how many others there // were stops a single card reading as the whole story. if (res.survivors.length > 1) { L.push({ w: 'micro', t: res.survivors.length - 1 === 1 ? 'one other pairing also stood out; this was the clearest' : (res.survivors.length - 1) + ' other pairings also stood out; this was the clearest' }); } } else { L.push({ w: 'lead', t: 'across ' + (res && res.daysLogged ? res.daysLogged : 0) + ' logged days' }); L.push({ w: 'headline', t: 'Nothing stood out.' }); L.push({ w: 'lead', t: 'and that is a real answer' }); L.push({ w: 'body', t: 'Of ' + (res && res.comparisons ? res.comparisons : 0) + ' pairings in the 1 to 3 days before, none survived correcting for how many I checked.' }); L.push({ w: 'body', t: 'measured against "' + outcome + '"' }); L.push({ w: 'rule' }); L.push({ w: 'micro', t: 'at about ' + (res && res.medianPairs ? res.medianPairs : 0) + ' overlapping days, a pattern needed to be about ' + (res && isNum(res.typicalNeeded) ? res.typicalNeeded.toFixed(2) : '?') + ' on a 0-to-1 scale to show here' }); // A null over a file so wide the bar was out of reach is not the same // statement as a null the maths could see. The full report distinguishes // them; a card that did not would be the page lying by omission at the // one moment it travels furthest. if (res && res.resolutionShort) { L.push({ w: 'micro', t: 'this file was wide enough that the honest bar sat above what the resampling could measure' }); } } L.push({ w: 'micro', t: 'rank correlation · block permutation · Benjamini-Yekutieli' }); L.push({ w: 'rule' }); L.push({ w: 'micro', t: 'an association in my own numbers over time.' }); L.push({ w: 'micro', t: 'not proof that one caused the other. not a diagnosis.' }); L.push({ w: 'micro', t: 'arkhelion.ai/what-came-before' }); L.push({ w: 'micro', t: 'the file never left the browser' }); return L; } // The plain-text twin. Same lines, same order, no picture — for a screen // reader, a text-only forum, or anyone who would rather not post an image. // Derived from the same array, so it cannot say something different. function receiptText(lines) { return lines.filter(function (l) { return l.w !== 'rule'; }) .map(function (l) { return l.t; }) .join('\n'); } // Wrap to a pixel width, shrinking then ellipsizing so the card CANNOT // overflow. `measure` is injected so this is testable without a canvas. function wrapLines(text, measure, maxWidth, maxRows) { var words = String(text).split(/\s+/).filter(Boolean); var rows = [], cur = ''; for (var i = 0; i < words.length; i++) { var next = cur ? cur + ' ' + words[i] : words[i]; if (cur && measure(next) > maxWidth) { rows.push(cur); cur = words[i]; } else { cur = next; } } if (cur) rows.push(cur); // A single word longer than the line — a column name with no spaces — is // broken by character rather than allowed to run off the edge. var out = []; for (var r = 0; r < rows.length; r++) { if (measure(rows[r]) <= maxWidth) { out.push(rows[r]); continue; } var acc = ''; for (var c = 0; c < rows[r].length; c++) { if (measure(acc + rows[r][c]) > maxWidth && acc) { out.push(acc); acc = ''; } acc += rows[r][c]; } if (acc) out.push(acc); } if (out.length > maxRows) { out = out.slice(0, maxRows); var last = out[maxRows - 1]; while (last.length > 1 && measure(last + '…') > maxWidth) last = last.slice(0, -1); out[maxRows - 1] = last + '…'; } return out; } // ───────────────────────────────────────────── the receipt, as pixels // // Canvas 2D, hand-rolled, no library. Norm #24 says adopt from floor 10, and // I looked: html-to-image, dom-to-image, modern-screenshot and satori all // solve a harder problem than this one (arbitrary DOM → raster), and each of // them either fetches a remote font, ships a WASM blob, or embeds a toolchain // that LANE L2 would have to be widened to tolerate. L2's failure condition // is "any absolute URL in the engine", and it is the mechanical form of "your // file never leaves this page" — the one promise here that must never be // relaxed for convenience. Fourteen boxes of text on a fixed 1080×1350 canvas // is not the case that justifies widening it. // // These live ABOVE the `document` guard, and deliberately: everything below // it is unreachable in a test runner, and the layout arithmetic is exactly // the part that silently ships broken on somebody's phone. It takes the // canvas as an argument and touches no other DOM, so it is a pure function of // (lines, measurement) and can be checked without a browser. // // Every colour is BRAND.md's, read from site.css: --bg #0a0a0c, // --card #111114, --fg #ededed, --muted .62, --dim .46, --accent-violet. var R_STYLE = { label: { size: 26, color: 'rgba(255,255,255,0.46)', weight: '600', lh: 1.4, gap: 0, rows: 1 }, flag: { size: 30, color: '#3fbf87', weight: '700', lh: 1.4, gap: 22, rows: 2 }, lead: { size: 44, color: 'rgba(255,255,255,0.62)', weight: '400', lh: 1.3, gap: 26, rows: 2 }, headline: { size: 92, color: '#ededed', weight: '600', lh: 1.12, gap: 18, rows: 3 }, body: { size: 38, color: 'rgba(255,255,255,0.62)', weight: '400', lh: 1.35, gap: 22, rows: 3 }, micro: { size: 26, color: 'rgba(255,255,255,0.46)', weight: '400', lh: 1.45, gap: 12, rows: 3 }, rule: { size: 0, color: 'rgba(255,255,255,0.13)', weight: '400', lh: 1, gap: 34, rows: 1 } }; // system-ui first: the reader's own OS face renders identically to everything // else on their phone and needs no download, which is the only font policy // available to an engine that may not name a remote host. var R_FONT = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; var R_PAD = 88; function rFont(ctx, st, size) { ctx.font = st.weight + ' ' + size + 'px ' + R_FONT; } // Lay the whole card out BEFORE drawing any of it, shrinking the headline and // then the mid-weights until everything fits between the margins. Overflow is // therefore not a rendering accident discovered on someone's phone — it is // arithmetic that happens first, every time, for every column name. function layoutReceipt(ctx, lines, W, H, pad) { var maxW = W - pad * 2, avail = H - pad * 2; var headScale = 1, leadScale = 1, plan, total; for (var attempt = 0; attempt < 24; attempt++) { plan = []; total = 0; for (var i = 0; i < lines.length; i++) { var l = lines[i], st = R_STYLE[l.w] || R_STYLE.body; var scale = l.w === 'headline' ? headScale : (l.w === 'rule' || l.w === 'micro' ? 1 : leadScale); var size = Math.round(st.size * scale); var rows = []; if (l.w !== 'rule') { rFont(ctx, st, size); rows = wrapLines(l.t, function (s) { return ctx.measureText(s).width; }, maxW, st.rows); } var h = l.w === 'rule' ? 1 : rows.length * Math.round(size * st.lh); var gap = i === 0 ? 0 : st.gap; plan.push({ line: l, st: st, size: size, rows: rows, h: h, gap: gap }); total += h + gap; } if (total <= avail) break; if (headScale > 0.55) headScale -= 0.06; else if (leadScale > 0.7) leadScale -= 0.05; else break; } return { plan: plan, total: total, avail: avail, overflow: total > avail, maxW: maxW, pad: pad, W: W, H: H }; } function drawReceipt(canvas, lines) { var W = RECEIPT_W, H = RECEIPT_H, pad = R_PAD; canvas.width = W; canvas.height = H; var ctx = canvas.getContext('2d'); ctx.fillStyle = '#0a0a0c'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#111114'; ctx.fillRect(40, 40, W - 80, H - 80); // One accent mark, top-left, the width of the label. Violet is never text // on this brand (3.87:1) — it is a rule, and that is all it is here. ctx.fillStyle = '#6b5ecd'; ctx.fillRect(pad, pad - 26, 96, 4); var lay = layoutReceipt(ctx, lines, W, H, pad); ctx.textBaseline = 'top'; var y = pad + Math.max(0, (lay.avail - lay.total) / 2); for (var i = 0; i < lay.plan.length; i++) { var p = lay.plan[i]; y += p.gap; if (p.line.w === 'rule') { ctx.fillStyle = p.st.color; ctx.fillRect(pad, Math.round(y), lay.maxW, 1); y += 1; continue; } rFont(ctx, p.st, p.size); ctx.fillStyle = p.st.color; var lh = Math.round(p.size * p.st.lh); for (var r = 0; r < p.rows.length; r++) { ctx.fillText(p.rows[r], pad, Math.round(y + r * lh)); } y += p.h; } return lay; } var Lookback = { parseCSV: parseCSV, buildSeries: buildSeries, analyze: analyze, chooseOutcome: chooseOutcome, driversFor: driversFor, exampleCSV: exampleCSV, noiseCSV: noiseCSV, selfTest: selfTest, ranksOf: ranksOf, pearson: pearson, adjustQ: adjustQ, receiptLines: receiptLines, receiptText: receiptText, receiptLabel: receiptLabel, wrapLines: wrapLines, layoutReceipt: layoutReceipt, drawReceipt: drawReceipt, RECEIPT_W: RECEIPT_W, RECEIPT_H: RECEIPT_H, RECEIPT_PAD: R_PAD, Q_CUTOFF: Q_CUTOFF }; if (typeof module !== 'undefined' && module.exports) module.exports = Lookback; if (typeof window !== 'undefined') window.Lookback = Lookback; // ══════════════════════════════════════════════════════════ UI if (typeof document === 'undefined') return; var state = { series: null, isExample: false, res: null }; function $(id) { return document.getElementById(id); } function show(el) { if (el) el.classList.remove('lb-hidden'); } function hide(el) { if (el) el.classList.add('lb-hidden'); } function setText(el, t) { if (el) el.textContent = t; } function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n; } function lagWords(lag) { if (lag === 0) return 'the same day'; if (lag === 1) return 'the day before'; return lag + ' days before'; } // A rank correlation only knows "the number went up". Whether UP is a worse // day or a better day is a property of the visitor's own scale, and this page // has no way to know it — a 0-10 pain column and a 0-10 energy column produce // the same rho with opposite meanings. // // This function used to answer `rho > 0 ? 'worse' : 'better'` unconditionally, // i.e. it assumed every outcome column is higher-is-worse. That is backwards // for energy, mood, wellbeing and score — and OUTCOME_HINT auto-selects // exactly those columns, so the DEFAULT path printed the direction inverted. // The sentence is also carried onto the "Print for an appointment" sheet, so // the inversion travelled to a doctor on paper. Asked, never assumed. function directionWords(rho, higherIsWorse) { var worseDay = higherIsWorse ? rho > 0 : rho < 0; return worseDay ? 'more of it, worse day' : 'more of it, better day'; } // Which way the visitor's scale runs, read from the control they can see and // correct. Defaults are a guess FROM THE COLUMN NAME and are always visible. // HIGHER_IS_BETTER and HIGHER_IS_WORSE are defined with the constants at the // top, above the document guard, because chooseOutcome() uses them too. function guessPolarity(name) { if (HIGHER_IS_WORSE.test(name)) return 'worse'; if (HIGHER_IS_BETTER.test(name)) return 'better'; return 'worse'; } function higherIsWorseNow() { var p = $('lb-polarity'); return !p || p.value !== 'better'; } function syncPolarity(name) { var p = $('lb-polarity'); if (p) p.value = guessPolarity(name); } // Restated whenever the chosen column changes. This used to be written once, // against the auto-guess, and then went stale the moment the visitor used the // dropdown the page had just asked them to use. function syncCols(name) { if (!state.series) return; var pick = driversFor(state.series.names, name); var t = 'Columns it will check: ' + pick.drivers.join(' · ') + '.'; if (pick.notDrivers.length) { t += ' Not checked as things that came before, because ' + (pick.notDrivers.length === 1 ? 'it reads' : 'they read') + ' like another way of saying how you felt: ' + pick.notDrivers.join(' · ') + '.'; } if (pick.symptomsAsDrivers) { t += ' Every other column reads like a symptom, so they will be checked against each other.'; } setText($('lb-cols'), t); } // What a q-value may honestly be called. It used to become "Roughly 1 in N // that this is a fluke", which is not what q is: q is the expected share of // false findings among everything reported at that bar, not the chance that // this one is a fluke. A trend artefact at q 0.100 read "1 in 10". The sentence // now says only what happened: it passed a stated bar, by how much, after how // many pairings. The number itself sits on the line below. function plainOdds(q, tested) { var after = tested ? 'after testing ' + tested + (tested === 1 ? ' pairing' : ' pairings') : ''; if (q < 0.01) return 'Passed the chance check with room to spare' + (after ? ', ' + after : '') + '.'; if (q < 0.05) return 'Passed the chance check' + (after ? ' ' + after : '') + '.'; return 'Only just passed the chance check' + (after ? ', ' + after : '') + '.'; } function numsLine(t, corrected) { return 'strength ' + Math.abs(t.rho).toFixed(2) + ' · ' + t.pairs + ' day pairs' + ' · p ' + (t.p < 0.001 ? '<0.001' : t.p.toFixed(3)) + ' · q ' + t.q.toFixed(3) + ' after correcting for ' + corrected + ' comparisons'; } // Same-day wording is deliberately NOT directionWords. "More of it, worse day" // reads as one thing leading to the other, and on the same day that is the one // reading the numbers cannot support. function sameDayWords(rho, higherIsWorse) { var worse = higherIsWorse ? rho > 0 : rho < 0; return worse ? 'higher on worse days' : 'higher on better days'; } function quoteList(names) { var q = names.map(function (n) { return '"' + n + '"'; }); if (q.length <= 1) return q.join(''); return q.slice(0, -1).join(', ') + ' or ' + q[q.length - 1]; } // Say out loud which columns were left out and why. The reader is looking at // their own spreadsheet; a column that is present there and absent here, with // no reason given, is indistinguishable from the page being broken. function showDropped(dropped) { var node = $('lb-dropped'); if (!node) return; if (!dropped || !dropped.length) { setText(node, ''); return; } setText(node, 'I left out ' + dropped.map(function (d) { return '"' + d.name + '" (' + d.why + ')'; }).join(', ') + '.'); } function loadText(text, isExample) { var parsed = parseCSV(text); var series = buildSeries(parsed); if (series.error) { setText($('lb-status'), series.error); // The reason list matters MOST on this path: this is the case where the // drop is why there is no answer at all. showDropped(series.dropped); // The refusal is the moment the reader most has something to say, and // until now it was the one moment with nowhere to say it. show($('lb-wrong-early')); hide($('lb-step2')); hide($('lb-results')); state.series = null; return; } state.series = series; state.isExample = !!isExample; showDropped(series.dropped); hide($('lb-wrong-early')); var sel = $('lb-outcome'); sel.textContent = ''; var named = chooseOutcome(series.names); var guess = named || series.names[series.names.length - 1]; // If a column was dropped and nothing here is recognisably a symptom // column, do NOT silently fall back to the last column. The reader came // about the column that got dropped; printing a confident report about // "stress" instead is the worst failure this page has. var mustChoose = !named && series.dropped && series.dropped.length; if (mustChoose) { var ph = el('option', null, 'pick the column that means how you felt'); ph.value = ''; ph.selected = true; sel.appendChild(ph); } series.names.forEach(function (n) { var o = el('option', null, n); o.value = n; if (!mustChoose && n === guess) o.selected = true; sel.appendChild(o); }); syncPolarity(mustChoose ? '' : guess); // The date reading is stated back on EVERY run, not only when it is // uncertain. A misread date format is the one failure that produces a // confident, plausible, completely wrong answer, and the only person who // can catch it is the reader looking at a date they remember. var status = (isExample ? 'Example data loaded. ' : 'Read your file. ') + series.daysLogged + ' days with entries, from ' + dayIndexToISO(series.first) + ' to ' + dayIndexToISO(series.first + series.span - 1) + '.'; // Announced, because skipping a row is a decision about the reader's file // and a silent decision is the thing this page refuses to make. if (series.titleRow) { status += ' I skipped the first row ("' + String(series.titleRow).slice(0, 40) + '") and used the row under it as your column names.'; } // Reshaping someone's table is a decision about their data, so it is said // out loud, before anything else about the file. if (series.pivotNote) status += ' ' + series.pivotNote; if (series.dateNote) status += ' ' + series.dateNote; if (series.smearWarning) status += ' ⚠ ' + series.smearWarning; setText($('lb-status'), status); $('lb-status').className = series.smearWarning ? 'lb-status lb-warn' : 'lb-status'; syncCols(guess); show($('lb-step2')); hide($('lb-results')); $('lb-step2').scrollIntoView({ behavior: 'smooth', block: 'start' }); } function renderReport(res) { var box = $('lb-report'); box.textContent = ''; if (res.error) { box.appendChild(el('p', 'lb-null', res.error)); show($('lb-results')); return; } var sameDay = res.sameDay || []; var notDrivers = res.notDrivers || []; var head = el('p', 'lb-runline'); head.textContent = 'Checked ' + res.comparisons + ' pairings: ' + (notDrivers.length ? 'the other columns' : 'every other column') + ' against "' + res.outcome + '", at 1, 2 and 3 days before, across ' + res.daysLogged + ' logged days.' + (res.sameDayComparisons ? ' The same day was checked on its own, ' + res.sameDayComparisons + ' more pairings, against a stricter bar.' : '') // The polarity control lives in .lb-picker, which print hides. Stating the // assumption inside the report is what carries it onto the paper, so a // reader, or their doctor, can see which way the scale was read. + ' Read as: a higher number in "' + res.outcome + '" means a ' + (res.higherIsWorse ? 'worse' : 'better') + ' day.' // The left-out columns are named HERE as well as above the button, because // this line is the one that reaches the printed sheet. + (notDrivers.length ? ' I did not check ' + quoteList(notDrivers) + ' as ' + (notDrivers.length === 1 ? 'something that came before, because it reads' : 'things that came before, because they read') + ' like another way of saying how you felt.' : '') + (res.symptomsAsDrivers ? ' Every other column reads like a symptom, so I checked them against each other. Read anything below as one symptom coming before another, not as a cause.' : '') + (state.isExample ? ' This is example data, not yours.' : ''); box.appendChild(head); if (!res.survivors.length) { var nul = el('div', 'lb-null'); nul.appendChild(el('h3', null, sameDay.length ? 'Nothing in the days before stands out from chance.' : 'Nothing here stands out from chance.')); nul.appendChild(el('p', null, 'Of ' + res.comparisons + ' pairings in the 1 to 3 days before, none survived once I corrected for how many I checked. ' + 'At about ' + res.medianPairs + ' overlapping days, an association would have needed to be roughly ' + res.typicalNeeded.toFixed(2) + ' on a 0-to-1 scale to stand out here.')); nul.appendChild(el('p', null, 'That is a real answer, not a failure. It usually means one of three things: not enough days yet, ' + 'the thing that matters was never one of your columns, or the pattern is there but smaller than this much data can see.')); // A null over a file so wide the bar was mathematically out of reach is // NOT the same statement as a null over a file the maths could see, and // printing them identically is the page lying by omission. Say which one // this is. Without this the reader is told nothing stood out when the // truth is that nothing COULD have. if (res.resolutionShort) { nul.appendChild(el('p', 'lb-warn', 'One caution about this particular file: it has ' + res.driversChecked + ' columns to check, which is ' + res.comparisons + ' comparisons. Correcting honestly for that many ' + 'raises the bar higher than ' + res.permutations + ' resamples can measure, so a real pattern could be ' + 'sitting under it. If you know which two or three columns you actually suspect, take the others out ' + 'of the file and load it again. Fewer columns is a lower bar, and that is not cheating: it is asking ' + 'a narrower question.')); } box.appendChild(nul); } else { // ONE HEADLINE CARD. The report used to print a card per surviving pairing, // up to eight, which on the audit export averaged 3.48 cards for one // planted effect, and the first of them was a same-day decoy. One card is // the claim; the rest are listed plainly underneath as what they are. var n = res.survivors.length; box.appendChild(el('h3', 'lb-found-h', n === 1 ? 'One thing stood out.' : 'The clearest of ' + n + ' things that stood out.')); var top = res.survivors[0]; var card = el('div', 'lb-find'); card.appendChild(el('p', 'lb-find-lag', lagWords(top.lag))); card.appendChild(el('h4', null, top.driver)); card.appendChild(el('p', 'lb-find-dir', directionWords(top.rho, res.higherIsWorse))); card.appendChild(el('p', 'lb-find-plain', plainOdds(top.q, res.comparisons))); card.appendChild(el('p', 'lb-find-nums', numsLine(top, res.comparisons))); box.appendChild(card); if (n > 1) { box.appendChild(el('p', 'lb-caveat', (n - 1 === 1 ? 'One other pairing' : (n - 1) + ' other pairings') + ' also passed the chance check, less clearly:')); var others = el('ul', 'lb-checks lb-checks--plain'); res.survivors.slice(1, 8).forEach(function (t) { others.appendChild(el('li', null, t.driver + ', ' + lagWords(t.lag) + ': ' + directionWords(t.rho, res.higherIsWorse) + '. ' + numsLine(t, res.comparisons))); }); box.appendChild(others); } // A driver that survives at several lags is almost always ONE pattern // seen four times, not four findings. Left unsaid, the page looks like // it found more than it did — which is the overselling this whole // exercise exists to avoid. var byDriver = {}; res.survivors.forEach(function (t) { byDriver[t.driver] = (byDriver[t.driver] || 0) + 1; }); var repeated = Object.keys(byDriver).filter(function (k) { return byDriver[k] > 1; }); if (repeated.length) { box.appendChild(el('p', 'lb-caveat', (repeated.length === 1 ? '"' + repeated[0] + '" appears' : 'Some of these appear') + ' at more than one lag. That is usually one pattern showing up several times rather than ' + 'several separate findings. A busy day tends to sit next to another busy day, so neighbouring ' + 'days carry much the same information. Read the one at the top and treat the rest as its shadow.')); } box.appendChild(el('p', 'lb-caveat', 'This is an association in your own numbers over time. It is not proof that one caused the other, ' + 'and it is not a diagnosis. Something you never wrote down could be behind both.')); } // THE SAME DAY, KEPT APART. Still checked, because a same-day link is real // information and the page has always said it looks there. Never the // headline, never a card, and worded so it cannot be read as one thing // leading to another. .lb-caveat and .lb-checks both print; .lb-note does not. if (sameDay.length) { box.appendChild(el('p', 'lb-caveat', 'Separately, on the same day, ' + (sameDay.length === 1 ? 'one column' : sameDay.length + ' columns') + ' moved with "' + res.outcome + '". A same-day link can run either way: a bad day can change a number ' + 'as easily as the number can change the day. So ' + (sameDay.length === 1 ? 'it is' : 'these are') + ' not counted as what came before, and had to pass a stricter bar.' // The association caveat above is printed only with a finding. When the // same day is all there is, it has to be said here instead. + (res.survivors.length ? '' : ' Like everything on this page, it is an association in your own numbers, not a cause and not a diagnosis.'))); var sd = el('ul', 'lb-checks lb-checks--plain'); sameDay.slice(0, 8).forEach(function (t) { sd.appendChild(el('li', null, t.driver + ', the same day: ' + sameDayWords(t.rho, res.higherIsWorse) + '. ' + numsLine(t, res.allComparisons))); }); box.appendChild(sd); } if (res.skipped.length) { var d = el('details', 'lb-details'); d.appendChild(el('summary', null, res.skipped.length + ' pairings were skipped')); var body = el('div', 'lb-details-body'); var ul = el('ul', 'lb-checks lb-checks--plain'); res.skipped.slice(0, 40).forEach(function (s) { ul.appendChild(el('li', null, s.driver + ', ' + lagWords(s.lag) + ' — ' + s.why + ' (' + s.pairs + ' pairs).')); }); body.appendChild(ul); d.appendChild(body); box.appendChild(d); } show($('lb-results')); $('lb-results').scrollIntoView({ behavior: 'smooth', block: 'start' }); } // Rendered ONLY when the reader presses the button, and only from `state.res` // — the object analyze() returned. There is no code path from the parsed file // to this canvas. function makeReceipt() { if (!state.res || state.res.error) return; var lines = receiptLines(state.res, { outcomeLabel: $('lb-rc-outcome') ? $('lb-rc-outcome').value : '', driverLabel: $('lb-rc-driver') ? $('lb-rc-driver').value : '', isExample: state.isExample }); var canvas = $('lb-rc-canvas'); var lay = drawReceipt(canvas, lines); var img = $('lb-rc-img'); var url; try { // toDataURL, not createObjectURL. The site's CSP is `img-src 'self' // data:` with NO `blob:`, so a blob URL renders as a broken image in // production and works perfectly on localhost — the failure mode that // shipped /privacy visually broken for a month. Verified against the // live header on 2026-09-09. url = canvas.toDataURL('image/png'); } catch (e) { setText($('lb-rc-note'), 'Your browser would not turn that into a picture. The text version below says the same thing.'); url = ''; } if (url) { img.src = url; img.alt = 'A card showing this result: ' + receiptText(lines).replace(/\n/g, '. '); show(img); var a = $('lb-rc-save'); a.href = url; a.download = 'what-came-before.png'; show(a); setText($('lb-rc-note'), // No caption. This is an instruction for getting the file out of the // browser, and it stops there. What they write around it is theirs. 'On a phone: press and hold the picture, then Save or Add to Photos. On a computer: right-click it, or use the button.'); } $('lb-rc-text').value = receiptText(lines); show($('lb-rc-out')); if (lay.overflow) setText($('lb-rc-note'), 'That column name was too long to fit, so it is shortened on the picture. The text version below has it in full.'); } // The labels are offered BEFORE the picture exists, pre-filled with the // reader's own column names, because those words are about to become a public // image and some of them are diagnoses. function offerReceipt(res) { var box = $('lb-rc'); if (!box) return; if (!res || res.error) { hide(box); return; } var found = res.survivors && res.survivors.length ? res.survivors[0] : null; $('lb-rc-outcome').value = res.outcome || ''; var dRow = $('lb-rc-driver-row'); if (found) { $('lb-rc-driver').value = found.driver || ''; show(dRow); } else { $('lb-rc-driver').value = ''; hide(dRow); } hide($('lb-rc-out')); show(box); } function run() { if (!state.series) return; if (!$('lb-outcome').value) { setText($('lb-cols'), 'Choose the column that means how you felt first.'); return; } var btn = $('lb-run'); btn.disabled = true; // A wide file takes seconds, and seconds of an unresponsive tab reads as a // crash to someone who is already exhausted. Say it is working, and say // that the width is why. setText(btn, state.series.names.length > 12 ? 'Looking… (wide file, a moment)' : 'Looking…'); // Yield once so the button state paints before the permutations block // the main thread. Two thousand resamples per pairing is fast but not // free, and a frozen button reads as a crash. setTimeout(function () { var res; try { res = analyze(state.series, $('lb-outcome').value); // Bound to the control the visitor can see, read at run time, never inferred. res.higherIsWorse = higherIsWorseNow(); } catch (e) { res = { error: 'Something went wrong reading that: ' + (e && e.message ? e.message : 'unknown error') + '. If it keeps happening, email brock@arkhelion.ai the shape of your columns — the headings, not the data itself.' }; } state.res = res; renderReport(res); offerReceipt(res); btn.disabled = false; setText(btn, 'Look back'); }, 20); } // Build the prefilled reply link HERE rather than in the HTML attribute. // // Cloudflare Scrape Shield rewrites every mailto: in served HTML into // /cdn-cgi/l/email-protection#, and it encodes the RAW attribute text. // An `&` — which is the correct way to write `&` in an HTML attribute — // therefore decodes back out as the literal five characters `&`, the mail // client reads the second parameter name as `amp;body`, and the body prefill // silently does nothing. Measured against the live page on 2026-09-03. Writing // a bare `&` instead only moves the problem: the obfuscator is not an HTML // parser, and the fix would depend on that staying true. // // External .js is not rewritten (verified: the address in the crash string // survives intact on the deployed file), so this is the one place the whole // link survives. The bare address stays in the HTML as the fallback, so the // link still works if this never runs. // // The three prompts are the point. "Tell me what it got wrong" to someone // exhausted returns "it didn't work". These three lines return something // reproducible, and cost the sender nothing to fill in. function setReply(id, body) { var a = $(id); if (!a) return; a.href = 'mailto:brock@arkhelion.ai' + '?subject=' + encodeURIComponent('What it got wrong') + '&body=' + encodeURIComponent(body); } // Two links, two different moments, two different prompts. The refusal path // needs the HEADING ROW, because that is what diagnoses a file the page // could not read — and it asks for headings ONLY, never a day of data. function wireReplyLink() { setReply('lb-wrong', 'What I expected:\n\nWhat it said:\n\nWhat my file looks like (headings only, not the data):\n'); setReply('lb-wrong-early', 'What I was trying to load:\n\nWhat it told me:\n\nThe HEADING ROW of my file — the column names only, no days and no numbers:\n'); } function wire() { wireReplyLink(); var params = new URLSearchParams(window.location.search); if (params.get('selftest') === '1') { var results = selfTest(); var box = $('lb-report'); box.textContent = ''; box.appendChild(el('h3', null, 'Self-test')); results.forEach(function (r) { box.appendChild(el('p', r.pass ? 'lb-pass' : 'lb-fail', (r.pass ? 'PASS — ' : 'FAIL — ') + r.name + ' · ' + r.detail)); }); show($('lb-results')); } $('lb-demo').addEventListener('click', function () { loadText(exampleCSV(), true); }); $('lb-file-input').addEventListener('change', function (ev) { var f = ev.target.files && ev.target.files[0]; if (!f) return; setText($('lb-status'), 'Reading ' + f.name + '…'); var reader = new FileReader(); reader.onerror = function () { setText($('lb-status'), 'Your browser could not read that file. On an iPhone, try picking it from Files rather than from an app.'); }; reader.onload = function () { loadText(String(reader.result), false); }; reader.readAsText(f); // A browser fires `change` only when the SELECTION changes, so picking the // same file twice was silently a no-op. Both of this page's own recovery // messages ask for exactly that — "try picking it from Files rather than // from an app", and every parse error a visitor would fix in a spreadsheet // and re-pick. The retry the page asks for was dead. Clearing the value // makes the next pick a change even when it is the same file. ev.target.value = ''; }); $('lb-paste-go').addEventListener('click', function () { var v = $('lb-paste').value.trim(); if (!v) { setText($('lb-status'), 'Nothing pasted yet.'); return; } loadText(v, false); }); $('lb-outcome').addEventListener('change', function (ev) { syncPolarity(ev.target.value); syncCols(ev.target.value); }); $('lb-run').addEventListener('click', run); $('lb-print').addEventListener('click', function () { window.print(); }); if ($('lb-rc-make')) $('lb-rc-make').addEventListener('click', makeReceipt); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', wire); } else { wire(); } })();