/* dow.cjs — take the calendar out of the data before anything looks for a * pattern in it. * * WHY THIS EXISTS * * `pages/arkhelion-site/public/js/what-came-before.js` finds delayed * relationships with a rank correlation, a circular block permutation and a * Benjamini-Yekutieli correction. All of that is sound. It still claims a * personal pattern on data that contains none, whenever the person has an * ordinary weekly rhythm — more on weekdays, rest at the weekend, or the * reverse. Most people who are ill have exactly that rhythm. * * MEASURED, not assumed. 40 runs per level, 120 days, four drivers and an * outcome that all carry the SAME weekly cycle and are otherwise independent, * so the honest answer at every level is "no pattern": * * weekly amplitude 0.0 -> claimed a pattern in 0/40 runs (0%) * weekly amplitude 0.4 -> claimed a pattern in 0/40 runs (0%) * weekly amplitude 0.8 -> claimed a pattern in 33/40 runs (83%) * weekly amplitude 1.2 -> claimed a pattern in 40/40 runs (100%) * * THE MECHANISM. `chooseBlock` sets the permutation block length from the * lag-1 autocorrelation of the pair, `max(7, min(ceil(4*tau), n/6))`. Whenever * that lands on anything other than a multiple of 7 — which is most of the * range, 8 through 15 on a four-month export — the circular resample slides * the driver's weekly phase relative to the outcome's. The null therefore has * the weekly alignment broken while the real data still has it. The observed * correlation is then extreme against its own null by construction, and the * page reports a calendar artefact to a sick person as a fact about their body. * * THE FIX. Subtract each weekday's own mean from every column, on the dense * daily grid, before any of the existing machinery runs. What survives is the * part of each day that is NOT explained by which day of the week it was, and * the question the engine answers becomes the one a reader actually wants: * "once my usual week is accounted for, does anything still line up?" * * Residualising the INPUT rather than patching the permutation keeps every * downstream decision — lag alignment, block choice, the q cutoff, the honesty * gates — exactly as it was, and lowers the autocorrelation `chooseBlock` * reads, which is also correct. * * WHAT IT COSTS. A genuine effect that happens to be weekly is removed along * with the artefact. That is the right trade for this reader: an engine that * cannot tell "Tuesdays are bad" from "exertion two days ago is bad" must not * claim the second. * * MEASURED BOTH WAYS, because either arm alone is worthless — arm A alone * passes for a fix that simply switches the engine off, and arm B alone passes * for the engine that ships today. 20 runs, 140 days, 900 permutations: * * as shipped with this * A. weekly rhythm, no relationship 95-100% 0% <- artefact * B. real 2-day lag, correctly found 15-20/20 20/20 <- power * B. wrong driver named 3 0 * * It strictly dominates: fewer false claims AND more true ones. The reason is * visible in a single run — removing the weekly cycle raises the real signal's * rho from 0.405 to 0.828, because the calendar was noise competing with it. * * `scripts/read/test-dow.cjs` pins both arms. NOTE the permutation count: the * q cutoff is 0.06 under Benjamini-Yekutieli over twelve pairings, so below * roughly 900 permutations neither arm can clear the bar and BOTH appear to * fail. That reads as "the fix broke it" and means "the test could not * measure". It cost an hour here; do not rediscover it. */ 'use strict'; /* A weekday group needs at least this many days before its own mean is * trustworthy enough to subtract. Below it, subtract the grand mean instead — * removing a 1- or 2-day group's own mean would drive those days to ~0 and * manufacture ties that the rank correlation would then read as structure. */ const MIN_GROUP = 3; /** * Residualise one dense daily column on day-of-week, in place. * * @param {Float64Array} col one value per day; NaN where the day is missing. * @param {number} phase grid index of day 0 modulo 7. Only consistency * matters, not which real weekday it is, so the * caller may pass 0 unless it knows better. * @returns {{groups:number, used:number, skipped:number}} what was subtracted. */ function deweekdayColumn(col, phase) { phase = phase | 0; const sums = new Float64Array(7); const counts = new Int32Array(7); let grand = 0, grandN = 0; for (let i = 0; i < col.length; i++) { const v = col[i]; if (!Number.isFinite(v)) continue; const g = (i + phase) % 7; sums[g] += v; counts[g]++; grand += v; grandN++; } if (grandN === 0) return { groups: 0, used: 0, skipped: 0 }; const grandMean = grand / grandN; const means = new Float64Array(7); let used = 0, skipped = 0; for (let g = 0; g < 7; g++) { if (counts[g] >= MIN_GROUP) { means[g] = sums[g] / counts[g]; used++; } else if (counts[g] > 0) { means[g] = grandMean; skipped++; } else { means[g] = grandMean; } } for (let i = 0; i < col.length; i++) { const v = col[i]; if (!Number.isFinite(v)) continue; col[i] = v - means[(i + phase) % 7]; } return { groups: used + skipped, used, skipped }; } /** * Residualise every numeric column of a `buildSeries` result on day-of-week. * Mutates `series.columns` in place and returns a short report. * * `buildSeries` produces a DENSE daily grid — `span = last - first + 1`, each * column a `Float64Array(span)` indexed by day offset — so the grid index * modulo 7 is a stable weekday group. That density is the property this * depends on; if it ever stops being dense, this stops being correct. */ function deweekdaySeries(series, phase) { if (!series || !series.columns) return { columns: 0 }; let n = 0, thin = 0; for (const name of Object.keys(series.columns)) { const col = series.columns[name]; if (!col || typeof col.length !== 'number') continue; const r = deweekdayColumn(col, phase || 0); n++; if (r.skipped) thin++; } return { columns: n, columnsWithThinWeekdays: thin }; } module.exports = { deweekdayColumn, deweekdaySeries, MIN_GROUP };