Module 5 · Residual diagnostics and seasonality
Econ 6376 · The George Washington University
| Term | L3 | L4 | L5 |
|---|---|---|---|
| , | on | on | on |
| , all | on | on | on |
| , all | on | on | on |
| Seasonal block — the same structure at lag | off | off | turning on today |
| on | on | on |
Two halves, one picture. Part A: learn to check a model — residuals are the final jury. Part B: the structure Module 4’s residuals kept pointing at — what seasonality is, and three ways to treat it.
By the end of today every slider on the mixing board is on.
library(forecast)
csv <- read.csv("../../data/UNRATE.csv") # cached FRED pull
csv <- subset(csv, observation_date >= "1960-01-01" &
observation_date <= "2019-12-01")
unrate <- ts(csv$UNRATE, start = c(1960, 1), frequency = 12)
d_unrate <- diff(unrate)
fit_l4 <- Arima(d_unrate, order = c(1, 0, 2), # Module 4's pick,
include.mean = FALSE) # same zero-mean policy
e_hat <- residuals(fit_l4)
ggAcf(e_hat, lag.max = 36)
acf(e_hat, lag.max = 36, plot = FALSE)$acf[c(13, 25, 37)]Same window as Module 4: 1960-01 to 2019-12, differences. The low lags are quiet. The spikes every twelve months survive.
What would you need to see to sign off on this model?
Four checks, one ranked line:
serial correlation → heteroskedasticity → zero mean → normality
The first is the whole of today. The second is a preview of Module 14 — eyeball it for now. The last two are cheap, and almost never the real problem.
| Term | Meaning | Belongs to |
|---|---|---|
| Autocorrelation (dependence, memory, persistence) | A property of a series, measured by its ACF — any series, residuals included | the data |
| Serial correlation | The diagnostic verdict that a fitted model’s residuals show autocorrelation | the model, relative to the data |
| Residual seasonality | Seasonal autocorrelation left in a published seasonally adjusted series | the adjustment procedure — itself a model |
“Serial correlation is not a property of the data — it is a property of your model relative to the data. It means your model is wrong.”
Neither is a property of the data.
| Residual ACF pattern | First candidate diagnosis — a proposal, not a verdict |
|---|---|
| Mostly inside , no systematic pattern | no visible evidence of remaining serial correlation |
| Spike at lag 1 only | an omitted short-run AR or MA term |
| Geometric decay from lag 1 | omitted low-order AR dynamics |
| Cutoff after a few lags | omitted low-order MA dynamics |
| Spikes at (here ) | omitted dependence at the seasonal frequency — today’s transition |
| Large correlations that persist, not decay | insufficient differencing; reassess the transformation |
Same table as Module 3, different question: there it told you what to fit; here, what your fit missed. Propose the next candidate, refit, re-check — a diagnosis earns its place only if the revised fit removes the pattern without creating a new one.
Pick a DGP you know — AR(2), MA(2), or the two glued together — fit an ARMA(), read the residual ACF and the Ljung-Box verdict. Predict before every move.
The diagnostic tools work. When the residuals carry real structure, Ljung-Box flags it without knowing the DGP, and the residual ACF shape proposes what to add — a candidate, not a verdict.
The fit will not volunteer the problem. Fit the one-block-short model with Arima() and it prints coefficients, standard errors, an AIC, a log-likelihood. None of them says “short”. Stop at the IC winner without checking residuals and you own a confidently wrong model. Module 1’s spurious-regression Monte Carlo, in a new setting: the printout lies, and only a dependence-aware check catches it.
The structure came from the model. The DGP was perfectly well behaved. The serial correlation in the residuals was generated by the fit. The correct orders leave white noise — and extra orders leave white noise too, at a price diagnostics cannot see and only parsimony can. Change the fit and the structure is gone; the data never moved.
Serial correlation is a model report card, not a disease the data has.
Box.test(e_hat, lag = 10, type = "Ljung-Box", fitdf = 3)
Box.test(e_hat, lag = 24, type = "Ljung-Box", fitdf = 3)
checkresiduals(fit_l4)The two horizons disagree, and the disagreement is the lesson. Over ten lags these residuals are white noise. Widen the window to lag 24 and the joint statistic sees what the eye saw: a test that never looks at lag 12 cannot reject because of lag 12.
The residual ACF shows no decay from lag 1, no cutoff, no spike at lag 1 — the Module 3 table’s first four rows are empty.
What it shows is spikes at 12, 24, 36. Every twelve months. Same sign each time.
Spikes that recur at regular intervals. What are those?
checkresiduals() on the Module 4 fitUNRATENSA — the same civilian unemployment rate, not seasonally adjusted. What the survey measures each month.
No model has been fitted. This is the series’ own autocorrelation. The lag-12 spike in the Module 4 residuals was serial correlation — a verdict on a fit. Same picture, different owner.
Ten times the band, and barely decaying from one year to the next. Hold that thought.
| Tradition | Core object | Representative work |
|---|---|---|
| Calendar means (regularity and repetition) | Seasonal means across a calendar partition | Falkner (1924); Kallek (1978) |
| Seasonal unit roots and stability (the ARIMA polynomial) | Roots of the AR polynomial at the seasonal frequencies | Hylleberg, Engle, Granger & Yoo (1990); Canova & Hansen (1995) |
| Unobserved components (signal extraction) | A latent seasonal component identified by a restriction | Hillmer & Tiao (1982); Bell & Hillmer (1984); X-13ARIMA-SEATS |
| Spectral (frequency domain) | A functional of the spectral density at the seasonal frequencies | Nerlove (1964); Granger (1978) |
Each is internally consistent and answers a well-posed question. None nests the others. Analysts consult several at once and quietly assemble a superset nobody has written down.
“Some of us looking for answers regarding seasonal analysis may feel as if we’re in a dark room looking for a black cat. That’s bad, but it’s not as bad as it could be. Pity the philosophers who are in a dark room looking for a black cat that isn’t there.” — Zellner et al. (1978, p. 451)
The seasonal unit root is not a third species. It is the stochastic case pushed to the limit where the seasonal pattern never mean-reverts — the random walk is the AR(1) at (Module 2), and this is the seasonal analogue at . The differencing decision is the decision about which side of that boundary the series is on.
Students want a checklist: seasonality is X; calendar effects, trading days, holidays are not seasonality. The course declines, and the reason matters.
| Diagnosis or need | Path | What it commits you to |
|---|---|---|
| The pattern is deterministic — fixed, exogenous, calendar-driven | Path 1 · seasonal dummies (or Fourier terms) in a regression | a fixed shape; if the pattern drifts, the residuals will say so |
| The pattern is stochastic — it drifts, and you want to model it and forecast with it | Path 2 · SARIMA — seasonal ARMA terms, with seasonal differencing if there is a seasonal unit root | a joint model of both bands of the console |
| “I need it gone, not modeled” — a series you can read, compare month to month, or feed to a non-seasonal model | Path 3 · seasonal adjustment — decompose, remove the seasonal, publish the rest | trusting a procedure that is itself a model |
The three are not rivals. Agencies run path 3 to publish the series you read in the news; forecasters run path 2 on the raw or the published series; path 1 is the cheap first question — is the pattern fixed?
A journalist comparing this month to last month — which path? A forecaster building next year’s fan chart?
All three, next, on one series: the raw unemployment rate.
On the first difference — the course has treated the unemployment rate as since Module 2 — regress on eleven month indicators plus an intercept, with month index and January () as the reference:
forecast::fourier(). Fewer parameters, same fixedness — and fixedness, not parsimony, is what is about to be tested.nsa <- read.csv("../../data/UNRATENSA.csv")
nsa <- subset(nsa, observation_date >= "1960-01-01" &
observation_date <= "2019-12-01")
unratensa <- ts(nsa$UNRATENSA, start = c(1960, 1),
frequency = 12)
d_unratensa <- diff(unratensa)
source("../../helpers/seasonality.R") # seasonal_dummies()
X_month <- seasonal_dummies(d_unratensa) # Jan = reference
fit_dum <- lm(d_unratensa ~ X_month)
summary(fit_dum)$r.squared # 0.72
e_dum <- ts(residuals(fit_dum), frequency = 12,
start = start(d_unratensa))
ggAcf(e_dum, lag.max = 36)
Box.test(e_dum, lag = 24, type = "Ljung-Box", fitdf = 0)Before the plot: the dummies absorb . Will lag 12 clear the band?
Honestly: the fitdf Ljung-Box also rejects for non-seasonal reasons — lags 2, 3, 5, 7, 11 breach too, the Module 4 dynamics a dummy regression cannot carry. Give it ARMA(1,2) errors and only the seasonal lags do the rejecting: passes (), fails.
Is the pattern fixed? Month effects by half, 1960–89 against 1990–2019: , . June halves (); July flips sign ().
The fixed part is absorbed. A drifting part remains. On to path 2.
The non-seasonal band works at lags — the knobs since Module 3. A seasonal process has a second band at lags with the same kinds of knobs: AR, MA, and differencing.
The bands multiply. Take the smallest case with both on, ARIMA:
sarima_simulator() in helpers/simulators.R does exactly this expansion before running the master-equation loop. (Worked in the notes’ Deeper Dive §5.7.2.)
Decaying spikes at : a stationary seasonal ARMA. Model the band, keep .
Flat spikes near one: a seasonal unit root. Difference once at the seasonal frequency, , then re-read.
Do not reach for without fresh evidence of a second seasonal unit root.
The benchmark, Box & Jenkins’s airline model, ARIMA:
Two differences, two parameters. A benchmark to improve on, not an answer.
fit_air <- Arima(unratensa, order = c(0, 1, 1), seasonal = list(order = c(0, 1, 1), period = 12))
fit_d0 <- Arima(unratensa, order = c(1, 1, 2), seasonal = list(order = c(1, 0, 1), period = 12))
fit_auto <- auto.arima(unratensa)
fit_win <- Arima(unratensa, order = c(1, 1, 2), seasonal = list(order = c(0, 1, 1), period = 12))
source("../../helpers/diagnostics.R") # lb_both()
lb_both(fit_win); checkresiduals(fit_win)Before the table: which of the four will put a coefficient on a boundary?
| Candidate | Seasonal coefficient | LB (10 / 24) | Verdict |
|---|---|---|---|
| Airline | , interior | / | fails both: the block is too thin — residual ACF 0.21, 0.14, 0.12, 0.11 at lags 2–5 |
| 0.97 / 0.76 | passes and wins the table, but the seasonal AR root, in , has modulus 1.006, within 0.01 of the unit circle: a seasonal unit root in disguise | ||
auto.arima() |
, , eight coefficients | 0.27 / 0.47 | passes; a third differencing class, so its AICc is on neither table — a very good intern, read its work |
| Winner | , interior | 0.88 / 0.42 | passes both horizons, every coefficient interior, same differencing as the airline |
A likelihood is evaluated on the seasonally differenced series — a different, shorter series than a model uses — and is different again. The four candidates fall into three classes, and rows are comparable only within a class.
The class, the airline model against the winner:
| AIC | BIC | LB (10 / 24) | |
|---|---|---|---|
| airline | −155.7 | −142.0 | / |
| −206.2 | −183.4 | 0.88 / 0.42 |
The winner beats the airline by 50 AIC / 41 BIC points — the whole gap is the non-seasonal block.
auto.arima() sits alone in the class; its AICc is on neither table.The tables cannot pick among candidates 2, 3, 4. Diagnostics and the coefficients do.
And a check nobody planned: X-13’s own automatic model selection, run on the next path with no guidance, chose the same orders as the winner.
On the published, already adjusted UNRATE, impose — the airline model:
The seasonal MA coefficient is pinned at the non-invertibility boundary: the Module 3 over-differencing signature. The model is trying to undo a difference the data did not need — the adjustment procedure had, in effect, already taken it.
On the raw UNRATENSA, leave and model the band with seasonal ARMA terms:
The seasonal AR coefficient is pinned at one — the root in has modulus 1.006, within 0.01 of the unit circle. The model is trying to manufacture a difference the data did need. Diagnostics pass; the coefficient tells the truth.
Either boundary estimate says: revisit , not the ARMA orders.
Neither shows up in an IC table, because IC cannot compare across differencing orders — a likelihood is evaluated on a different, shorter series. When candidates disagree about differencing, decide on diagnostics and coefficients, and on out-of-sample forecasts (Module 6).
A forecaster wants to model the seasonal band. A statistical agency, a journalist, a policymaker wants it gone, so this month can be compared with last month without the January jump in the way.
Recognition level, deliberately: no simulate step, no spec file, one call.
library(seasonal) # wraps X-13ARIMA-SEATS
fit_x13 <- seas(unratensa) # no options, no spec file
summary(fit_x13) # chose (1 1 2)(0 1 1), SEATS,
# no log, leap year + weekday,
# and a level shift at 1975-01
sa_x13 <- final(fit_x13)
autoplot(cbind(published = unrate, x13 = sa_x13))
cor(sa_x13, unrate)
mean(abs(sa_x13 - unrate)); max(abs(sa_x13 - unrate))Before the overlay: how close will one default call get to the published line?
The two adjusted lines are one line. The gaps are what a default call against a production run should leave — BLS adjusts concurrently, with its own span, regressors and revision history. One X-13 call reproduces the published series. Not equals.
The UNRATE series you have used since Module 2 is the output of this procedure.
Every ACF, ADF test, ARMA fit and information criterion in Modules 2–4 was computed on trend-cycle-plus-irregular from an X-13 run at the BLS, not on what the survey measured. Not a complaint — it changes what you are modeling.
Adjustment is a model. It fits a SARIMA, imposes an identifying restriction, and removes what that restriction calls seasonal. Like any model it can be wrong relative to the data — so the rule from Part A applies to it exactly as it applies to your own fits.
You cannot diagnose residual seasonality without saying what seasonality is.
A test for “seasonality left over” needs a definition to test against — the one thing the field has never agreed on. The Deeper Dive that follows is one way of drawing that line so it can be tested. It is optional and never assessed.
Deeper Dive · not assessable
Every finite series has two equivalent descriptions, connected by the discrete Fourier transform at the Fourier frequencies :
The series is a mixture. The DFT is the centrifuge that separates it into pure tones. For monthly data the annual cycle lives at , with harmonics at , , , , .
Time to frequency: the instructor’s animation, local file. Press play.
Deeper Dive · not assessable
The periodogram is the sample estimate of how variance is spread across frequencies.
Here it is for UNRATENSA — the series whose ACF spiked at 12, 24, 36. The spikes sit on the dashed lines: the same seasonality, by rate of repetition.
Which line is tallest? — the semiannual harmonic, a January jump and a June jump — not the annual . The lag-12 spike cannot tell you that; the periodogram shows it at a glance.
Model-free: deterministic, stochastic and unit-root seasonality all show up the same way.
Deeper Dive · not assessable
Draw the line before looking at the data:
A series is seasonal, relative to the chosen cycles and partition, if and only if .
In plain language: the chosen cycles are demonstrably larger than every other cycle. Relative, not absolute, so a narrow spike at and a diffuse hump around count alike, with no commitment to what generated the peak. Whatever lands in a seasonal bin counts, trading-day leakage included — the “no clean isn’t” made operational.
Deeper Dive · not assessable
Replace with the periodogram and take the same two maxima:
Under white noise with innovation variance , periodogram ordinates are approximately independent exponentials; the max of of them is Gumbel, and a difference of two Gumbels with a common scale is Logistic. With seasonal and non-seasonal ordinates,
Closed form: no simulation, no bandwidth, no kernel. (The paper writes ; the course writes .)
Prewhiten first with a BIC-chosen non-seasonal ARIMA, so ordinary autocorrelation cannot colour the non-seasonal maximum; standardize to unit variance, so .
Deeper Dive · not assessable
Most shops run a test from one tradition and a filter from another, so the filter removes an object the test never measured. The requirement here: the adjustment removes exactly the excess the test detects, so re-running the test on the output fails to reject by construction. Stochastic spectral imputation (SSI), in three moves:
Deeper Dive · not assessable Classic SSI on UNRATENSA, 1960–2019, replayed from precomputed freqseas draws. Press Adjust.
Back to the series Module 4 handed us. Its ARMA(1,2) on the published UNRATE showed serial correlation at the seasonal lags — a verdict on that fit. The series it was fitted to is the output of an adjustment procedure, so the course’s name for that leftover is residual seasonality: the procedure’s own serial correlation, showing up in the data it published. The seasonal ARMA terms of ARIMA are what absorbed it.
The sign says which way. and its echoes (, ) are negative. Under a non-seasonal ARMA that is consistent with the published series carrying too little variation at the seasonal frequencies — a seasonal deficit, the signature of an adjustment that removed slightly more than the seasonal component. A dip, not a peak.
Two honest limits. This is consistent with an adjustment-side explanation, not a claim about how the BLS runs its procedure. And the numbers do not establish why the dip is there.
Same series, two fits. The left picture asked Module 4’s question; the right one answers it — seasonal band on, seasonal lags inside the band, Ljung-Box passing at both horizons. When someone asks what residual diagnostics are for, hand them this pair.
— and the seasonal block, written out, is the same structure at lag , multiplied in:
| Knob | Module | State |
|---|---|---|
| , — level and trend | L1, L2 | on |
| , — the base band | L3, L4 | on |
| — differencing at the base frequency | L2 | on |
| , , — the seasonal band | L5 | on |
| L1 | on |
The fitting toolkit built since Module 1 is complete. From here on, every module is about what you do with a fitted model, or about lifting it to several variables, or about letting the variance have a master equation of its own.
Module 5 hands Module 6 a diagnostic-clean univariate model — the ARIMA on UNRATE that passed Ljung-Box at both horizons.
You have a model. You have checked it. Now the question is what you use it for.
A forecast is a conditional mean plus honest uncertainty; classical ARMA gives you both.
This uncounted support slide keeps both widget factories in the document. Use the RevealJS menu to return to a widget if a browser reload interrupts an interaction.
// Payload cell for makeSSI: owned by the including document because Quarto resolves
// FileAttachment relative to THIS file, not the partial (widgets/README.md).
ssiData = FileAttachment("cache/ssi_unratensa.json").json()// Unique-id generator in its own cell: a factory may not reference its own name (that
// is a circular definition in OJS). Same idiom as Module 3's nextUid and Module 4's m4Uid.
misspecUid = (function () { let n = 0; return () => ++n; })()// The numeric core. Everything between the BEGIN and END markers is byte-identical to
// slides/module_05/widgets/misspec_core.mjs (misspec_check.mjs asserts it). Edit one
// copy, then paste it over the other.
misspecCore = (function () {
// ===================== misspec core BEGIN =====================
// Numeric core for the Module 5 misspecification widget (makeMisspec; decisions 50, 56).
// Plain ES2017, no imports, no globals, no DOM. Course sign convention throughout
// (overview/notation_dictionary.md): eps_t is the DGP innovation, e_t the residual.
//
// Contents: RNG kit (mulberry32, hashSeed, normF); simARMA; sampleACF; solveLinear;
// olsLags; hannanRissanen; cssResid; cssJacobian; lmCSS; fitCSS (conditional sum of
// squares for ARMA(p, q) with a mean); gammln / gammaPQ / chisqUpper; ljungBox.
// --- RNG kit, ported verbatim from slides/module_03/module_03_slides.qmd (fpk) ---
const mulberry32 = a => () => {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const hashSeed = a => {
a = a >>> 0;
a = Math.imul(a ^ (a >>> 16), 2246822507);
a = Math.imul(a ^ (a >>> 13), 3266489909);
return (a ^ (a >>> 16)) >>> 0;
};
const normF = rng => () => {
let u = 0, v = 0;
while (u === 0) u = rng();
while (v === 0) v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
// --- Simulator: Module 3's simARMA with optional single seasonal AR / MA coefficients at
// lag s (additive). The widget no longer uses the seasonal arguments (decision 56); with
// Phi = Theta = 0 this is exactly fpk.simARMA:
// y_t = sum_j phi_j y_{t-j} + sum_l theta_l eps_{t-l} + eps_t, eps_t ~ N(0, sigma^2)
// Zero-mean DGP; burn-in default 300.
function simARMA(phi, theta, T, seed, sigma, burn, Phi, Theta, s) {
sigma = (sigma == null) ? 1 : sigma;
burn = (burn == null) ? 300 : burn;
Phi = (Phi == null) ? 0 : Phi;
Theta = (Theta == null) ? 0 : Theta;
s = (s == null) ? 12 : s;
const nrm = normF(mulberry32(hashSeed(seed)));
const p = phi.length, q = theta.length, n = T + burn;
const eps = new Float64Array(n), y = new Float64Array(n);
for (let t = 0; t < n; t++) eps[t] = sigma * nrm();
for (let t = 0; t < n; t++) {
let v = eps[t];
for (let i = 1; i <= p; i++) if (t - i >= 0) v += phi[i - 1] * y[t - i];
for (let j = 1; j <= q; j++) if (t - j >= 0) v += theta[j - 1] * eps[t - j];
if (Phi !== 0 && t - s >= 0) v += Phi * y[t - s];
if (Theta !== 0 && t - s >= 0) v += Theta * eps[t - s];
y[t] = v;
}
return Array.from(y.slice(burn));
}
// --- Sample ACF, divisor T (biased), demeaned by the sample mean — matches
// stats::acf(), which is what Box.test() calls.
function sampleACF(y, K) {
const T = y.length;
let m = 0; for (let i = 0; i < T; i++) m += y[i]; m /= T;
const g = new Float64Array(K + 1);
for (let k = 0; k <= K; k++) {
let s = 0;
for (let t = k; t < T; t++) s += (y[t] - m) * (y[t - k] - m);
g[k] = s / T;
}
const r = new Array(K + 1);
for (let k = 0; k <= K; k++) r[k] = g[0] === 0 ? 0 : g[k] / g[0];
return r;
}
// --- Gaussian elimination with partial pivoting.
// A: array of n rows (each an array of n numbers), b: length-n array. Both are copied.
function solveLinear(A, b) {
const n = b.length;
const M = A.map((row, i) => row.slice().concat([b[i]]));
for (let c = 0; c < n; c++) {
let piv = c;
for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[piv][c])) piv = r;
if (Math.abs(M[piv][c]) < 1e-300) throw new Error("solveLinear: singular system");
if (piv !== c) { const tmp = M[c]; M[c] = M[piv]; M[piv] = tmp; }
for (let r = c + 1; r < n; r++) {
const f = M[r][c] / M[c][c];
if (f === 0) continue;
for (let k = c; k <= n; k++) M[r][k] -= f * M[c][k];
}
}
const x = new Array(n).fill(0);
for (let r = n - 1; r >= 0; r--) {
let s = M[r][n];
for (let k = r + 1; k < n; k++) s -= M[r][k] * x[k];
x[r] = s / M[r][r];
}
return x;
}
// --- OLS of y_t on an intercept and the lags listed in `lags` (e.g. [1, 2] for AR(2)).
// The first max(lags) observations are dropped, so the residual vector has n = T - max(lags)
// entries. Returns { coef, resid, n, lags } with coef[0] the intercept and coef[i] the
// coefficient on y_{t - lags[i-1]}. lags = [] is the intercept-only fit.
function olsLags(y, lags) {
const T = y.length, m = lags.length, maxlag = m ? Math.max.apply(null, lags) : 0;
const n = T - maxlag, k = m + 1;
const XtX = Array.from({ length: k }, () => new Array(k).fill(0));
const Xty = new Array(k).fill(0);
const row = new Array(k);
for (let t = maxlag; t < T; t++) {
row[0] = 1;
for (let i = 0; i < m; i++) row[i + 1] = y[t - lags[i]];
for (let a = 0; a < k; a++) {
Xty[a] += row[a] * y[t];
for (let b = a; b < k; b++) XtX[a][b] += row[a] * row[b];
}
}
for (let a = 0; a < k; a++) for (let b = 0; b < a; b++) XtX[a][b] = XtX[b][a];
const coef = solveLinear(XtX, Xty);
const resid = new Array(n);
for (let t = maxlag; t < T; t++) {
let f = coef[0];
for (let i = 0; i < m; i++) f += coef[i + 1] * y[t - lags[i]];
resid[t - maxlag] = y[t] - f;
}
return { coef, resid, n, lags: lags.slice() };
}
// --- Hannan-Rissanen start values for ARMA(p, q) with a mean. Step 1: a long AR(m) by OLS
// gives proxy innovations (zero before observation m). Step 2: OLS of y_t on a constant,
// p lags of y and q lags of the proxies over t >= m + q. Deterministic; m fixed (>= p).
function hannanRissanen(y, p, q, m) {
const T = y.length;
m = Math.max(m || 10, p);
const longLags = []; for (let j = 1; j <= m; j++) longLags.push(j);
const longAR = olsLags(y, longLags);
const eh = new Array(T).fill(0);
for (let t = m; t < T; t++) eh[t] = longAR.resid[t - m];
const k = 1 + p + q, t0 = m + q;
const XtX = Array.from({ length: k }, () => new Array(k).fill(0));
const Xty = new Array(k).fill(0);
const row = new Array(k);
for (let t = t0; t < T; t++) {
row[0] = 1;
for (let j = 1; j <= p; j++) row[j] = y[t - j];
for (let l = 1; l <= q; l++) row[p + l] = eh[t - l];
for (let a = 0; a < k; a++) {
Xty[a] += row[a] * y[t];
for (let b = a; b < k; b++) XtX[a][b] += row[a] * row[b];
}
}
for (let a = 0; a < k; a++) for (let b = 0; b < a; b++) XtX[a][b] = XtX[b][a];
let coef;
try { coef = solveLinear(XtX, Xty); } catch (err) { return null; }
const phi = coef.slice(1, 1 + p), theta = coef.slice(1 + p);
let sphi = 0; for (let j = 0; j < p; j++) sphi += phi[j];
let ybar = 0; for (let t = 0; t < T; t++) ybar += y[t]; ybar /= T;
const mu = Math.abs(1 - sphi) > 1e-8 ? coef[0] / (1 - sphi) : ybar;
return { mu, phi, theta };
}
// --- Conditional-sum-of-squares residuals of ARMA(p, q) with mean mu:
// e_t = (y_t - mu) - sum_j phi_j (y_{t-j} - mu) - sum_l theta_l e_{t-l}, t = p ... T-1,
// with pre-sample residuals zero (e_{t-l} = 0 whenever t - l < p). This is the recursion
// R's arima(method = "CSS") minimises (stats:::C_ARIMA_CSS with ncond = p), so the residual
// vector and the optimum coincide with R's. Returns { e, sse, n } with e of length T - p.
function cssResid(y, mu, phi, theta) {
const T = y.length, p = phi.length, q = theta.length, n = T - p;
const e = new Array(n);
let sse = 0;
for (let t = p; t < T; t++) {
let v = y[t] - mu;
for (let j = 1; j <= p; j++) v -= phi[j - 1] * (y[t - j] - mu);
for (let l = 1; l <= q; l++) { const s = t - p - l; if (s >= 0) v -= theta[l - 1] * e[s]; }
e[t - p] = v; sse += v * v;
}
return { e, sse, n };
}
// Analytic Jacobian of the residual vector (n x k, k = 1 + p + q), the same recursions
// differentiated; derivative of a pre-sample residual is zero:
// de_t/dmu = -(1 - sum_j phi_j) - sum_l theta_l de_{t-l}/dmu
// de_t/dphi_j = -(y_{t-j} - mu) - sum_l theta_l de_{t-l}/dphi_j
// de_t/dtheta_l = -e_{t-l} - sum_m theta_m de_{t-m}/dtheta_l
function cssJacobian(y, mu, phi, theta, e) {
const T = y.length, p = phi.length, q = theta.length, n = T - p, k = 1 + p + q;
const J = Array.from({ length: n }, () => new Array(k).fill(0));
let sphi = 0; for (let j = 0; j < p; j++) sphi += phi[j];
for (let t = p; t < T; t++) {
const r = t - p, row = J[r];
row[0] = -(1 - sphi);
for (let j = 1; j <= p; j++) row[j] = -(y[t - j] - mu);
for (let l = 1; l <= q; l++) { const s = r - l; row[p + l] = s >= 0 ? -e[s] : 0; }
for (let l = 1; l <= q; l++) {
const s = r - l; if (s < 0) continue;
const th = theta[l - 1], prev = J[s];
for (let c = 0; c < k; c++) row[c] -= th * prev[c];
}
}
return J;
}
// --- Levenberg-Marquardt on the CSS sum of squares from one start. Deterministic: fixed
// damping schedule (lambda / 10 on success, x 10 on failure), fixed iteration budget, no
// randomness. Stops when a step reduces the sum of squares by less than 1e-12 relative or
// moves no coefficient by more than 1e-10, or when no damping up to 1e12 yields a descent
// step (a stationary point to machine precision). Returns null if the start is unusable.
function lmCSS(y, start, maxIter) {
maxIter = maxIter || 300;
const p = start.phi.length, q = start.theta.length, k = 1 + p + q;
const unpack = v => ({ mu: v[0], phi: v.slice(1, 1 + p), theta: v.slice(1 + p) });
let x = [start.mu].concat(start.phi, start.theta);
let cur = unpack(x), R = cssResid(y, cur.mu, cur.phi, cur.theta);
if (!isFinite(R.sse)) return null;
let lambda = 1e-3, iters = 0, converged = false, stationary = false;
for (; iters < maxIter && !converged; iters++) {
const J = cssJacobian(y, cur.mu, cur.phi, cur.theta, R.e);
const A = Array.from({ length: k }, () => new Array(k).fill(0)), g = new Array(k).fill(0);
for (let r = 0; r < R.n; r++) {
const row = J[r], er = R.e[r];
for (let a = 0; a < k; a++) {
g[a] += row[a] * er;
for (let b = a; b < k; b++) A[a][b] += row[a] * row[b];
}
}
for (let a = 0; a < k; a++) for (let b = 0; b < a; b++) A[a][b] = A[b][a];
let accepted = false;
for (let tries = 0; tries < 40; tries++) {
const M = A.map((row, i) => row.map((v, j) => i === j ? v * (1 + lambda) + 1e-12 : v));
let d;
try { d = solveLinear(M, g.map(v => -v)); } catch (err) { lambda *= 10; continue; }
const xn = x.map((v, i) => v + d[i]);
const nxt = unpack(xn), Rn = cssResid(y, nxt.mu, nxt.phi, nxt.theta);
if (isFinite(Rn.sse) && Rn.sse < R.sse) {
const drop = R.sse - Rn.sse;
let step = 0; for (let i = 0; i < k; i++) step = Math.max(step, Math.abs(d[i]));
x = xn; cur = nxt; R = Rn; lambda = Math.max(lambda / 10, 1e-15); accepted = true;
if (drop <= 1e-12 * R.sse || step <= 1e-10) converged = true;
break;
}
lambda *= 10;
if (lambda > 1e12) break;
}
if (!accepted) { stationary = true; converged = true; }
}
return { mu: cur.mu, phi: cur.phi, theta: cur.theta, e: R.e, sse: R.sse, n: R.n, iters, converged, stationary };
}
// --- CSS fit of ARMA(p, q) with a mean.
// q = 0: the problem is linear in (alpha, phi) with alpha = mu (1 - sum phi), so the fit is
// plain OLS with intercept (olsLags) and mu = alpha / (1 - sum phi); residuals are
// identical to the CSS recursion at that optimum.
// q > 0: Levenberg-Marquardt from two deterministic starts, Hannan-Rissanen and R's own
// start (mu = ybar, phi = theta = 0); the lower sum of squares wins.
// Returns { p, q, mu, phi, theta, resid, n, sse, iters, converged, start, method, starts }
// with resid of length T - p (t = p ... T-1) and n = T - p.
function fitCSS(y, p, q) {
const T = y.length;
let ybar = 0; for (let t = 0; t < T; t++) ybar += y[t]; ybar /= T;
if (q === 0) {
const lags = []; for (let j = 1; j <= p; j++) lags.push(j);
const f = olsLags(y, lags);
const phi = f.coef.slice(1);
let sphi = 0; for (let j = 0; j < p; j++) sphi += phi[j];
const mu = Math.abs(1 - sphi) > 1e-12 ? f.coef[0] / (1 - sphi) : ybar;
let sse = 0; for (let i = 0; i < f.n; i++) sse += f.resid[i] * f.resid[i];
return { p, q, mu, phi, theta: [], resid: f.resid, n: f.n, sse, iters: 0, converged: true,
start: "ols", method: "ols", starts: [{ name: "ols", sse }] };
}
const zeros = m => { const v = new Array(m); for (let i = 0; i < m; i++) v[i] = 0; return v; };
const starts = [];
const hr = hannanRissanen(y, p, q);
if (hr) starts.push({ name: "hr", mu: hr.mu, phi: hr.phi, theta: hr.theta });
starts.push({ name: "zero", mu: ybar, phi: zeros(p), theta: zeros(q) });
let best = null; const tried = [];
for (let i = 0; i < starts.length; i++) {
const s = starts[i], r = lmCSS(y, s);
if (!r) { tried.push({ name: s.name, sse: NaN }); continue; }
tried.push({ name: s.name, sse: r.sse, iters: r.iters, converged: r.converged });
if (!best || r.sse < best.sse) { best = r; best.start = s.name; }
}
return { p, q, mu: best.mu, phi: best.phi, theta: best.theta, resid: best.e, n: best.n, sse: best.sse,
iters: best.iters, converged: best.converged, start: best.start, method: "css-lm", starts: tried };
}
// --- Chi-square upper tail via the regularized incomplete gamma function.
// Standard numerical recipe: Lanczos ln-gamma, series for x < a + 1, continued
// fraction (modified Lentz) otherwise. Double-precision tolerances.
function gammln(xx) {
const cof = [57.1562356658629235, -59.5979603554754912, 14.1360979747417471,
-0.491913816097620199, 0.339946499848118887e-4, 0.465236289270485756e-4,
-0.983744753048795646e-4, 0.158088703224912494e-3, -0.210264441724104883e-3,
0.217439618115212643e-3, -0.164318106536763890e-3, 0.844182239838527433e-4,
-0.261908384015814087e-4, 0.368991826595316234e-5];
let x = xx, y = xx;
let tmp = x + 5.24218750000000000;
tmp = (x + 0.5) * Math.log(tmp) - tmp;
let ser = 0.999999999999997092;
for (let j = 0; j < 14; j++) ser += cof[j] / ++y;
return tmp + Math.log(2.5066282746310005 * ser / x);
}
function gammaSeries(a, x) { // P(a, x), converges fast for x < a + 1
const gln = gammln(a);
let ap = a, sum = 1 / a, del = sum;
for (let n = 0; n < 5000; n++) {
ap += 1; del *= x / ap; sum += del;
if (Math.abs(del) < Math.abs(sum) * 1e-16) break;
}
return sum * Math.exp(-x + a * Math.log(x) - gln);
}
function gammaContFrac(a, x) { // Q(a, x), converges fast for x >= a + 1
const gln = gammln(a), FPMIN = 1e-300, EPS = 1e-16;
let b = x + 1 - a, c = 1 / FPMIN, d = 1 / b, h = d;
for (let i = 1; i < 5000; i++) {
const an = -i * (i - a);
b += 2;
d = an * d + b; if (Math.abs(d) < FPMIN) d = FPMIN;
c = b + an / c; if (Math.abs(c) < FPMIN) c = FPMIN;
d = 1 / d;
const del = d * c; h *= del;
if (Math.abs(del - 1) < EPS) break;
}
return Math.exp(-x + a * Math.log(x) - gln) * h;
}
// Regularized lower (P) and upper (Q) incomplete gamma, P + Q = 1.
function gammaPQ(a, x) {
if (!(x > 0)) return { P: 0, Q: 1 };
if (x < a + 1) { const P = gammaSeries(a, x); return { P, Q: 1 - P }; }
const Q = gammaContFrac(a, x); return { P: 1 - Q, Q };
}
// Upper-tail probability of a chi-square with df degrees of freedom: pchisq(x, df, lower.tail = FALSE).
function chisqUpper(x, df) { return gammaPQ(df / 2, x / 2).Q; }
// --- Ljung-Box on a residual vector: Q(h) = n(n+2) sum_{k=1}^{h} rho_k^2 / (n - k),
// compared with chi-square on h - fitdf degrees of freedom. Matches
// Box.test(e, lag = h, type = "Ljung-Box", fitdf = fitdf) term for term.
function ljungBox(e, h, fitdf) {
fitdf = fitdf || 0;
const n = e.length, r = sampleACF(e, h);
let Q = 0;
for (let k = 1; k <= h; k++) Q += r[k] * r[k] / (n - k);
Q *= n * (n + 2);
const df = h - fitdf;
return { Q, df, p: df > 0 ? chisqUpper(Q, df) : NaN, h, fitdf, n };
}
// --- The widget's DGP menu (decision 56). Shared coefficients: phi = (0.5, 0.3) has real
// inverse AR roots 0.85 and -0.35; theta = (0.5, 0.4) has a complex pair of inverse MA roots
// of modulus 0.63, so the ARMA(2,2) has no near-cancelling AR/MA pair. Zero mean, sigma = 1.
// Fingerprints at T = 500: AR(2) PACF cuts off at 2 (rho_1 = 0.71, rho_2 = 0.66); MA(2) ACF
// cuts off at 2 (rho_1 = 0.50, rho_2 = 0.28); ARMA(2,2) tails off on both sides.
const MISSPEC_DGPS = {
ar2: { key: "ar2", label: "AR(2)", p: 2, q: 0, phi: [0.5, 0.3], theta: [],
eq: "yₜ = 0.5·yₜ₋₁ + 0.3·yₜ₋₂ + εₜ" },
ma2: { key: "ma2", label: "MA(2)", p: 0, q: 2, phi: [], theta: [0.5, 0.4],
eq: "yₜ = εₜ + 0.5·εₜ₋₁ + 0.4·εₜ₋₂" },
arma22: { key: "arma22", label: "ARMA(2,2)", p: 2, q: 2, phi: [0.5, 0.3], theta: [0.5, 0.4],
eq: "yₜ = 0.5·yₜ₋₁ + 0.3·yₜ₋₂ + εₜ + 0.5·εₜ₋₁ + 0.4·εₜ₋₂" }
};
// Default base seed: the first base >= 6376 whose draw 1 tells every beat cleanly (see README).
const MISSPEC_DEFAULT_SEED = 6383;
const MISSPEC_CORE = { mulberry32, hashSeed, normF, simARMA, sampleACF, solveLinear, olsLags,
hannanRissanen, cssResid, cssJacobian, lmCSS, fitCSS,
gammln, gammaPQ, chisqUpper, ljungBox,
DGPS: MISSPEC_DGPS, DEFAULT_SEED: MISSPEC_DEFAULT_SEED };
// ===================== misspec core END =====================
return MISSPEC_CORE;
})()makeMisspec = function (opts) {
opts = opts || {};
const C = misspecCore;
const uid = misspecUid();
const T = opts.T == null ? 500 : Number(opts.T);
const K = opts.K == null ? 36 : Number(opts.K);
const H = opts.h == null ? 24 : Number(opts.h);
const PMAX = 3, QMAX = 3, BURN = 300, ALPHA = 0.05;
const baseSeed = opts.seed0 == null ? C.DEFAULT_SEED : Number(opts.seed0) >>> 0;
const DGPS = C.DGPS, KEYS = Object.keys(DGPS);
const clampOrd = (v, dflt, mx) => v == null ? dflt : Math.max(0, Math.min(mx, Number(v) | 0));
const defaults = {
dgp: KEYS.indexOf(opts.dgp0) >= 0 ? opts.dgp0 : "arma22",
p: clampOrd(opts.p0, 2, PMAX),
q: clampOrd(opts.q0, 0, QMAX),
draw: opts.draw0 == null ? 1 : Math.max(1, Number(opts.draw0) | 0)
};
let dgpKey = defaults.dgp, p = defaults.p, q = defaults.q, draw = defaults.draw;
// Reseed steps a counter; the series seed is a hash of it, so every student sees the
// same draw 1, draw 2, ... in the same order.
const seedOf = d => C.hashSeed((baseSeed + d) >>> 0);
// ---- tiny DOM kit (Module 4's m4k, inlined so the partial is self-contained) ----
const NS = "http://www.w3.org/2000/svg";
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const sEl = (tag, attrs, text) => { const n = document.createElementNS(NS, tag); Object.keys(attrs || {}).forEach(k => n.setAttribute(k, String(attrs[k]))); if (text != null) n.textContent = text; return n; };
const button = (label, action, cls) => { const b = el("button", "btn" + (cls ? " " + cls : ""), label); b.type = "button"; b.dataset.action = action; return b; };
const clear = n => { while (n.firstChild) n.removeChild(n.firstChild); };
const fmt = (x, d) => { const s = Number(x).toFixed(d == null ? 2 : d); return s === "-0." + "0".repeat(d == null ? 2 : d) ? s.slice(1) : s; };
const SUB = ["₀", "₁", "₂", "₃", "₄", "₅", "₆", "₇", "₈", "₉"];
const sub = n => String(n).split("").map(ch => SUB[+ch]).join("");
const HAT = "̂";
// ---- DOM ----
const root = el("div", "widget card misspec");
root.dataset.widget = "misspec"; root.dataset.uid = String(uid);
const bar1 = el("div", "wbar");
const grpD = el("div", "grp"); grpD.appendChild(el("span", "lab", "the data (DGP)"));
const dgpBtn = {};
KEYS.forEach(k => { const d = DGPS[k]; const b = button(d.label, "dgp-" + k); b.title = d.eq; dgpBtn[k] = b; grpD.appendChild(b); });
const reseedB = button("Reseed", "reseed");
const drawTag = el("span", "tag");
bar1.append(grpD, el("div", "sep"), reseedB, drawTag);
const bar2 = el("div", "wbar");
const grpF = el("div", "grp"); grpF.appendChild(el("span", "lab", "your model"));
const mkSlider = (label, mx, val, action) => {
const ctl = el("label", "ctl"); ctl.append(label + " ");
const s = document.createElement("input"); s.type = "range"; s.min = 0; s.max = mx; s.step = 1; s.value = val; s.dataset.action = action;
const out = el("span", "cval"); ctl.append(s, out); return { ctl, s, out };
};
const pC = mkSlider("AR order p =", PMAX, p, "p");
const qC = mkSlider("MA order q =", QMAX, q, "q");
grpF.append(pC.ctl, qC.ctl);
const resetB = button("Reset", "reset", "ghost");
bar2.append(grpF, el("div", "sep"), resetB);
const chart = sEl("svg", { width: 1120, height: 320, viewBox: "0 0 1120 320", role: "img",
"aria-label": "Residual autocorrelation function to lag 36 with plus or minus two over root n bands" });
const readout = el("div", "readout");
const caveat = el("div", "caveat");
caveat.innerHTML = "Fits here are conditional sum of squares; R’s <code>Arima()</code> uses maximum likelihood — the residual story is the same.";
root.append(bar1, bar2, chart, readout, caveat);
// ---- compute ----
function state() {
const d = DGPS[dgpKey];
const seed = seedOf(draw);
const y = C.simARMA(d.phi, d.theta, T, seed, 1, BURN);
const fit = C.fitCSS(y, p, q);
const r = C.sampleACF(fit.resid, K);
const band = 2 / Math.sqrt(fit.n);
const lb = C.ljungBox(fit.resid, H, p + q);
const outs = []; for (let k = 1; k <= K; k++) if (Math.abs(r[k]) > band) outs.push(k);
let kmax = 0; for (let i = 0; i < outs.length; i++) if (kmax === 0 || Math.abs(r[outs[i]]) > Math.abs(r[kmax])) kmax = outs[i];
return { d, seed, fit, r, band, lb, outs, kmax };
}
// ---- chart ----
function drawACF(st) {
clear(chart);
// Type inside the viewBox: every text element is >= 22 units. The deck caps the chart at
// 1500 CSS px; reveal.js scales the 1920 canvas by (1 - margin) at 1080p, 0.98 for the
// Module 5 deck (margin 0.02), so one viewBox unit is 1500 * 0.98 / 1120 = 1.3125 screen
// px and 22 units render at 28.9 px. Even at Quarto's default margin 0.1 it is 26.5 px.
const W = 1120, Hh = 320, L = 72, R = 26, Tm = 44, B = 46;
const iw = W - L - R, ih = Hh - Tm - B, YMAX = 0.8;
const clampY = v => Math.max(-YMAX, Math.min(YMAX, v));
const sy = v => Tm + (YMAX - clampY(v)) / (2 * YMAX) * ih;
const slot = iw / K, sx = k => L + slot * (k - 0.5);
const bw = Math.min(16, slot * 0.5);
const c = { ink: "#16202b", muted: "#5d6b78", line: "#d7dee5", soft: "#e7ecf1", shade: "#eef2f6",
grey: "#8a94a0", blue: "#2c6fbb", orange: "#e4572e", navy: "#0b1f3a" };
// band
chart.appendChild(sEl("rect", { x: L, y: sy(st.band), width: iw, height: sy(-st.band) - sy(st.band), fill: c.shade }));
[-0.5, 0.5].forEach(v => chart.appendChild(sEl("line", { x1: L, x2: L + iw, y1: sy(v), y2: sy(v), stroke: c.soft, "stroke-width": 1 })));
[st.band, -st.band].forEach(v => chart.appendChild(sEl("line", { x1: L, x2: L + iw, y1: sy(v), y2: sy(v), stroke: c.grey, "stroke-width": 1.5, "stroke-dasharray": "7 6" })));
// zero line
chart.appendChild(sEl("line", { x1: L, x2: L + iw, y1: sy(0), y2: sy(0), stroke: c.line, "stroke-width": 1.5 }));
// y labels
[-0.5, 0, 0.5].forEach(v => chart.appendChild(sEl("text", { x: L - 12, y: sy(v) + 8, "text-anchor": "end", "font-size": 22, fill: c.muted }, fmt(v, 1))));
// bars
const outSet = {}; st.outs.forEach(k => { outSet[k] = true; });
for (let k = 1; k <= K; k++) {
const v = st.r[k], out = !!outSet[k];
chart.appendChild(sEl("line", { x1: sx(k), x2: sx(k), y1: sy(0), y2: sy(v), stroke: out ? c.orange : c.blue,
"stroke-width": bw, "stroke-linecap": "butt", "data-lag": k, "data-rho": v.toFixed(4) }));
if (Math.abs(v) > YMAX) chart.appendChild(sEl("text", { x: sx(k), y: v > 0 ? Tm - 6 : Tm + ih + 20, "text-anchor": "middle", "font-size": 22, fill: c.orange }, "▲"));
}
// annotate the largest excursions (at most three); tall bars get the label beside the
// tip so it never runs into the chart title or the x labels
st.outs.slice().sort((a, b) => Math.abs(st.r[b]) - Math.abs(st.r[a])).slice(0, 3).forEach(k => {
const v = st.r[k], tall = Math.abs(v) > 0.55;
const attrs = tall
? { x: sx(k) + bw / 2 + 6, y: sy(v) + (v >= 0 ? 18 : -6), "text-anchor": "start" }
: { x: sx(k), y: v >= 0 ? sy(v) - 8 : sy(v) + 24, "text-anchor": "middle" };
attrs["font-size"] = 22; attrs.fill = c.orange; attrs["font-weight"] = 700;
chart.appendChild(sEl("text", attrs, fmt(v, 2)));
});
// x labels; 1, 2, 3 are where AR/MA truncation shows, 12/24/36 stay marked for the hinge
[1, 2, 3, 6, 12, 18, 24, 30, 36].filter(k => k <= K).forEach(k => {
const strongLag = k <= 3 || k % 12 === 0;
chart.appendChild(sEl("text", { x: sx(k), y: Hh - 12, "text-anchor": "middle", "font-size": strongLag ? 26 : 22,
fill: strongLag ? c.navy : c.muted, "font-weight": strongLag ? 700 : 400 }, String(k)));
});
// titles
chart.appendChild(sEl("text", { x: L, y: 28, "font-size": 26, fill: c.ink }, "residual ACF ρ" + HAT + "ₑ(k) by lag k"));
chart.appendChild(sEl("text", { x: L + iw, y: 28, "text-anchor": "end", "font-size": 22, fill: c.muted },
"±2/√n = ±" + fmt(st.band, 3) + " (n = " + st.fit.n + ")"));
}
// ---- words ----
function modelName(pp, qq) {
if (pp === 0 && qq === 0) return "ARMA(0,0), mean only";
if (qq === 0) return "AR(" + pp + ")";
if (pp === 0) return "MA(" + qq + ")";
return "ARMA(" + pp + "," + qq + ")";
}
// Every readout string is kept short enough to stay on ONE line at the deck's width
// (about 150 characters at the deck's 0.82rem readout), so the widget height is fixed.
function coefLine(st) {
const parts = ["μ" + HAT + " = " + fmt(st.fit.mu, 2)];
st.fit.phi.forEach((v, i) => parts.push("φ" + HAT + sub(i + 1) + " = " + fmt(v, 2)));
st.fit.theta.forEach((v, i) => parts.push("θ" + HAT + sub(i + 1) + " = " + fmt(v, 2)));
return parts.join(", ");
}
function fitNote(st) {
if (st.fit.method === "ols") return "exact OLS";
return "LM " + st.fit.iters + " steps, " + (st.fit.start === "hr" ? "HR" : "zero") + " start" +
(st.fit.converged ? "" : ", budget reached (flat objective)");
}
// Verdict: Ljung-Box decides adequacy; the largest excursion is named. It says only that
// structure is missing: which block is missing is read off the fitted orders against the
// DGP's, so it is named in the why() line under the "DGP known here" label (in practice
// the residual ACF/PACF shape only proposes a candidate).
function missingBlock(st) {
const underAR = p < st.d.p, underMA = q < st.d.q;
return underAR && underMA ? "AR and MA structure" : underAR ? "AR structure" : underMA ? "MA structure" : null;
}
function verdict(st) {
const d = st.d, rej = st.lb.p < ALPHA;
const exact = p === d.p && q === d.q;
const over = p >= d.p && q >= d.q && p + q > d.p + d.q;
const largest = st.kmax ? ", largest at lag " + st.kmax + " (ρ" + HAT + "ₑ = " + fmt(st.r[st.kmax], 2) + ")" : "";
const noise = st.outs.length ? " The excursion at lag " + st.outs.slice(0, 3).join(", ") + " is sampling noise (1 lag in 20)." : "";
if (rej) {
if (missingBlock(st)) return { warn: true, text: "Residuals show serial correlation" + largest + " → this fit is missing structure." };
return { warn: true, text: "Serial correlation" + largest + " although the orders cover the DGP: a 5% false alarm or an off-optimum fit — reseed." };
}
if (over) return { warn: false, text: "Residuals look like white noise — adequate, but you are paying for parameters the data do not need (Module 4)." };
if (exact) return { warn: false, text: "Residuals look like white noise — this fit is adequate." + noise };
return { warn: false, text: "Residuals look like white noise — adequate though not the DGP’s orders: a finite AR or MA approximates the other side (duality)." };
}
// The why() line carries the "DGP known here" label, so it is where the missing block is named.
function why(st) {
const miss = missingBlock(st);
return (miss ? "missing " + miss + " — " : "") + whyBody(st);
}
function whyBody(st) {
const k = st.d.key;
const flat = "; a cancelling root pair flattens the objective: estimates wander, residuals do not.";
if (k === "ar2") {
if (p < 2 && q === 0) return "an AR(2) needs two lags of y; AR(" + p + ") leaves the second lag’s memory in eₜ, so the residual ACF still decays.";
if (p < 2) return "an AR(2) is an MA(∞) with slowly fading weights; MA(" + q + ") truncates that series, so structure remains (Module 3 duality).";
if (q === 0) return p === 2 ? "AR(2) is the DGP; eₜ is εₜ up to estimation error." : "AR(2) nests inside AR(" + p + "); the extra lag estimates ≈ 0 and costs a parameter (Module 4).";
return "the MA terms explain nothing" + flat;
}
if (k === "ma2") {
if (p === 0 && q < 2) return "an MA(2) has exactly two non-zero autocorrelations; MA(" + q + ") leaves lag " + (q + 1) + " in eₜ.";
if (q < 2) return "an MA(2) is an AR(∞) with weights fading like 0.63ᵏ; AR(" + p + ") truncates it, so the leftover shrinks as p grows (duality).";
if (p === 0) return q === 2 ? "MA(2) is the DGP; eₜ is εₜ up to estimation error." : "MA(2) nests inside MA(" + q + "); the extra term estimates ≈ 0 and costs a parameter (Module 4).";
return "the AR terms explain nothing" + flat;
}
if (q === 0) return "AR(" + p + ") truncates the AR(∞) form, leaving MA structure in eₜ at lags 1–2.";
if (p === 0) return "MA(" + q + ") truncates the MA(∞) form, leaving slowly fading autocorrelation in eₜ.";
if (p < 2 || q < 2) return "the leftover is the truncated remainder of the missing term (Module 3 duality).";
if (p === 2 && q === 2) return "ARMA(2,2) is the DGP; eₜ is εₜ up to estimation error.";
return "the extra terms explain nothing (≈ 0, a parameter each)" + flat;
}
// ---- render ----
function render() {
Object.keys(dgpBtn).forEach(k => dgpBtn[k].classList.toggle("on", k === dgpKey));
pC.s.value = p; pC.out.textContent = String(p);
qC.s.value = q; qC.out.textContent = String(q);
drawTag.textContent = "draw " + draw;
const st = state();
drawACF(st);
const v = verdict(st);
readout.innerHTML =
"<div><b>Fitted:</b> " + modelName(p, q) + " by CSS · " + coefLine(st) +
" · n = " + st.fit.n + " · " + fitNote(st) + " · DGP " + st.d.label + ", draw " + draw + "</div>" +
"<div><b>Ljung-Box</b> Q(" + H + ") = <b>" + fmt(st.lb.Q, 2) + "</b> · h = " + H + ", fitdf = " + (p + q) +
" · χ²" + sub(st.lb.df) + " · p = <b>" + (st.lb.p < 0.0005 ? "< 0.001" : fmt(st.lb.p, 3)) + "</b> " +
"<span class=\"tag " + (st.lb.p < ALPHA ? "warn" : "good") + "\">" + (st.lb.p < ALPHA ? "reject at 5%: serial correlation" : "fail to reject at 5%") + "</span></div>" +
"<div class=\"verdict\" data-verdict=\"" + (v.warn ? "warn" : "ok") + "\">" + v.text + "</div>" +
"<div class=\"why\">Why (DGP known here, not in practice): " + why(st) + "</div>";
}
// ---- wire ----
Object.keys(dgpBtn).forEach(k => dgpBtn[k].addEventListener("click", () => { dgpKey = k; render(); }));
pC.s.addEventListener("input", () => { p = Number(pC.s.value) | 0; render(); });
qC.s.addEventListener("input", () => { q = Number(qC.s.value) | 0; render(); });
reseedB.addEventListener("click", () => { draw += 1; render(); });
resetB.addEventListener("click", () => { dgpKey = defaults.dgp; p = defaults.p; q = defaults.q; draw = defaults.draw; render(); });
render();
return root;
}// Unique-id generator in its OWN cell: a factory may not reference its own name
// (circular definition in OJS), and each instance needs its own clipPath id.
ssiUid = (function () { let n = 0; return () => ++n; })()// ================================ makeSSI ==================================
// Port of the research deck's makeSurgery (bls_presentation/bls_slides.qmd,
// w4_spec.md s4) to Module 5. Same choreography: setup flash -> slow beats on
// the first ordinate of harmonics 1-3 -> cascade -> closing (published series
// draws in, Delta-hat chip completes). Changes in the port: course notation
// and palette; data supplied by the includer (opts.data); exact AR-recursion
// reconstruction (contract "coeffs_recursion") instead of the windowed
// approximation; the package-default "band" comparison variant, its
// single-step morph, and the KaTeX dependency are gone (readouts are MathML
// and plain HTML, which is what html-math-method: mathml renders elsewhere in
// the deck); a levels/first-differences view replaces levels/log-differences
// because UNRATENSA is adjusted in percentage points; the unadjusted head
// window is shaded and named; the default zoom is the last six years.
makeSSI = function (opts) {
opts = opts || {};
const uid = ssiUid();
const explore = opts.explore !== false;
// ---- data unpack -------------------------------------------------------
const DATA = opts.data;
if (!DATA || !DATA.meta || !DATA.shared || !DATA.variants)
throw new Error("makeSSI: opts.data must be the parsed ssi_unratensa.json payload " +
"(define ssiData = FileAttachment(...).json() in the including document)");
const META = DATA.meta, SH = DATA.shared, VS = DATA.variants;
if (META.contract !== "coeffs_recursion")
throw new Error(`makeSSI: payload contract '${META.contract}' is not 'coeffs_recursion'`);
const LOG = META.transform === "log";
const U = META.units || { scale: 1, suffix: "", digits: 1, level_label: "reconstructed levels" };
const SERIES = META.series || "series", SA_SERIES = META.sa_series || "published SA series";
const T = META.n_dx, t0 = META.t0, NE = META.n_e, NS = META.n_spec;
const AR = (META.stage0 && META.stage0.ar ? META.stage0.ar : []).map(Number);
const P_AR = AR.length;
const ALPHA = SH.evt_before.alpha;
const TG = SH.targets, NT = TG.length;
const OMG = TG.map(t => 2 * Math.PI * t.j / NE); // exact phases from j
const dx0 = Float64Array.from(SH.dx_nsa);
const pgB = SH.pgram_before;
const lvlNSA = SH.level_nsa, lvlSA = SH.level_sa, dates = SH.dates;
const xAnchor = SH.x_nsa[0];
const toLevel = v => LOG ? Math.exp(v) : v;
const fromLevel = v => LOG ? Math.log(v) : v;
const dxSA = new Float64Array(T);
for (let i = 0; i < T; i++) dxSA[i] = fromLevel(lvlSA[i + 1]) - fromLevel(lvlSA[i]);
const defV = VS.find(v => v.is_default);
const NY = SH.benchmark.n_years, YS = SH.benchmark.year_sums_nsa;
const HEAD = META.head_unadjusted || { n_level: t0 };
// play order: slow beats = first ordinate of harmonics 1-3, then the rest
// straight through in exported animation order (deltas are additive, so any
// order reconstructs exactly).
const firstOfH = [];
TG.forEach((t, i) => { if (firstOfH[t.harmonic - 1] === undefined) firstOfH[t.harmonic - 1] = i; });
const slowIdx = firstOfH.slice(0, 3).filter(i => i !== undefined);
const order = slowIdx.concat(TG.map((_, i) => i).filter(i => slowIdx.indexOf(i) < 0));
// harmonic bin extents (grid-index runs of the targets) for shading/labels
const hInfo = [];
TG.forEach(t => {
const h = t.harmonic - 1;
if (!hInfo[h]) hInfo[h] = { label: t.harmonic_label, jmin: t.j, jmax: t.j };
hInfo[h].jmin = Math.min(hInfo[h].jmin, t.j);
hInfo[h].jmax = Math.max(hInfo[h].jmax, t.j);
});
// ---- reconstruction math (exact; see meta.coef_convention) --------------
// One target ordinate k adds the whitened-domain sinusoid
// u_i = Re[(c_re + i c_im) e^{i omega_k (i - (t0-1))}] for 0-based i >= t0-1
// recolored through the whitener's AR recursion started from zero:
// delta_i = u_i + sum_m AR[m] * delta_{i-1-m}.
const applyDelta = (dl, V, k, sign) => {
const cr = sign * V.c_re[k], ci = sign * V.c_im[k], w = OMG[k];
const cw = Math.cos(w), sw = Math.sin(w);
let er = 1, ei = 0;
const hist = new Float64Array(Math.max(P_AR, 1));
for (let i = t0 - 1; i < T; i++) {
let d = cr * er - ci * ei;
for (let m = 0; m < P_AR; m++) d += AR[m] * hist[m];
dl[i] += d;
for (let m = P_AR - 1; m > 0; m--) hist[m] = hist[m - 1];
if (P_AR) hist[0] = d;
const nr = er * cw - ei * sw; ei = er * sw + ei * cw; er = nr;
}
};
const deltaSeries = (V, k) => { const d = new Float64Array(T); applyDelta(d, V, k, 1); return d; };
const xFrom = dl => { // cumulate: x scale, length T+1
const X = new Float64Array(T + 1);
X[0] = xAnchor;
for (let i = 0; i < T; i++) X[i + 1] = X[i] + dl[i];
return X;
};
const benchLevels = X => { // pro-rata per 12-obs year block
const out = new Float64Array(T + 1);
for (let m = 0; m <= T; m++) out[m] = toLevel(X[m]);
for (let y = 0; y < NY; y++) {
let s = 0; const a = y * 12;
for (let m = a; m < a + 12; m++) s += out[m];
const f = YS[y] / s;
for (let m = a; m < a + 12; m++) out[m] *= f;
}
return out;
};
// JS self-check: (a) rebuild the default variant's final differenced series
// from coefficients alone; (b) benchmark the snapped levels via the JS rule
// and compare to the exported final_level_bench (1e-6 relative).
let checkFailed = false;
{
const test = Float64Array.from(dx0);
for (let k = 0; k < NT; k++) applyDelta(test, defV, k, 1);
let dev = 0;
for (let i = 0; i < T; i++) dev = Math.max(dev, Math.abs(test[i] - defV.final_dx_check[i]));
const tol = Math.max(2 * defV.coef_final_dev, META.validation.js_snap_tolerance);
if (!(dev <= tol)) {
checkFailed = true;
console.error(`makeSSI[${uid}] self-check FAILED: coefficient reconstruction dev ${dev.toExponential(3)} > tol ${tol.toExponential(3)}`);
}
const bl = benchLevels(xFrom(defV.final_dx_check));
let rel = 0;
for (let m = 0; m <= T; m++)
rel = Math.max(rel, Math.abs(bl[m] - defV.final_level_bench[m]) / Math.abs(defV.final_level_bench[m]));
if (!(rel <= 1e-6)) {
checkFailed = true;
console.error(`makeSSI[${uid}] self-check FAILED: benchmark mirror rel dev ${rel.toExponential(3)} > 1e-6`);
}
}
// ---- palette (literal hex in SVG attributes) ----------------------------
const C = { blue: "#2c6fbb", orange: "#e4572e", grey: "#8a94a0", teal: "#17a2b8",
purple: "#6b4fa0", lblue: "#d8ebff", lorange: "#fbe3dc", ink: "#333333",
ghost: "#b9bdbd", head: "#eef2f6" };
// ---- geometry ----------------------------------------------------------
// viewBox 1700 x 620. Text sizes are viewBox units: 25 for tick and axis labels,
// 31 for the harmonic flash labels, 23 for the log-power decades (were 19/24/18
// before the 2026-09-17 classroom pass, x1.3). At the deck's 1440px chart mount one
// unit is 0.847px, so ticks render at ~21px there and at ~25px at 1700px. H was
// trimmed from 680 to 620 (minimap 60 -> 44, panels ~14% shorter) so the larger
// HTML chrome above and below does not make the widget taller.
const W = 1700, H = 620, mL = 90, mR = 30;
const iw = W - mL - mR;
const FS = 25, FS_H = 31, FS_P = 23; // tick/axis, harmonic labels, decades
const pT = 14, pB = 228; // periodogram panel
const tT = 314, tB = 520; // time panel
const nT = 568, nB = 612; // minimap
const xJ = j => mL + (j / NS) * iw; // grid index -> x (omega scale)
// log10 power scale: domain covers before + every exported after
let pLo = Infinity, pHi = -Infinity;
const eat = v => { if (v > 0) { pLo = Math.min(pLo, v); pHi = Math.max(pHi, v); } };
pgB.forEach(eat);
VS.forEach(v => { if (v.pgram_after) v.pgram_after.forEach(eat); });
const l10 = Math.log10, pLo10 = l10(pLo) - 0.25, pHi10 = l10(pHi) + 0.25;
const yP = v => pT + (pHi10 - l10(Math.max(v, 1e-300))) / (pHi10 - pLo10) * (pB - pT);
// zoom window: fixed 6-year width, default = the last six years of the
// sample; drag the minimap to move it (display-only).
const ZW = 71;
const zmDefault = Math.max(0, T - ZW);
let zm0 = zmDefault, zm1 = zmDefault + ZW;
const xT = m => mL + ((m - zm0) / ZW) * iw;
let lvLo = 0, lvHi = 1, dLo = 0, dHi = 1;
const computeYDomains = () => {
lvLo = Infinity; lvHi = -Infinity;
for (let m = zm0; m <= zm1; m++) {
lvLo = Math.min(lvLo, lvlNSA[m], lvlSA[m], defV.final_level_bench[m]);
lvHi = Math.max(lvHi, lvlNSA[m], lvlSA[m], defV.final_level_bench[m]);
}
const padL = 0.07 * (lvHi - lvLo);
lvLo = (lvLo - padL) / U.scale; lvHi = (lvHi + padL) / U.scale;
dLo = Infinity; dHi = -Infinity;
for (let i = Math.max(0, zm0 - 1); i <= zm1 - 1; i++) {
dLo = Math.min(dLo, dx0[i], dxSA[i]);
dHi = Math.max(dHi, dx0[i], dxSA[i]);
}
const padD = 0.1 * (dHi - dLo); dLo -= padD; dHi += padD;
};
computeYDomains();
const yLv = v => tT + (lvHi - v) / (lvHi - lvLo) * (tB - tT);
const yDl = v => tT + (dHi - v) / (dHi - dLo) * (tB - tT);
let nLo = Infinity, nHi = -Infinity;
for (let m = 0; m <= T; m++) { nLo = Math.min(nLo, lvlNSA[m]); nHi = Math.max(nHi, lvlNSA[m]); }
const xN = m => mL + (m / T) * iw;
const yN = v => nT + (nHi - v) / (nHi - nLo) * (nB - nT);
// ---- path builders -----------------------------------------------------
const P = (x, y) => x.toFixed(1) + " " + y.toFixed(1);
const zoomPathLevelsRaw = arr => {
let d = "";
for (let m = zm0; m <= zm1; m++) d += (m === zm0 ? "M" : "L") + P(xT(m), yLv(arr[m] / U.scale));
return d;
};
const zoomPathDx = dl => {
let d = "";
const i0 = Math.max(0, zm0 - 1);
for (let i = i0; i <= zm1 - 1; i++) d += (i === i0 ? "M" : "L") + P(xT(i + 1), yDl(dl[i]));
return d;
};
const miniPathRaw = arr => {
let d = "";
for (let m = 0; m <= T; m += 2) d += (m === 0 ? "M" : "L") + P(xN(m), yN(arr[m]));
return d;
};
let pNSAlv = "", pSAlv = "", pNSAdl = "", pSAdl = "";
const pathNSAmini = miniPathRaw(lvlNSA);
// ---- formatting helpers ------------------------------------------------
const statFmt = s => Math.abs(s) >= 10 ? s.toFixed(1) : s.toFixed(2);
// Unicode super/subscripts instead of <sup>/<sub>/<tspan>: the glyphs sit at the
// full font size, so the type floor holds and the SVG decade labels need no tspan.
const SUPD = { "-": "⁻", "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹" };
const supU = n => String(n).split("").map(ch => SUPD[ch] || ch).join("");
const hatDelta = `<math><mover accent="true"><mi>Δ</mi><mo>^</mo></mover></math>`;
const pHTML = p => {
if (p <= 0) return "<i>p</i> < 10⁻¹⁵";
if (p < 1e-3) {
const e = Math.floor(l10(p)), m = (p / Math.pow(10, e)).toFixed(1);
return `<i>p</i> = ${m} × 10${supU(e)}`;
}
return `<i>p</i> = ${p.toFixed(3)}`;
};
const fmtLv = v => v.toFixed(U.digits) + (U.suffix || "");
const fmtDl = v => (LOG ? (v * 100).toFixed(1) + "%" : (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(2));
const ym = m => dates[m].slice(0, 7);
// ---- static SVG skeleton ----------------------------------------------
const root = document.createElement("div");
root.className = "ssi-widget";
root.dataset.widget = "ssi";
let shadeS = "", hLabS = "";
hInfo.forEach((h, i) => {
if (!h) return;
const x0 = xJ(h.jmin - 0.5), x1 = Math.min(xJ(h.jmax + 0.5), mL + iw);
shadeS += `<rect x="${x0.toFixed(1)}" y="${pT}" width="${(x1 - x0).toFixed(1)}" height="${pB - pT}" fill="${C.lblue}" opacity="0.6"/>`;
hLabS += `<text class="hlab" data-h="${i}" x="${((x0 + x1) / 2).toFixed(1)}" y="${pT + 32}" text-anchor="middle" font-size="${FS_H}" font-weight="700" fill="${C.blue}" opacity="0">${h.label}</text>`;
});
let baseS = "";
for (let jj = 0; jj < NS; jj++)
baseS += `<circle class="pd" cx="${xJ(jj + 1).toFixed(1)}" cy="${yP(pgB[jj]).toFixed(1)}" r="2.2" fill="${C.grey}"/>`;
const fLab = ["0", "π/6", "π/3", "π/2", "2π/3", "5π/6", "π"];
let fAxS = "";
for (let i = 0; i <= 6; i++) {
const x = mL + (i / 6) * iw;
fAxS += `<line x1="${x}" y1="${pB}" x2="${x}" y2="${pB + 7}" stroke="#999"/>` +
`<text x="${x}" y="${pB + 31}" text-anchor="middle" font-size="${FS}" fill="#666">${fLab[i]}</text>`;
}
const buildTAx = () => {
let s = "";
for (let m = zm0; m <= zm1; m++) if (dates[m].slice(5, 7) === "01")
s += `<line x1="${xT(m).toFixed(1)}" y1="${tB}" x2="${xT(m).toFixed(1)}" y2="${tB + 7}" stroke="#999"/>` +
`<text x="${xT(m).toFixed(1)}" y="${tB + 31}" text-anchor="start" font-size="${FS}" fill="#666">${dates[m].slice(0, 4)}</text>`;
return s;
};
const ticksOf = (lo, hi, n) => {
const span = hi - lo, step = Math.pow(10, Math.floor(l10(span / n)));
const mult = span / n / step >= 5 ? 5 : span / n / step >= 2 ? 2 : 1;
const s = mult * step, out = [];
for (let v = Math.ceil(lo / s) * s; v <= hi + 1e-12; v += s) out.push(v);
return out;
};
const buildLvTicks = () => ticksOf(lvLo, lvHi, 4).map(v =>
`<line x1="${mL}" y1="${yLv(v).toFixed(1)}" x2="${mL + iw}" y2="${yLv(v).toFixed(1)}" stroke="#eee"/>` +
`<text x="${mL - 10}" y="${(yLv(v) + 8).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="#666">${fmtLv(v)}</text>`).join("");
const buildDlTicks = () => ticksOf(dLo, dHi, 4).map(v =>
`<line x1="${mL}" y1="${yDl(v).toFixed(1)}" x2="${mL + iw}" y2="${yDl(v).toFixed(1)}" stroke="${Math.abs(v) < 1e-12 ? "#bbb" : "#eee"}"/>` +
`<text x="${mL - 10}" y="${(yDl(v) + 8).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="#666">${fmtDl(v)}</text>`).join("");
const pTickS = (() => {
let s = "";
for (let e = Math.ceil(pLo10); e <= Math.floor(pHi10); e++) {
const y = pT + (pHi10 - e) / (pHi10 - pLo10) * (pB - pT);
s += `<line x1="${mL}" y1="${y.toFixed(1)}" x2="${mL + iw}" y2="${y.toFixed(1)}" stroke="#f0f0f0"/>` +
`<text x="${mL - 10}" y="${(y + 8).toFixed(1)}" text-anchor="end" font-size="${FS_P}" fill="#888">10${supU(e)}</text>`;
}
return s;
})();
// unadjusted head window (first HEAD.n_level months) on the minimap
const headMini = `<rect x="${xN(0).toFixed(1)}" y="${nT - 3}" width="${(xN(HEAD.n_level - 1) - xN(0)).toFixed(1)}" height="${nB - nT + 6}" fill="${C.orange}" opacity="0.25"/>`;
const chart = document.createElement("div");
chart.innerHTML =
`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Stochastic spectral imputation on ${SERIES}: whitened periodogram above, reconstructed series below, full-range minimap at the bottom">
<defs><clipPath id="ssiclip${uid}"><rect x="${mL}" y="${tT}" width="${iw}" height="${tB - tT}"/></clipPath></defs>
${shadeS}${pTickS}
<line x1="${mL}" y1="${pB}" x2="${mL + iw}" y2="${pB}" stroke="#333" stroke-width="2"/>${fAxS}
<g class="gbase">${baseS}</g>
<g class="gpool"></g>
<line class="thline" x1="${mL}" y1="0" x2="${mL + iw}" y2="0" stroke="${C.grey}" stroke-width="2.5" stroke-dasharray="8 6" opacity="0.55"/>
<text class="thlab" x="${mL + 10}" y="0" font-size="${FS}" font-style="italic" fill="#666"></text>
<g class="grings"></g>
<g class="gtgt"></g>
<path class="donarc" fill="none" stroke="${C.teal}" stroke-width="3" opacity="0" d=""/>
<circle class="donflash" r="9" fill="none" stroke="${C.teal}" stroke-width="3.5" opacity="0" cx="-20" cy="-20"/>
${hLabS}
<g class="gtickLv"></g>
<g class="gtickDl" style="display:none"></g>
<line x1="${mL}" y1="${tB}" x2="${mL + iw}" y2="${tB}" stroke="#333" stroke-width="2"/>
<g class="gtax"></g>
<g clip-path="url(#ssiclip${uid})">
<rect class="headshade" x="0" y="${tT}" width="0" height="${tB - tT}" fill="${C.orange}" opacity="0.12"/>
<g class="ghosts"></g>
<path class="nsaline" fill="none" stroke="${C.grey}" stroke-width="2" opacity="0.85" d=""/>
<path class="saline" fill="none" stroke="${C.purple}" stroke-width="2.5" stroke-dasharray="9 6" opacity="0" d=""/>
<path class="reconline" fill="none" stroke="${C.blue}" stroke-width="3" d=""/>
<path class="trace" fill="none" stroke="${C.orange}" stroke-width="1.8" opacity="0" d=""/>
</g>
${headMini}
<path class="minsa" fill="none" stroke="${C.grey}" stroke-width="1.4" opacity="0.85" d="${pathNSAmini}"/>
<path class="mrecon" fill="none" stroke="${C.blue}" stroke-width="1.4" d=""/>
<rect class="mzoom" x="${xN(zm0).toFixed(1)}" y="${nT - 3}" width="${(xN(zm1) - xN(zm0)).toFixed(1)}" height="${nB - nT + 6}" fill="${C.lorange}" opacity="0.45" stroke="${C.orange}" stroke-width="2.5" style="cursor:grab;"/>
<rect class="mhit" x="${mL}" y="${nT - 8}" width="${iw}" height="${nB - nT + 16}" fill="none" pointer-events="all" style="cursor:grab;touch-action:none;"/>
</svg>`;
const svg = chart.querySelector("svg");
const $ = sel => svg.querySelector(sel);
const $$ = sel => Array.prototype.slice.call(svg.querySelectorAll(sel));
const gPool = $(".gpool"), gRings = $(".grings"), gTgt = $(".gtgt");
const thLine = $(".thline"), thLab = $(".thlab");
const donArc = $(".donarc"), donFlash = $(".donflash");
const hLabs = $$(".hlab");
const reconLine = $(".reconline"), nsaLine = $(".nsaline"), saLine = $(".saline");
const traceEl = $(".trace"), headShade = $(".headshade");
const mRecon = $(".mrecon"), ghostsG = $(".ghosts");
const gTickLv = $(".gtickLv"), gTickDl = $(".gtickDl"), gTax = $(".gtax");
const mZoomR = $(".mzoom"), mHit = $(".mhit");
let ringEls = [], tgtEls = [];
{
let rS = "", tS = "";
for (let k = 0; k < NT; k++) {
const x = xJ(TG[k].j).toFixed(1), y = yP(pgB[TG[k].j - 1]).toFixed(1);
rS += `<circle cx="${x}" cy="${y}" r="6.5" fill="none" stroke="${C.orange}" stroke-width="2" opacity="0.35"/>`;
tS += `<circle cx="${x}" cy="${y}" r="3.4" fill="${C.orange}"/>`;
}
gRings.innerHTML = rS; gTgt.innerHTML = tS;
ringEls = Array.prototype.slice.call(gRings.children);
tgtEls = Array.prototype.slice.call(gTgt.children);
}
// ---- header chrome -----------------------------------------------------
const mkRow = () => { const d = document.createElement("div"); d.className = "ssi-head"; return d; };
const mkTitle = html => { const s = document.createElement("span"); s.className = "ssi-title"; s.innerHTML = html; return s; };
const headP = mkRow();
headP.append(mkTitle(`whitened periodogram of Δ${SERIES} (log₁₀ power) · seasonal bins shaded, <i>s</i> = 12 · ${NT} targets`));
const chipBox = document.createElement("div"); chipBox.className = "ssi-chip";
const chipB = document.createElement("div");
chipB.style.color = C.orange;
chipB.innerHTML = `before ${hatDelta} = ${statFmt(SH.evt_before.statistic)} · ${pHTML(SH.evt_before.p)} ⇒ reject H₀`;
const chipA = document.createElement("div");
chipBox.append(chipB, chipA);
headP.append(chipBox);
// Time-panel header: ONE row that must fit the chart width (the deck mounts the
// chart at 1440px). The title and the extracted-component label share the left
// slot (the label replaces the title during a slow beat); the legend is short and
// the "seasonally adjusted" expansion lives in the title.
const headT = mkRow(); headT.classList.add("ssi-head-t");
const tpTitle = mkTitle("");
const traceLab = document.createElement("span"); traceLab.className = "ssi-tracelab";
const swatch = (stroke, sw, dash) =>
`<svg width="34" height="9" style="display:inline;width:34px;height:9px;vertical-align:.1em;">` +
`<line x1="0" y1="4.5" x2="34" y2="4.5" stroke="${stroke}" stroke-width="${sw}"` +
(dash ? ` stroke-dasharray="${dash}"` : "") + `/></svg>`;
const legend = document.createElement("div"); legend.className = "ssi-legend";
legend.innerHTML = swatch(C.grey, 2.5) + ` ${SERIES} ` + swatch(C.blue, 3) + " reconstruction";
const saKey = document.createElement("span"); saKey.style.opacity = "0";
saKey.innerHTML = " " + swatch(C.purple, 2.5, "7 5") + ` published ${SA_SERIES}`;
legend.append(saKey);
headT.append(tpTitle, traceLab, legend);
// ---- controls ----------------------------------------------------------
// Two deliberate rows (play controls; variant controls) rather than one wrapping
// row: at the 26px host size the controls no longer fit one line, and a fixed
// two-row bar has a predictable height. explore=false mounts row 1 only.
const cbar = document.createElement("div"); cbar.className = "ssi-bar";
const mkRowC = () => { const d = document.createElement("div"); d.className = "ssi-row"; return d; };
const row1 = mkRowC();
const mkBtn = (txt, action) => {
const b = document.createElement("button"); b.type = "button"; b.className = "ssi-btn";
b.textContent = txt; if (action) b.dataset.action = action; return b;
};
const mkSel = (lab, opts_, action) => {
const w = document.createElement("label");
const s = document.createElement("span"); s.textContent = lab;
const sel = document.createElement("select"); if (action) sel.dataset.action = action;
opts_.forEach(o => { const e = document.createElement("option"); e.value = o.value; e.textContent = o.text; sel.appendChild(e); });
w.append(s, sel); return { w, sel };
};
const mkSep = () => { const s = document.createElement("span"); s.className = "ssi-sep"; return s; };
const adjB = mkBtn("Adjust", "adjust"); adjB.classList.add("ssi-primary");
const stepB = mkBtn("Step +1", "step"), skipB = mkBtn("Skip to end", "skip");
const spdW = document.createElement("label"); spdW.innerHTML = `<span>speed</span>`;
const spdI = document.createElement("input");
spdI.type = "range"; spdI.min = "0.25"; spdI.max = "3"; spdI.step = "0.25"; spdI.value = "1"; spdI.dataset.action = "speed";
const spdO = document.createElement("span"); spdO.className = "ssi-cval"; spdO.textContent = "×1";
spdW.append(spdI, spdO);
row1.append(adjB, stepB, skipB, spdW);
cbar.append(row1);
let qSel = null, mSel = null, reseedB = null, drawLab = null, viewB = null, resetB = null;
if (explore) {
const row2 = mkRowC();
const qs = mkSel("quantile", SH.donor.quantiles.map(q => ({ value: String(q), text: "q = " + q })), "quantile");
qs.sel.value = String(SH.donor.default_quantile);
const ms = mkSel("method", [
{ value: "bootstrap", text: "bootstrap" },
{ value: "exponential", text: "exponential" },
{ value: "mean", text: "mean" }
], "method");
qSel = qs.sel; mSel = ms.sel;
reseedB = mkBtn("Reseed", "reseed");
drawLab = document.createElement("span"); drawLab.className = "ssi-cval"; drawLab.style.minWidth = "8em";
drawLab.title = "precomputed freqseas draws (R), replayed";
viewB = mkBtn("view: levels", "view");
resetB = mkBtn("Reset", "reset");
row2.append(qs.w, ms.w, reseedB, drawLab, mkSep(), viewB, resetB);
cbar.append(row2);
}
if (checkFailed) {
const warn = document.createElement("span"); warn.className = "ssi-warn";
warn.textContent = "⚠ data self-check failed — see console";
row1.append(warn);
}
// ---- footer (text set by setTitle: zoom range, minimap hint, head window) ----
const foot = document.createElement("div"); foot.className = "ssi-foot";
const footTxt = document.createElement("span");
const tag = document.createElement("span"); tag.className = "ssi-tag"; tag.textContent = "Deeper Dive · not assessable";
foot.append(footTxt, tag);
const chartWrap = document.createElement("div"); chartWrap.className = "ssi-chartwrap";
// overlay row sits in the band between the periodogram's axis labels (baseline
// pB + 31) and the time panel (tT): 266..314 viewBox units, ~41px at 1440px width
headT.style.top = (266 / H * 100).toFixed(2) + "%";
chartWrap.append(chart, headT);
root.append(headP, cbar, chartWrap, foot);
// ---- instance state ----------------------------------------------------
let V = defV;
let q = SH.donor.default_quantile, method = "bootstrap";
let seedIdx = Math.max(0, opts.seedIndex | 0);
let domain = "levels"; // "levels" | "dx"
let speed = 1;
let phase = "idle"; // idle|setup|beat|cascade|closing|done|stepped
let phaseT = 0, qpos = 0, casStart = 0, casT = 0;
let setupProg = 0, saOp = 0;
let beatApplied = false, donPulse = 0;
const flashT = new Float64Array(hInfo.length);
let lastH = -1;
const applied = new Uint8Array(NT);
const dl = Float64Array.from(dx0);
let snapped = false;
let dirty = true, activeT = 0;
let trace = { d: "", op: 0, label: "" };
const ghosts = [];
// payload order, not sorted: the exporter lists the default draw first, so
// "draw 1 of K" is the bench's seed and Reseed cycles deterministically
const seedsFor = (q_, m_) => VS.filter(v => v.quantile === q_ && v.method === m_ && v.seed != null)
.map(v => v.seed);
const poolInfo = () => SH.donor.by_quantile.find(d => d.quantile === q);
const D_SETUP = 1.4, D_BEAT = 2.4, D_CASC = 2.6, D_CLOSE = 0.9;
const MAX_ACTIVE = 60;
// ---- variant / donor visuals ------------------------------------------
const cyBefore = TG.map(t => yP(pgB[t.j - 1]));
let cyAfter = [];
const loadVariant = () => {
if (method === "mean") V = VS.find(v => v.quantile === q && v.method === "mean");
else {
const seeds = seedsFor(q, method);
seedIdx = Math.min(seedIdx, Math.max(0, seeds.length - 1));
V = VS.find(v => v.quantile === q && v.method === method && v.seed === seeds[seedIdx]);
}
if (!V) V = defV;
cyAfter = TG.map((t, k) => yP(V.pgram_after[k]));
rebuildDonorVisuals();
if (drawLab) {
const K = seedsFor(q, method).length;
drawLab.textContent = K ? `draw ${seedIdx + 1} of ${K}` : "deterministic";
}
if (reseedB) reseedB.disabled = seedsFor(q, method).length <= 1;
};
const rebuildDonorVisuals = () => {
gRings.setAttribute("opacity", "1");
const info = poolInfo();
gPool.innerHTML = info.pool.map(j =>
`<circle cx="${xJ(j).toFixed(1)}" cy="${yP(pgB[j - 1]).toFixed(1)}" r="2.8" fill="${C.teal}" opacity="0.45"/>`).join("");
const y = yP(info.threshold).toFixed(1);
thLine.setAttribute("y1", y); thLine.setAttribute("y2", y);
thLine.setAttribute("opacity", "0.55");
thLab.setAttribute("y", (Number(y) - 8).toFixed(1));
thLab.textContent = `donor threshold (q = ${q}) · ${info.n_pool} donors`;
};
// ---- chip / titles -----------------------------------------------------
// The headline "after" is the package's own post-test on the adjusted series
// (seas_adjust()$post_evt -- the number the notes' runnable code reproduces);
// the re-test on the displayed, benchmarked levels rides beside it.
const setChipAfter = on => {
if (!on) { chipA.style.color = "#999"; chipA.innerHTML = "after …"; return; }
const e = V.evt_after, rej = e.p < ALPHA, eb = V.evt_after_bench;
chipA.style.color = rej ? C.orange : C.blue;
chipA.innerHTML = `after ${hatDelta} = ${statFmt(e.statistic)} · ${pHTML(e.p)} ⇒ ${rej ? "reject" : "fail to reject"} H₀` +
`<span style="color:#999;"> (benchmarked: ${pHTML(eb.p)})</span>`;
};
const setTitle = () => {
const zr = `${dates[zm0].slice(0, 4)}–${dates[zm1].slice(0, 4)}`;
tpTitle.textContent = domain === "levels"
? `seasonally adjusted series, percent (annual means pinned to ${SERIES})`
: `${LOG ? "log-differences" : "first differences"} (surgery domain, pre-benchmark)`;
footTxt.textContent =
`zoom ${zr} · drag the minimap window to move it · head ${ym(0)} to ${ym(HEAD.n_level - 1)} ` +
`(${HEAD.n_level} obs) unadjusted by construction`;
};
// ---- run control -------------------------------------------------------
const resetRun = () => {
dl.set(dx0); applied.fill(0);
qpos = 0; casStart = 0; phase = "idle"; phaseT = 0; casT = 0;
setupProg = 0; saOp = 0; activeT = 0; snapped = false;
trace.op = 0; donPulse = 0; lastH = -1; flashT.fill(0);
setChipAfter(false); dirty = true;
};
const snapFinal = () => { dl.set(V.final_dx_check); snapped = true; dirty = true; };
const finishRun = () => {
snapFinal(); applied.fill(1); qpos = NT;
saOp = 1; setupProg = 1;
setChipAfter(true); phase = "done";
};
const applyOrd = k => {
applyDelta(dl, V, k, 1); applied[k] = 1; dirty = true;
const h = TG[k].harmonic - 1;
if (h !== lastH) { flashT[h] = 0.8; lastH = h; }
};
const beginNext = () => {
if (qpos >= NT) { phase = "closing"; phaseT = 0; snapFinal(); setChipAfter(true); return; }
if (qpos < slowIdx.length) { phase = "beat"; phaseT = 0; beatApplied = false; }
else { phase = "cascade"; casStart = qpos; casT = 0; }
};
const startRun = () => { resetRun(); phase = "setup"; phaseT = 0; };
const makeTrace = k => {
const del = deltaSeries(V, k);
let amp = 0;
const i0 = Math.max(0, zm0 - 1);
for (let i = i0; i <= zm1 - 1; i++) amp = Math.max(amp, Math.abs(del[i]));
const mid = (tT + tB) / 2, px = amp > 0 ? 34 / amp : 0;
let d = "";
for (let i = i0; i <= zm1 - 1; i++) d += (i === i0 ? "M" : "L") + P(xT(i + 1), mid - del[i] * px);
trace.d = d; trace.label = `extracted ${TG[k].harmonic_label} component (rescaled)`;
};
// ---- ghosts ------------------------------------------------------------
const pushGhost = () => {
if (phase !== "done") return;
ghosts.push({ lv: V.final_level_bench, dl: Float64Array.from(dl) });
if (ghosts.length > 12) ghosts.shift();
renderGhosts();
};
const renderGhosts = () => {
ghostsG.innerHTML = ghosts.map(g =>
`<path fill="none" stroke="${C.ghost}" stroke-width="1.4" opacity="0.65" d="${
domain === "levels" ? zoomPathLevelsRaw(g.lv) : zoomPathDx(g.dl)}"/>`).join("");
};
const clearGhosts = () => { ghosts.length = 0; renderGhosts(); };
// ---- render ------------------------------------------------------------
const ease = u => u < 0.5 ? 2 * u * u : 1 - 2 * (1 - u) * (1 - u);
function render(dt) {
const s = setupProg;
ringEls.forEach((r, k) => r.setAttribute("opacity", applied[k] ? "0.12" : String(0.35 + 0.55 * s)));
gPool.setAttribute("opacity", String(0.6 + 0.4 * s + donPulse * 0.4));
thLine.setAttribute("stroke-width", String(2.5 + 1.2 * s));
for (let k = 0; k < NT; k++) {
const el = tgtEls[k];
let cy;
if (applied[k]) { cy = cyAfter[k]; el.setAttribute("fill", C.blue); }
else if (phase === "beat" && order[qpos] === k) {
const u = Math.max(0, Math.min(1, (phaseT / (D_BEAT / speed) - 0.3) / 0.3));
cy = cyBefore[k] + (cyAfter[k] - cyBefore[k]) * ease(u);
el.setAttribute("fill", C.orange);
} else { cy = cyBefore[k]; el.setAttribute("fill", C.orange); }
el.setAttribute("cy", cy.toFixed(1));
}
for (let h = 0; h < hInfo.length; h++) {
if (flashT[h] > 0) flashT[h] = Math.max(0, flashT[h] - dt);
if (hLabs[h]) hLabs[h].setAttribute("opacity", String(Math.min(1, flashT[h] / 0.3)));
}
if (phase === "beat" && V.donor_j) {
const k = order[qpos], dj = V.donor_j[k];
const u = phaseT / (D_BEAT / speed);
const op = u < 0.3 ? Math.min(1, u / 0.08) : Math.max(0, 1 - (u - 0.3) / 0.15);
const dx_ = xJ(dj), dy = yP(pgB[dj - 1]), tx = xJ(TG[k].j), ty = cyAfter[k];
donFlash.setAttribute("cx", dx_.toFixed(1)); donFlash.setAttribute("cy", dy.toFixed(1));
donFlash.setAttribute("opacity", String(op));
donArc.setAttribute("d", `M ${P(dx_, dy)} Q ${P((dx_ + tx) / 2, Math.min(dy, ty) - 70)} ${P(tx, ty)}`);
donArc.setAttribute("opacity", String(op * 0.8));
} else { donFlash.setAttribute("opacity", "0"); donArc.setAttribute("opacity", "0"); }
if (donPulse > 0) donPulse = Math.max(0, donPulse - dt);
traceEl.setAttribute("d", trace.d);
traceEl.setAttribute("opacity", String(trace.op));
// the extracted-component label takes the title's slot while a beat plays
const tracing = trace.op > 0;
tpTitle.style.display = tracing ? "none" : "";
traceLab.style.display = tracing ? "" : "none";
traceLab.textContent = trace.label;
traceLab.style.opacity = String(Math.max(0.4, trace.op));
if (dirty) {
const bl = snapped ? V.final_level_bench : benchLevels(xFrom(dl));
if (domain === "levels") reconLine.setAttribute("d", zoomPathLevelsRaw(bl));
else reconLine.setAttribute("d", zoomPathDx(dl));
mRecon.setAttribute("d", miniPathRaw(bl));
dirty = false;
}
saLine.setAttribute("opacity", String(saOp));
saKey.style.opacity = String(saOp);
}
const setDomain = dom => {
domain = dom;
gTickLv.style.display = dom === "levels" ? "" : "none";
gTickDl.style.display = dom === "dx" ? "" : "none";
nsaLine.setAttribute("d", dom === "levels" ? pNSAlv : pNSAdl);
saLine.setAttribute("d", dom === "levels" ? pSAlv : pSAdl);
if (viewB) viewB.textContent = "view: " + (dom === "levels" ? "levels" : (LOG ? "log-differences" : "first differences"));
setTitle(); renderGhosts(); dirty = true;
};
const applyZoom = () => {
computeYDomains();
gTickLv.innerHTML = buildLvTicks();
gTickDl.innerHTML = buildDlTicks();
gTax.innerHTML = buildTAx();
pNSAlv = zoomPathLevelsRaw(lvlNSA); pSAlv = zoomPathLevelsRaw(lvlSA);
pNSAdl = zoomPathDx(dx0); pSAdl = zoomPathDx(dxSA);
nsaLine.setAttribute("d", domain === "levels" ? pNSAlv : pNSAdl);
saLine.setAttribute("d", domain === "levels" ? pSAlv : pSAdl);
mZoomR.setAttribute("x", xN(zm0).toFixed(1));
mZoomR.setAttribute("width", (xN(zm1) - xN(zm0)).toFixed(1));
// head window shading inside the zoom (only visible when the window covers it)
const hEnd = HEAD.n_level - 1;
if (zm0 <= hEnd) {
headShade.setAttribute("x", xT(zm0).toFixed(1));
headShade.setAttribute("width", (xT(Math.min(hEnd, zm1)) - xT(zm0)).toFixed(1));
} else headShade.setAttribute("width", "0");
if (phase === "beat" && trace.d !== "") makeTrace(order[qpos]);
setTitle(); renderGhosts(); dirty = true;
};
let mapDrag = false;
const zoomToPointer = e => {
const r = svg.getBoundingClientRect();
const xv = (e.clientX - r.left) / r.width * W;
const mm = Math.round((xv - mL) / iw * T);
const nz = Math.max(0, Math.min(T - ZW, mm - (ZW >> 1)));
if (nz !== zm0) { zm0 = nz; zm1 = zm0 + ZW; applyZoom(); }
};
mHit.addEventListener("pointerdown", e => {
mapDrag = true;
try { mHit.setPointerCapture(e.pointerId); } catch (_) { /* synthetic pointer */ }
mHit.style.cursor = "grabbing"; mZoomR.style.cursor = "grabbing";
e.preventDefault(); e.stopPropagation();
zoomToPointer(e);
});
mHit.addEventListener("pointermove", e => { if (mapDrag) zoomToPointer(e); });
const endMapDrag = () => { mapDrag = false; mHit.style.cursor = "grab"; mZoomR.style.cursor = "grab"; };
mHit.addEventListener("pointerup", endMapDrag);
mHit.addEventListener("pointercancel", endMapDrag);
// ---- state machine -----------------------------------------------------
function advance(dt) {
if (phase === "idle" || phase === "done" || phase === "stepped") return;
activeT += dt;
if (activeT > MAX_ACTIVE) { finishRun(); return; }
phaseT += dt;
if (phase === "setup") {
setupProg = Math.min(1, phaseT / (D_SETUP / speed));
if (setupProg >= 1) beginNext();
} else if (phase === "beat") {
const u = phaseT / (D_BEAT / speed), k = order[qpos];
if (!V.donor_j && u < 0.3) donPulse = 0.5;
if (u >= 0.3 && trace.d === "") makeTrace(k);
if (u >= 0.6 && !beatApplied) { applyOrd(k); beatApplied = true; }
trace.op = u < 0.3 ? 0 : u < 0.5 ? (u - 0.3) / 0.2 : u < 0.7 ? 1 : Math.max(0, (1 - u) / 0.3);
if (u >= 1) { trace.d = ""; trace.op = 0; qpos++; beginNext(); }
} else if (phase === "cascade") {
casT += dt;
const want = casStart + Math.floor((casT / (D_CASC / speed)) * (NT - casStart));
while (qpos < Math.min(want, NT)) { applyOrd(order[qpos]); qpos++; }
if (qpos >= NT) { phase = "closing"; phaseT = 0; snapFinal(); setChipAfter(true); }
} else if (phase === "closing") {
saOp = Math.min(1, phaseT / (D_CLOSE / speed));
if (saOp >= 1) phase = "done";
}
}
// ---- wire controls -----------------------------------------------------
adjB.onclick = () => { if (phase === "stepped") { setupProg = 1; beginNext(); } else startRun(); };
stepB.onclick = () => {
if (phase === "done" || phase === "closing" || qpos >= NT) return;
if (phase === "idle") resetRun();
setupProg = 1;
trace.d = ""; trace.op = 0;
if (phase === "beat" && beatApplied) qpos++;
else { applyOrd(order[qpos]); qpos++; donPulse = 0.6; }
if (qpos >= NT) { snapFinal(); setChipAfter(true); saOp = 1; phase = "done"; }
else phase = "stepped";
};
skipB.onclick = () => finishRun();
spdI.oninput = () => { speed = +spdI.value; spdO.textContent = "×" + speed; };
const refreshDisabled = () => {
if (!explore) return;
Array.prototype.forEach.call(qSel.options, o => {
o.disabled = method === "exponential" ? seedsFor(Number(o.value), "exponential").length === 0 : false;
});
Array.prototype.forEach.call(mSel.options, o => {
o.disabled = o.value === "exponential" ? seedsFor(q, "exponential").length === 0 : false;
});
};
if (explore) {
qSel.onchange = () => { q = Number(qSel.value); seedIdx = 0; loadVariant(); refreshDisabled(); resetRun(); };
mSel.onchange = () => { method = mSel.value; seedIdx = 0; loadVariant(); refreshDisabled(); resetRun(); };
reseedB.onclick = () => {
pushGhost();
const K = seedsFor(q, method).length;
seedIdx = K ? (seedIdx + 1) % K : 0;
loadVariant(); resetRun(); startRun();
};
viewB.onclick = () => setDomain(domain === "levels" ? "dx" : "levels");
resetB.onclick = () => {
clearGhosts();
if (zm0 !== zmDefault) { zm0 = zmDefault; zm1 = zmDefault + ZW; applyZoom(); }
resetRun();
};
}
// ---- start -------------------------------------------------------------
loadVariant(); refreshDisabled(); applyZoom(); setDomain("levels"); resetRun(); render(0);
let last = null, prevOn = false;
function tick(now) {
if (!document.contains(root)) return; // orphaned after re-render
if (last === null) last = now;
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
const onSlide = !!root.closest("section.present");
if (onSlide && !prevOn) { resetRun(); render(0); } // fresh arm on slide entry
prevOn = onSlide;
if (onSlide) { advance(dt); render(dt); }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// hooks for headless QA (not part of the API)
root._ssi = { get phase() { return phase; }, get variant() { return V.id; }, get seedIdx() { return seedIdx; },
get checkFailed() { return checkFailed; }, get zoom() { return [zm0, zm1]; },
get ghosts() { return ghosts.length; }, get domain() { return domain; } };
return root;
}