Capstone C3, replicate ERP CORE: the P3 oddball effect end to end from raw with amplitude, latency, effect size, SME, a cluster test and a divergence paragraph, with a FULL_COHORT switch

nb-c3-replicate-erp-core Level 3 · Event-Related Analysis capstone ~8 min Used in C3 · Capstone — Replicate ERP CORE

Downloads from ds-erpcore when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-c3-replicate-erp-core · Capstone C3: replicate the ERP CORE P3 effect

Capstone C3 · Level 3 · Status draft — for expert review; uncertain points carry TODO(confirm).

Brief (spec §6 C3). Pick one ERP CORE paradigm; from raw data and your C2 pipeline, reproduce the published component effect. Report amplitude, latency, effect size, SME, a cluster test, a comparison to the published values, and one paragraph discussing any divergence.

What this notebook does. It runs the paradigm chosen for Phase 2 — P3, the active visual oddball — end to end from the raw EEGLAB files of a documented subject subset, with one pipeline and one a-priori measurement window, and prints every deliverable the rubric asks for. It then stops at the one thing it cannot do honestly: the published ERP CORE numbers are not in the site's dataset catalog, so every published value in the comparison table is a literal TODO(confirm) rather than a number from memory. Filling that table is the capstone's last step, and the notebook computes the divergence for you once you do.

Rubric (spec §6 C3). ① the pipeline is reused unchanged from C2 · ② measurement windows are fixed a priori · ③ the interpretation of the cluster test is correct. Section 8 checks all three explicitly.

Data. ds-erpcore — ERP CORE, Kappenman, Farrens, Zhang, Stewart & Luck (2020), ERP CORE: An Open Resource for Human Event-related Potential Research, PsyArXiv, DOI 10.31234/osf.io/4azqm; dataset DOI 10.18112/openneuro.ds003069.v1.0.0. Paradigm P3, an active visual oddball task. From data/directory.yaml: Biosemi ActiveTwo, 30 EEG + 3 EOG electrodes in a 10-20 placement scheme, 1024 Hz, CMS reference, 60 Hz mains, no software filters, 40 participants, access: open.

Licence — CC BY-SA 4.0, contested at source. Three statements exist and all three are real: the LICENSE file shipped with the data says CC BY-SA 4.0 with explicit share-alike wording, the BIDS dataset_description.json says CC0, and the OSF node thsqg record says CC BY 4.0. Spec §10.7 makes the most restrictive reading govern, so the site records CC-BY-SA-4.0 (data/directory.yaml, 2026-09-18) and share-alike is assumed to bind anything derived from these data. helpers_l3.ERPCORE_LICENCE_STATEMENTS carries all three verbatim. Redistribution is permitted under every reading; only share-alike is in question.

Files are fetched per subject from the paradigm's own OSF component (etdkz) and cached locally; a checkout that already holds them downloads nothing.

No published values are quoted. The catalog carries the citation and the DOIs but no published amplitudes, latencies or effect sizes, so every comparison with the paper's own numbers is a literal TODO(confirm) rather than a number from memory.

Conditions come from the dataset's own code dictionary (task-P3_events.json): a stimulus code's first digit is the block's target letter and its second digit is the letter shown, so equal digits = target, unequal digits = standard. The design gives p = .2 for the target category, so a subject contributes about 40 target and 160 standard trials.

Subset and the FULL_COHORT switch (spec §11). The default is a documented subset of 20 subjects (sub-001 … sub-020, the first twenty of the forty the dataset ships — no subject is chosen by its result). Setting FULL_COHORT = True uses all 40; that costs roughly 58 MB of download per subject not already cached, so the next cell checks free disk and refuses rather than filling the volume.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed when the
#    pinned stack is already present (local runs, CI); a fresh Colab or Binder kernel installs
#    it once.  On Colab, run from a clone of the repository so that notebooks/_shared/ is
#    available (repository URL: TODO(confirm), spec section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers, located relative to the working directory -- notebooks/<level>/ or
#    notebooks/ -- never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l3.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L3/ (or notebooks/) so that _shared/helpers_l3.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l3 as L3

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg (no windows,
#    nothing blocks); outside Jupyter the helpers select Agg.  Every MNE figure is requested
#    with show=False and each figure cell ends with plt.show().
import matplotlib.pyplot as plt
import numpy as np
import mne

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers_l3 imported from notebooks/_shared")
print(f"ERP CORE cache: {L3.erpcore_root().name}/ (resolved relative to the working directory, "
      "or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched")
MNE 1.10.2; helpers_l3 imported from notebooks/_shared
ERP CORE cache: erpcore/ (resolved relative to the working directory, or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched

1 · Configuration, and the disk check before anything downloads

In [2]:
FULL_COHORT = False               # True -> all 40 subjects; see the disk check below

SUBJECTS = list(range(1, 41)) if FULL_COHORT else list(range(1, 21))
W = L3.P3_WINDOW
CH = L3.P3_CHANNEL
ALPHA = 0.05
SEED = 20260918
SME_THRESHOLD_UV = 1.0            # the threshold nb-3-5 states and uses

root = L3.erpcore_root()
have = [s for s in SUBJECTS if (root / "p3" / L3.erpcore_subject_id(s)).is_dir()
        or (root / "P3" / L3.erpcore_subject_id(s)).is_dir()]
missing = [s for s in SUBJECTS if s not in have]
free_mb = helpers.free_disk_mb(root)
need_mb = 58.0 * len(missing)
print(f"cohort: {'FULL (40 subjects)' if FULL_COHORT else f'documented subset ({len(SUBJECTS)} subjects)'}")
print(f"  already cached: {len(have)}; to download: {len(missing)} (~{need_mb:.0f} MB at ~58 MB each)")
print(f"  free disk where the cache lives: {free_mb:.0f} MB")
if need_mb > 0 and need_mb + 500 > free_mb:
    raise SystemExit(f"refusing to download {need_mb:.0f} MB with only {free_mb:.0f} MB free; "
                     f"set FULL_COHORT = False, or free space, or point $EEG_COURSE_ERPCORE elsewhere")
print("  proceeding" + ("" if missing else " -- nothing to download"))
cohort: documented subset (20 subjects)
  already cached: 20; to download: 0 (~0 MB at ~58 MB each)
  free disk where the cache lives: 4234 MB
  proceeding -- nothing to download

2 · The pipeline, reused unchanged (rubric item ①)

The pipeline is helpers_l3.P3_PIPELINE, the same object every Level-3 notebook calls, whose steps are the canonical C2 order: load → montage → bad-channel detection → filter → interpolate → re-reference → ocular correction → epoch → reject. Nothing in this notebook changes any of its parameters; the printout below is the evidence, not a promise.

In [3]:
# The one Level-3 pipeline, printed rather than described.  Every Level-3 notebook and the C3
# capstone call the same helpers_l3.load_p3_epochs, so their numbers are comparable.
for key, value in L3.P3_PIPELINE.items():
    print(f"{key:15s} : {value}")
print()
print(f"a-priori measurement window : {L3.P3_WINDOW[0] * 1000:.0f}-{L3.P3_WINDOW[1] * 1000:.0f} ms "
      f"at {L3.P3_CHANNEL}, fixed in helpers_l3.P3_WINDOW")
dataset         : ds-erpcore, paradigm P3 (active visual oddball)
conditions      : target vs standard, read from the dataset's own code dictionary (task-P3_events.json): a stimulus code's first digit is the block's target letter and its second digit the letter shown, so equal digits = target, unequal digits = standard
channel_names   : FP1/FP2 renamed Fp1/Fp2 so MNE's standard_1005 montage matches; the three EOG channels (HEOG_left, HEOG_right, VEOG_lower) typed as EOG and excluded from every EEG average
montage         : standard_1005 (MNE), matched by name
bad_channels    : a channel is bad when its standard deviation over the whole recording exceeds 5x the median of the 30 EEG channels AND its largest absolute correlation with its four nearest neighbours is below 0.4 (both measured on a 1-40 Hz copy); bad channels are interpolated (spherical splines) before re-referencing.  Two conditions, because a channel dominated by blinks is large but still correlates with its neighbours.
reference       : average of the 30 EEG channels, applied after interpolation
filter          : FIR band-pass 0.1-40 Hz at the native 1024 Hz (MNE raw.filter defaults: firwin, Hamming, zero-phase, 'auto' transition bands)
ocular          : FastICA (n_components=15, random_state=20260918) fitted on a 1 Hz high-passed 128 Hz copy; components whose absolute correlation with any EOG channel reaches 0.5 are removed.  Level 2 owns this step (L2.6) and Level 3 does not re-teach it; it is here so that the frontal ocular artifact does not decide which trials survive.
epochs          : -200 to +800 ms around the stimulus event, baseline -200 to 0 ms (mean subtraction), no annotation-based rejection; trial metadata attached
resample        : 1024 -> 256 Hz after epoching (MNE epochs.resample, FFT-based)
rejection       : an epoch is rejected when the peak-to-peak amplitude over -200 to +800 ms exceeds 150 uV on any of the 30 EEG channels (label_source: algorithmic).  The criterion is always evaluated over that window, whatever window the epochs were cut to, so every Level-3 notebook rejects the same trials
measurement     : P3 = mean amplitude over 300-600 ms at Pz, target minus standard; the window is fixed a priori in helpers_l3.P3_WINDOW and is not moved after looking at the data (nb-1-5-filters used 300-500 ms on a different dataset; nb-3-3 shows what a collapsed-localizer window would give instead)

a-priori measurement window : 300-600 ms at Pz, fixed in helpers_l3.P3_WINDOW
In [4]:
import time as _time

t0 = _time.time()
store, qc = {}, []
for s in SUBJECTS:
    ep, nfo = L3.load_p3_epochs(s, verbose=False)
    store[s] = {"target": L3.condition_epochs(ep, "target"), "standard": L3.condition_epochs(ep, "standard"),
                "info": nfo}
    qc.append(nfo)
    print(f"  {nfo['subject']}: {nfo['n_kept']['target']:3d} target / {nfo['n_kept']['standard']:3d} standard "
          f"kept, {nfo['n_rejected']:3d} rejected; bads {nfo['bad_channels'] or '[]'}; "
          f"ICA excluded {nfo['ica_excluded']}; RT target {nfo['mean_rt_ms']['target']:.0f} ms; "
          f"accuracy {100 * nfo['accuracy']:.1f} %", flush=True)
times = ep.times
eeg = qc[0]["eeg_channels"]
i_ch = eeg.index(CH)
proto = store[SUBJECTS[0]]["target"].average().copy().pick("eeg")
print(f"\n{len(SUBJECTS)} subjects processed from raw in {_time.time() - t0:.1f} s")
  sub-001:  35 target / 138 standard kept,  27 rejected; bads []; ICA excluded [0]; RT target 569 ms; accuracy 97.5 %
  sub-002:  40 target / 158 standard kept,   2 rejected; bads []; ICA excluded [5]; RT target 538 ms; accuracy 99.0 %
  sub-003:  36 target / 144 standard kept,  20 rejected; bads []; ICA excluded [0, 2]; RT target 460 ms; accuracy 92.0 %
  sub-004:  40 target / 159 standard kept,   1 rejected; bads []; ICA excluded [0, 4]; RT target 320 ms; accuracy 95.0 %
  sub-005:  38 target / 150 standard kept,  12 rejected; bads []; ICA excluded [0, 1]; RT target 627 ms; accuracy 98.0 %
  sub-006:  32 target / 138 standard kept,  30 rejected; bads []; ICA excluded [0, 3]; RT target 271 ms; accuracy 77.3 %
  sub-007:  40 target / 160 standard kept,   0 rejected; bads []; ICA excluded [0, 1]; RT target 359 ms; accuracy 97.0 %
  sub-008:  40 target / 151 standard kept,   9 rejected; bads []; ICA excluded [0, 4]; RT target 395 ms; accuracy 88.5 %
  sub-009:  40 target / 160 standard kept,   0 rejected; bads ['P10', 'PO8']; ICA excluded [0, 2]; RT target 514 ms; accuracy 92.5 %
  sub-010:  38 target / 153 standard kept,   9 rejected; bads []; ICA excluded [0, 3]; RT target 284 ms; accuracy 81.0 %
  sub-011:  36 target / 150 standard kept,  14 rejected; bads []; ICA excluded [0, 2]; RT target 422 ms; accuracy 99.5 %
  sub-012:  40 target / 160 standard kept,   0 rejected; bads []; ICA excluded [1]; RT target 362 ms; accuracy 93.5 %
  sub-013:  40 target / 160 standard kept,   0 rejected; bads []; ICA excluded [0]; RT target 374 ms; accuracy 94.5 %
  sub-014:  40 target / 158 standard kept,   2 rejected; bads []; ICA excluded [1, 3]; RT target 483 ms; accuracy 91.5 %
  sub-015:  39 target / 159 standard kept,   2 rejected; bads []; ICA excluded [0]; RT target 406 ms; accuracy 99.0 %
  sub-016:  40 target / 159 standard kept,   1 rejected; bads []; ICA excluded [7, 0]; RT target 468 ms; accuracy 99.5 %
  sub-017:  40 target / 160 standard kept,   0 rejected; bads []; ICA excluded [0]; RT target 504 ms; accuracy 100.0 %
  sub-018:  40 target / 159 standard kept,   1 rejected; bads []; ICA excluded [0, 3]; RT target 389 ms; accuracy 95.5 %
  sub-019:  40 target / 160 standard kept,   0 rejected; bads []; ICA excluded [2, 0]; RT target 423 ms; accuracy 99.0 %
  sub-020:  40 target / 159 standard kept,   1 rejected; bads []; ICA excluded [0, 2]; RT target 381 ms; accuracy 98.5 %
20 subjects processed from raw in 108.5 s

Quality control: what the pipeline did to whom

A replication that does not report its exclusions is not a replication. Every number below is a count of decisions the pipeline made, and any subject whose counts look unlike the others is a subject whose result you should look at before believing.

In [5]:
n_t = np.array([q["n_kept"]["target"] for q in qc])
n_s = np.array([q["n_kept"]["standard"] for q in qc])
n_rej = np.array([q["n_rejected"] for q in qc])
n_ica = np.array([len(q["ica_excluded"]) for q in qc])
n_bad = np.array([len(q["bad_channels"]) for q in qc])
acc = np.array([q["accuracy"] for q in qc])
rt_t = np.array([q["mean_rt_ms"]["target"] for q in qc])
rt_s = np.array([q["mean_rt_ms"]["standard"] for q in qc])

print(f"cohort summary ({len(SUBJECTS)} subjects, 200 stimulus events each by design: 40 target, 160 standard)")
print(f"  epochs rejected at {L3.REJECT_PTP_UV:g} uV peak-to-peak: median {np.median(n_rej):.0f} "
      f"({100 * np.median(n_rej) / 200:.1f} %), range {n_rej.min()}-{n_rej.max()}")
print(f"  target trials kept   : median {np.median(n_t):.0f}, range {n_t.min()}-{n_t.max()}")
print(f"  standard trials kept : median {np.median(n_s):.0f}, range {n_s.min()}-{n_s.max()}")
print(f"  condition-biased rejection (|% target rejected - % standard rejected|): median "
      f"{np.median(np.abs(100 * (1 - n_t / 40) - 100 * (1 - n_s / 160))):.1f} points, "
      f"max {np.max(np.abs(100 * (1 - n_t / 40) - 100 * (1 - n_s / 160))):.1f} points")
print(f"  bad channels interpolated: {int(n_bad.sum())} across the cohort "
      f"({int((n_bad > 0).sum())} subjects); ICA components removed as ocular: median {np.median(n_ica):.0f}, "
      f"range {n_ica.min()}-{n_ica.max()}")
print(f"  behaviour: accuracy {100 * acc.mean():.1f} % (range {100 * acc.min():.1f}-{100 * acc.max():.1f}); "
      f"reaction time target {rt_t.mean():.0f} ms, standard {rt_s.mean():.0f} ms "
      f"(target - standard {rt_t.mean() - rt_s.mean():+.0f} ms)")
from scipy import stats
t_rt, p_rt = stats.ttest_rel(rt_t, rt_s)
print(f"    paired t test on reaction time, target vs standard: t({len(SUBJECTS) - 1}) = {t_rt:.3f}, "
      f"p = {p_rt:.5f} -- the behavioural oddball effect, independent of the EEG")

fig, axes = plt.subplots(1, 3, figsize=(14, 3.6))
axes[0].bar(np.arange(len(SUBJECTS)), n_rej, color="tab:blue")
axes[0].set(xlabel="Subject (index in the subset)", ylabel="Epochs rejected (of 200)",
            title=f"Artifact rejection at {L3.REJECT_PTP_UV:g} uV peak-to-peak")
axes[1].bar(np.arange(len(SUBJECTS)) - 0.2, n_t, 0.4, label="target")
axes[1].bar(np.arange(len(SUBJECTS)) + 0.2, n_s / 4, 0.4, label="standard / 4")
axes[1].set(xlabel="Subject (index in the subset)", ylabel="Trials kept",
            title="Trials kept per condition (standard scaled by 1/4)")
axes[1].legend(fontsize=8)
axes[2].plot(rt_s, rt_t, "o")
lims = [min(rt_s.min(), rt_t.min()) - 20, max(rt_s.max(), rt_t.max()) + 20]
axes[2].plot(lims, lims, "k--", lw=0.8)
axes[2].set(xlabel="Reaction time, standard (ms)", ylabel="Reaction time, target (ms)",
            title="Behaviour: every point above the line is a slower target")
for ax in axes:
    ax.grid(alpha=0.3)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
cohort summary (20 subjects, 200 stimulus events each by design: 40 target, 160 standard)
  epochs rejected at 150 uV peak-to-peak: median 2 (0.8 %), range 0-30
  target trials kept   : median 40, range 32-40
  standard trials kept : median 159, range 138-160
  condition-biased rejection (|% target rejected - % standard rejected|): median 0.6 points, max 6.2 points
  bad channels interpolated: 2 across the cohort (1 subjects); ICA components removed as ocular: median 2, range 1-2
  behaviour: accuracy 94.4 % (range 77.3-100.0); reaction time target 428 ms, standard 396 ms (target - standard +31 ms)
    paired t test on reaction time, target vs standard: t(19) = 3.561, p = 0.00209 -- the behavioural oddball effect, independent of the EEG
Figure 1 of notebook nb-c3-replicate-erp-core, an output plot. The text around it states what it shows and the units of every axis.

3 · Amplitude (rubric item ②: the window was fixed a priori)

The measurement window and channel come from helpers_l3.P3_WINDOW / P3_CHANNEL, which are module-level constants shared with every Level-3 notebook and are not touched here. Mean amplitude is the primary measure because it is unbiased by trial count (nb-3-3 measures that bias); peak amplitude is reported beside it so the divergence is on the record.

In [6]:
X_all = np.stack([(store[s]["target"].average().copy().pick("eeg").data
                   - store[s]["standard"].average().copy().pick("eeg").data) * 1e6 for s in SUBJECTS])
X = X_all[:, i_ch, :]
grand_t = np.mean([store[s]["target"].average().copy().pick("eeg").data for s in SUBJECTS], axis=0) * 1e6
grand_s = np.mean([store[s]["standard"].average().copy().pick("eeg").data for s in SUBJECTS], axis=0) * 1e6
n = len(SUBJECTS)

amp_mean = np.array([L3.mean_amplitude(X[k], None, W, times=times) for k in range(n)])
amp_peak = np.array([L3.peak_amplitude(X[k], None, W, times=times) for k in range(n)])
t_stat, p_val = stats.ttest_1samp(amp_mean, 0)
dz = amp_mean.mean() / amp_mean.std(ddof=1)
ci = stats.t.ppf(1 - ALPHA / 2, n - 1) * amp_mean.std(ddof=1) / np.sqrt(n)

print(f"P3 amplitude, {CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, target minus standard, {n} subjects")
print(f"  mean amplitude : {amp_mean.mean():+.3f} uV  (SD {amp_mean.std(ddof=1):.3f}, "
      f"SEM {amp_mean.std(ddof=1) / np.sqrt(n):.3f}, 95% CI [{amp_mean.mean() - ci:+.3f}, "
      f"{amp_mean.mean() + ci:+.3f}])")
print(f"  peak amplitude : {amp_peak.mean():+.3f} uV  (SD {amp_peak.std(ddof=1):.3f}) -- reported for "
      f"comparison only; it is biased upward by noise")
print(f"  grand-average difference wave at {CH}: mean "
      f"{L3.mean_amplitude(grand_t[i_ch] - grand_s[i_ch], None, W, times=times):+.3f} uV, peak "
      f"{L3.peak_amplitude(grand_t[i_ch] - grand_s[i_ch], None, W, times=times):+.3f} uV")
print(f"  per condition at {CH}: target {L3.mean_amplitude(grand_t[i_ch], None, W, times=times):+.3f} uV, "
      f"standard {L3.mean_amplitude(grand_s[i_ch], None, W, times=times):+.3f} uV")
print(f"  {int((amp_mean > 0).sum())} of {n} subjects show a positive effect")

fig, axes = plt.subplots(1, 2, figsize=(13, 4.4))
L3.plot_erp({f"target (n = {int(n_t.sum())} trials)": grand_t[i_ch],
             f"standard (n = {int(n_s.sum())} trials)": grand_s[i_ch],
             "difference": ((grand_t - grand_s)[i_ch], {"color": "k", "lw": 1.8})},
            times, window=W, ax=axes[0],
            title=f"C3: grand-average P3 at {CH}, {n} ERP CORE subjects (uV, positive up)")
axes[1].hist(amp_mean, bins=max(6, n // 2), color="tab:blue", alpha=0.85)
axes[1].axvline(0, color="gray", lw=1.0)
axes[1].axvline(amp_mean.mean(), color="tab:orange", lw=2,
                label=f"mean {amp_mean.mean():+.2f} uV, 95% CI +/-{ci:.2f}")
axes[1].set(xlabel=f"P3 mean amplitude at {CH} (uV)", ylabel="Subjects",
            title=f"Distribution across {n} subjects (uV)")
axes[1].grid(alpha=0.3, axis="y")
axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
P3 amplitude, Pz, 300-600 ms, target minus standard, 20 subjects
  mean amplitude : +2.623 uV  (SD 2.417, SEM 0.540, 95% CI [+1.492, +3.755])
  peak amplitude : +4.640 uV  (SD 3.135) -- reported for comparison only; it is biased upward by noise
  grand-average difference wave at Pz: mean +2.623 uV, peak +3.337 uV
  per condition at Pz: target +4.805 uV, standard +2.182 uV
  20 of 20 subjects show a positive effect
Figure 2 of notebook nb-c3-replicate-erp-core, an output plot. The text around it states what it shows and the units of every axis.

4 · Latency

In [7]:
lat_peak = np.array([L3.peak_latency(X[k], None, W, times=times) for k in range(n)])
lat_fal = np.array([L3.fractional_area_latency(X[k], None, W, times=times) for k in range(n)])
g_diff = (grand_t - grand_s)[i_ch]

print(f"P3 latency, {CH}, measured inside the same a-priori window "
      f"({W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms)")
print(f"  peak latency, per subject then averaged      : {1000 * lat_peak.mean():.1f} ms "
      f"(SD {1000 * lat_peak.std(ddof=1):.1f}, range {1000 * lat_peak.min():.0f}-{1000 * lat_peak.max():.0f})")
print(f"  50% fractional-area latency, per subject      : {1000 * lat_fal.mean():.1f} ms "
      f"(SD {1000 * lat_fal.std(ddof=1):.1f}, range {1000 * lat_fal.min():.0f}-{1000 * lat_fal.max():.0f})")
print(f"  peak latency of the GRAND AVERAGE            : {1000 * L3.peak_latency(g_diff, None, W, times=times):.1f} ms")
print(f"  50% area latency of the GRAND AVERAGE        : "
      f"{1000 * L3.fractional_area_latency(g_diff, None, W, times=times):.1f} ms")
print(f"  the grand-average latency and the average of the per-subject latencies are different quantities "
      f"and neither is 'the' latency; both are reported because a paper that quotes one and a replication "
      f"that computes the other will not agree.")
print(f"  correlation between a subject's P3 latency and its mean reaction time: "
      f"r = {np.corrcoef(lat_fal, rt_t)[0, 1]:+.3f} (peak latency: "
      f"{np.corrcoef(lat_peak, rt_t)[0, 1]:+.3f}); nb-3-6 does this within subject, trial by trial")
P3 latency, Pz, measured inside the same a-priori window (300-600 ms)
  peak latency, per subject then averaged      : 468.0 ms (SD 69.5, range 351-577)
  50% fractional-area latency, per subject      : 452.6 ms (SD 34.6, range 386-524)
  peak latency of the GRAND AVERAGE            : 475.6 ms
  50% area latency of the GRAND AVERAGE        : 458.0 ms
  the grand-average latency and the average of the per-subject latencies are different quantities and neither is 'the' latency; both are reported because a paper that quotes one and a replication that computes the other will not agree.
  correlation between a subject's P3 latency and its mean reaction time: r = -0.064 (peak latency: -0.436); nb-3-6 does this within subject, trial by trial

5 · Effect size and standardized measurement error

In [8]:
m_win = (times >= W[0]) & (times <= W[1])
# get_data returns volts; every score in this notebook is in microvolts
scores = {s: {c: store[s][c].get_data(picks="eeg")[:, i_ch, :][:, m_win].mean(1) * 1e6
              for c in ("target", "standard")} for s in SUBJECTS}
print(f"single-trial mean amplitudes at {CH} over {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms: "
      f"cohort trial SD {np.mean([scores[s]['target'].std(ddof=1) for s in SUBJECTS]):.2f} uV (target)")
sme_t = np.array([L3.sme(scores[s]["target"]) for s in SUBJECTS])
sme_s = np.array([L3.sme(scores[s]["standard"]) for s in SUBJECTS])
sme_d = np.sqrt(sme_t ** 2 + sme_s ** 2)
need = np.array([int(np.ceil((scores[s]["target"].std(ddof=1) / SME_THRESHOLD_UV) ** 2)) for s in SUBJECTS])
reach = int(np.sum(need <= n_t))

print(f"effect size ({n} subjects, within-subject contrast)")
print(f"  Cohen dz (mean / SD of the per-subject differences) : {dz:.3f}")
print(f"  t({n - 1}) = {t_stat:.3f}, p = {p_val:.3e}")
print(f"  95% CI on the mean difference                        : "
      f"[{amp_mean.mean() - ci:+.3f}, {amp_mean.mean() + ci:+.3f}] uV")
print()
print(f"standardized measurement error (mean amplitude at {CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms)")
print(f"  target condition   : median {np.median(sme_t):.3f} uV, range {sme_t.min():.3f}-{sme_t.max():.3f}")
print(f"  standard condition : median {np.median(sme_s):.3f} uV, range {sme_s.min():.3f}-{sme_s.max():.3f}")
print(f"  the difference     : median {np.median(sme_d):.3f} uV (the two errors add in quadrature)")
print(f"  at the stated threshold SME <= {SME_THRESHOLD_UV:g} uV: median {int(np.median(need))} target trials "
      f"needed, range {need.min()}-{need.max()}; {reach}/{n} subjects reach it with the trials they have")
print(f"  measurement error accounts for about "
      f"{100 * np.mean(sme_d ** 2) / amp_mean.var(ddof=1):.0f} % of the between-subject variance in the "
      f"effect; the remaining {100 - 100 * np.mean(sme_d ** 2) / amp_mean.var(ddof=1):.0f} % is real "
      f"between-subject difference")

fig, ax = plt.subplots(figsize=(10, 4.4))
order = np.argsort(amp_mean)
ax.errorbar(np.arange(n), amp_mean[order], yerr=sme_d[order], fmt="o", capsize=3, ms=5)
ax.axhline(0, color="gray", lw=0.8)
ax.axhline(amp_mean.mean(), color="tab:orange", lw=1.4, ls="--",
           label=f"cohort mean {amp_mean.mean():+.2f} uV (95% CI +/-{ci:.2f})")
ax.fill_between([-0.5, n - 0.5], amp_mean.mean() - ci, amp_mean.mean() + ci, color="tab:orange", alpha=0.15)
ax.set_xticks(np.arange(n))
ax.set_xticklabels([qc[i]["subject"][-3:] for i in order], fontsize=7, rotation=90)
ax.set(xlabel="Subject (sorted by effect)", ylabel="P3, target - standard (uV)", xlim=(-0.5, n - 0.5),
       title=f"C3: every subject's P3 with its standardized measurement error "
             f"({CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, uV)")
ax.grid(alpha=0.3, axis="y")
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
single-trial mean amplitudes at Pz over 300-600 ms: cohort trial SD 5.89 uV (target)
effect size (20 subjects, within-subject contrast)
  Cohen dz (mean / SD of the per-subject differences) : 1.085
  t(19) = 4.853, p = 1.104e-04
  95% CI on the mean difference                        : [+1.492, +3.755] uV

standardized measurement error (mean amplitude at Pz, 300-600 ms)
  target condition   : median 0.859 uV, range 0.591-2.332
  standard condition : median 0.467 uV, range 0.268-1.224
  the difference     : median 0.984 uV (the two errors add in quadrature)
  at the stated threshold SME <= 1 uV: median 27 target trials needed, range 14-175; 13/20 subjects reach it with the trials they have
  measurement error accounts for about 23 % of the between-subject variance in the effect; the remaining 77 % is real between-subject difference
Figure 3 of notebook nb-c3-replicate-erp-core, an output plot. The text around it states what it shows and the units of every axis.

6 · The cluster test

In [9]:
threshold = float(stats.t.ppf(1 - ALPHA / 2, n - 1))
N_PERM = 10000
t_obs, clusters, cluster_p, H0 = mne.stats.permutation_cluster_1samp_test(
    X, threshold=threshold, n_permutations=N_PERM, tail=0, out_type="indices", seed=SEED, verbose=False)

print(f"mne.stats.permutation_cluster_1samp_test(X, threshold={threshold:.4f}, n_permutations={N_PERM}, "
      f"tail=0, out_type='indices', seed={SEED})")
print(f"  X: {X.shape[0]} subjects x {X.shape[1]} time points of the {CH} difference wave (uV)")
print(f"  permutations used {len(H0)} of the {2 ** n if n <= 25 else 'many'} possible sign flips; "
      f"smallest attainable p-value {1 / len(H0):.5f}")
rows = []
for i in np.argsort(cluster_p):
    idx = np.asarray(clusters[i][0])
    rows.append({"start_ms": float(times[idx[0]] * 1000), "end_ms": float(times[idx[-1]] * 1000),
                 "n": int(len(idx)), "t_sum": float(t_obs[idx].sum()), "p": float(cluster_p[i]),
                 "sign": "positive" if t_obs[idx].sum() > 0 else "negative"})
for r in rows:
    print(f"  {r['sign']:8s} cluster {r['start_ms']:7.1f} to {r['end_ms']:7.1f} ms ({r['n']:3d} samples): "
          f"t-sum {r['t_sum']:+9.2f}, p = {r['p']:.4f}"
          + ("   <- significant" if r["p"] <= ALPHA else ""))
sig_clusters = [r for r in rows if r["p"] <= ALPHA]

adjacency, _ = mne.channels.find_ch_adjacency(proto.info, ch_type="eeg")
t_st, cl_st, p_st, H0_st = mne.stats.spatio_temporal_cluster_1samp_test(
    np.transpose(X_all, (0, 2, 1)), threshold=threshold, n_permutations=N_PERM, tail=0,
    adjacency=adjacency, out_type="mask", seed=SEED, verbose=False)
st_rows = []
for i in np.argsort(p_st):
    mask = cl_st[i]
    ti, ci_ = np.where(mask.any(axis=1))[0], np.where(mask.any(axis=0))[0]
    st_rows.append({"start_ms": float(times[ti[0]] * 1000), "end_ms": float(times[ti[-1]] * 1000),
                    "n_ch": int(len(ci_)), "t_sum": float(t_st[mask].sum()), "p": float(p_st[i]),
                    "channels": [eeg[j] for j in ci_]})
print(f"\nspatio-temporal over {len(eeg)} channels "
      f"(mne.stats.spatio_temporal_cluster_1samp_test, same threshold/seed/permutations, adjacency from "
      f"mne.channels.find_ch_adjacency):")
for r in st_rows[:4]:
    print(f"  {r['start_ms']:7.1f} to {r['end_ms']:7.1f} ms, {r['n_ch']:2d} channels: t-sum "
          f"{r['t_sum']:+9.1f}, p = {r['p']:.4f}"
          + ("   <- significant" if r["p"] <= ALPHA else ""))
    print(f"      {', '.join(r['channels'])}")
st_sig = [r for r in st_rows if r["p"] <= ALPHA]

fig, ax = plt.subplots(figsize=(10, 4.2))
L3.plot_cluster_test(times, t_obs, clusters, cluster_p, alpha=ALPHA, threshold=threshold, ax=ax,
                     title=f"C3 cluster permutation test at {CH} ({n} subjects, {len(H0)} permutations, "
                           f"seed {SEED})")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
mne.stats.permutation_cluster_1samp_test(X, threshold=2.0930, n_permutations=10000, tail=0, out_type='indices', seed=20260918)
  X: 20 subjects x 256 time points of the Pz difference wave (uV)
  permutations used 10000 of the 1048576 possible sign flips; smallest attainable p-value 0.00010
  positive cluster   295.9 to   635.7 ms ( 88 samples): t-sum   +363.22, p = 0.0001   <- significant
  positive cluster  -157.2 to  -145.5 ms (  4 samples): t-sum     +8.79, p = 0.5045
  positive cluster    26.4 to    30.3 ms (  2 samples): t-sum     +4.22, p = 0.6905
spatio-temporal over 30 channels (mne.stats.spatio_temporal_cluster_1samp_test, same threshold/seed/permutations, adjacency from mne.channels.find_ch_adjacency):
    284.2 to   682.6 ms, 12 channels: t-sum   -1795.2, p = 0.0001   <- significant
      Fp1, F7, C5, P7, P9, PO7, Fp2, F4, F8, P8, P10, PO8
    295.9 to   729.5 ms, 12 channels: t-sum   +2017.4, p = 0.0001   <- significant
      FC3, C3, P3, PO3, Pz, CPz, FCz, Cz, C4, C6, P4, PO8
    213.9 to   272.5 ms, 11 channels: t-sum    +244.7, p = 0.2270
      Fp1, F3, F7, Fp2, Fz, F4, F8, FC4, FCz, C4, C6
    202.1 to   280.3 ms,  9 channels: t-sum    -218.9, p = 0.2644
      C3, C5, P3, P7, P9, PO7, PO3, O1, Oz
Figure 4 of notebook nb-c3-replicate-erp-core, an output plot. The text around it states what it shows and the units of every axis.

7 · Comparison with the published values

This table cannot be filled from this notebook, and it is not filled from memory. Spec §0.3 and the build contract allow scientific numbers only from the spec or data/catalog/. The catalog now carries the citation and both DOIs — Kappenman et al. (2020), paper DOI 10.31234/osf.io/4azqm, dataset DOI 10.18112/openneuro.ds003069.v1.0.0 — but it carries no published amplitudes, latencies or effect sizes. So every published cell below is a literal TODO(confirm). Fill them from the paper and the divergence column computes itself.

Three things to check before comparing a number, because a mismatch in any of them makes the comparison meaningless rather than interesting:

  • the measurement window and channel — this notebook uses 300–600 ms at Pz (helpers_l3.P3_WINDOW);
  • the reference — this notebook uses the average of the 30 EEG channels; nb-3-4 shows the same data giving +6.7 µV instead of +3.6 µV under a linked-mastoid stand-in, which is larger than most published effects differ from each other;
  • the subject set and the exclusions — this notebook keeps every subject it loads and reports every count.
In [10]:
published = {
    # Fill each value from the ERP CORE paper (Kappenman et al. 2020, DOI 10.31234/osf.io/4azqm; dataset DOI
    # 10.18112/openneuro.ds003069.v1.0.0, both now in data/directory.yaml).  The catalog holds the citation but
    # no result values, so these stay None -- and None keeps the TODO(confirm) marker -- until read from the
    # paper itself (spec section 0.3: no scientific number from memory).
    "mean amplitude, target - standard (uV)": None,
    "peak latency (ms)": None,
    "effect size (Cohen dz)": None,
    "SME of the mean amplitude (uV)": None,
    "n subjects": None,
    "measurement window (ms)": None,
    "measurement channel": None,
    "reference": None,
}
ours = {
    "mean amplitude, target - standard (uV)": amp_mean.mean(),
    "peak latency (ms)": 1000 * lat_peak.mean(),
    "effect size (Cohen dz)": dz,
    "SME of the mean amplitude (uV)": float(np.median(sme_t)),
    "n subjects": float(n),
    "measurement window (ms)": f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f}",
    "measurement channel": CH,
    "reference": "average of the 30 EEG channels",
}

print(f"{'quantity':42s} {'this replication':>22s} {'published':>18s} {'divergence':>14s}")
for key in ours:
    pub = published[key]
    mine = ours[key]
    mine_s = f"{mine:.3f}" if isinstance(mine, float) else str(mine)
    if pub is None:
        print(f"{key:42s} {mine_s:>22s} {'TODO(confirm)':>18s} {'TODO(confirm)':>14s}")
    elif isinstance(pub, (int, float)) and isinstance(mine, float):
        print(f"{key:42s} {mine_s:>22s} {pub:18.3f} {mine - pub:+14.3f}")
    else:
        print(f"{key:42s} {mine_s:>22s} {str(pub):>18s} {'(match)' if str(pub) == str(mine) else '(differs)':>14s}")
print()
print("Every published cell is TODO(confirm) by design.  The catalog (data/directory.yaml) now carries the "
      "citation and both DOIs -- paper 10.31234/osf.io/4azqm, dataset "
      "10.18112/openneuro.ds003069.v1.0.0 -- but it carries no published amplitudes, latencies or effect "
      "sizes, and spec section 0.3 forbids supplying those from memory.  Fill `published` above from the "
      "paper; nothing else in this notebook changes.")
quantity                                         this replication          published     divergence
mean amplitude, target - standard (uV)                      2.623      TODO(confirm)  TODO(confirm)
peak latency (ms)                                         467.969      TODO(confirm)  TODO(confirm)
effect size (Cohen dz)                                      1.085      TODO(confirm)  TODO(confirm)
SME of the mean amplitude (uV)                              0.859      TODO(confirm)  TODO(confirm)
n subjects                                                 20.000      TODO(confirm)  TODO(confirm)
measurement window (ms)                                   300-600      TODO(confirm)  TODO(confirm)
measurement channel                                            Pz      TODO(confirm)  TODO(confirm)
reference                                  average of the 30 EEG channels      TODO(confirm)  TODO(confirm)

Every published cell is TODO(confirm) by design.  The catalog (data/directory.yaml) now carries the citation and both DOIs -- paper 10.31234/osf.io/4azqm, dataset 10.18112/openneuro.ds003069.v1.0.0 -- but it carries no published amplitudes, latencies or effect sizes, and spec section 0.3 forbids supplying those from memory.  Fill `published` above from the paper; nothing else in this notebook changes.

Sensitivity: how much of a divergence could the analysis choices explain?

Before attributing a difference to the sample, the recording or the year, it is worth knowing how much the analysis can move the number on these very data. The cell below re-measures the same cohort under the choices most likely to differ between two labs, one at a time.

In [11]:
i9, i10 = eeg.index("P9"), eeg.index("P10")
variants = {
    "this notebook (average reference, 300-600 ms, mean amplitude)": amp_mean,
    "linked-mastoid stand-in (P9/P10), same window":
        np.array([L3.mean_amplitude((X_all[k] - 0.5 * (X_all[k, [i9]] + X_all[k, [i10]]))[i_ch],
                                    None, W, times=times) for k in range(n)]),
    "window 300-500 ms (the window nb-1-5-filters used)":
        np.array([L3.mean_amplitude(X[k], None, (0.30, 0.50), times=times) for k in range(n)]),
    "window 400-700 ms": np.array([L3.mean_amplitude(X[k], None, (0.40, 0.70), times=times) for k in range(n)]),
    "channel CPz instead of Pz":
        np.array([L3.mean_amplitude(X_all[k, eeg.index("CPz")], None, W, times=times) for k in range(n)]),
    "peak amplitude instead of mean amplitude": amp_peak,
}
print(f"the same {n} subjects measured six ways")
print(f"{'variant':62s} {'mean (uV)':>10s} {'SD':>8s} {'dz':>7s} {'vs this notebook':>18s}")
for label, v in variants.items():
    t_, p_ = stats.ttest_1samp(v, 0)
    delta = v.mean() - amp_mean.mean()
    print(f"{label:62s} {v.mean():+10.3f} {v.std(ddof=1):8.3f} {v.mean() / v.std(ddof=1):7.3f} "
          f"{delta:+18.3f}")
print(f"\nthe spread across these six choices is "
      f"{max(v.mean() for v in variants.values()) - min(v.mean() for v in variants.values()):.3f} uV -- "
      f"which is the size of the divergence the analysis alone can produce, and the number any discussion "
      f"of divergence has to beat before invoking anything about the sample.")
the same 20 subjects measured six ways
variant                                                         mean (uV)       SD      dz   vs this notebook
this notebook (average reference, 300-600 ms, mean amplitude)      +2.623    2.417   1.085             +0.000
linked-mastoid stand-in (P9/P10), same window                      +5.453    4.102   1.329             +2.830
window 300-500 ms (the window nb-1-5-filters used)                 +2.648    2.416   1.096             +0.025
window 400-700 ms                                                  +2.285    2.417   0.946             -0.338
channel CPz instead of Pz                                          +2.454    2.500   0.982             -0.170
peak amplitude instead of mean amplitude                           +4.640    3.135   1.480             +2.017

the spread across these six choices is 3.168 uV -- which is the size of the divergence the analysis alone can produce, and the number any discussion of divergence has to beat before invoking anything about the sample.

8 · Rubric check, and the paragraph template

① The pipeline was reused unchanged from C2

helpers_l3.load_p3_epochs was called with its defaults for every subject; the steps are the C2 order and every parameter is in helpers_l3.P3_PIPELINE, printed in section 2. Nothing in this notebook re-tunes a filter, a threshold or a reference; where an alternative appears (section 7's sensitivity table) it is labelled as a sensitivity check and not used for the headline numbers.

② The measurement windows were fixed a priori

P3_WINDOW = (0.300, 0.600) and P3_CHANNEL = "Pz" are module-level constants shared with every Level-3 notebook, set before these data were plotted, and unchanged here. nb-3-3 reports what a collapsed-localizer window would have given instead; this notebook does not use one.

③ The interpretation of the cluster test is correct

The cluster p-value rejects "no difference anywhere in the tested window and channel set", and licenses nothing about when the effect starts or ends; the cluster boundaries depend on a cluster-forming threshold that nb-3-7 shows moving them. The amplitude, its confidence interval and the effect size are reported separately, from the a-priori window, because a t-sum is not an effect size.

The divergence paragraph — template

Copy the paragraph below into your report and replace each {…} with the value the last cell prints. It is deliberately structured so that the cheap explanations are ruled in or out before the interesting one.

Comparison with the published effect. We measured the P3 oddball effect as {amplitude} µV (95 % CI {ci_low} to {ci_high}, dz = {dz}, n = {n}) as the mean amplitude of the target-minus-standard difference wave over {window} at {channel}, against a published value of TODO(confirm). The difference is {divergence} µV. Before attributing it to the sample, three analysis choices were checked on our own data: the reference (a linked-mastoid stand-in gives {alt_ref} µV, a difference of {alt_ref_delta} µV), the measurement window ({alt_window} µV over 300–500 ms) and the measure itself (peak amplitude gives {alt_peak} µV). Across the six analysis variants we tried, the measured effect spans {sensitivity_range} µV, so any divergence smaller than that is explained by analysis choices alone and not by the data. Our cohort was {n} subjects of the 40 the dataset ships, with a median of {median_rejected} of 200 epochs rejected by a {reject_threshold} µV peak-to-peak criterion and a median of {median_target} target trials retained per subject; the median standardized measurement error of the target mean amplitude was {sme} µV, so {reach} of {n} subjects met our stated precision threshold of {sme_threshold} µV. {cluster_sentence} Remaining candidate explanations, in the order we would test them: differences in the measurement window and reference (largest and cheapest to check, quantified above); differences in artifact handling and the resulting trial counts (our rejection is documented above, the published criterion is TODO(confirm)); differences in the subject set ({n} of 40 here, TODO(confirm) published); and only then anything about the population or the recording itself.

9 · The numbers

In [12]:
alt_ref = variants["linked-mastoid stand-in (P9/P10), same window"].mean()
alt_win = variants["window 300-500 ms (the window nb-1-5-filters used)"].mean()
alt_pk = variants["peak amplitude instead of mean amplitude"].mean()
spread = max(v.mean() for v in variants.values()) - min(v.mean() for v in variants.values())
cluster_sentence = (
    f"A cluster-based permutation test on the subject-level difference waves at {CH} returned "
    f"{len(sig_clusters)} significant cluster(s) "
    + "; ".join(f"({r['start_ms']:.0f}-{r['end_ms']:.0f} ms, t-sum {r['t_sum']:+.0f}, p = {r['p']:.4f})"
                for r in sig_clusters)
    + f", which establishes that the conditions differ somewhere in the tested window and says nothing about "
      f"when the difference begins or ends."
) if sig_clusters else (
    f"A cluster-based permutation test on the subject-level difference waves at {CH} returned no significant "
    f"cluster, which is not evidence that the conditions are the same.")

print("nb-c3-replicate-erp-core -- C3 capstone numbers (draft; TODO(confirm) at author review)")
print(f"Cohort: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({n} subjects; FULL_COHORT = {FULL_COHORT}; "
      f"set it True for all 40); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)")
print(f"Pipeline: helpers_l3.P3_PIPELINE, unchanged (printed in section 2)")
print(f"Measurement: mean amplitude, {CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, a-priori "
      f"(helpers_l3.P3_WINDOW)")
print()
print(f"  AMPLITUDE       {amp_mean.mean():+.3f} uV  (SD {amp_mean.std(ddof=1):.3f}, 95% CI "
      f"[{amp_mean.mean() - ci:+.3f}, {amp_mean.mean() + ci:+.3f}]); peak amplitude "
      f"{amp_peak.mean():+.3f} uV")
print(f"  LATENCY         peak {1000 * lat_peak.mean():.1f} ms (SD {1000 * lat_peak.std(ddof=1):.1f}); "
      f"50% fractional-area {1000 * lat_fal.mean():.1f} ms (SD {1000 * lat_fal.std(ddof=1):.1f}); "
      f"grand-average peak {1000 * L3.peak_latency(g_diff, None, W, times=times):.1f} ms")
print(f"  EFFECT SIZE     Cohen dz = {dz:.3f}; t({n - 1}) = {t_stat:.3f}, p = {p_val:.3e}")
print(f"  SME             median {np.median(sme_t):.3f} uV (target), {np.median(sme_s):.3f} uV (standard), "
      f"{np.median(sme_d):.3f} uV (difference); {reach}/{n} subjects reach SME <= "
      f"{SME_THRESHOLD_UV:g} uV with the trials they have")
print(f"  CLUSTER TEST    {CH}, threshold t = {threshold:.4f}, {len(H0)} permutations, seed {SEED}:")
for r in rows:
    print(f"                    {r['sign']:8s} {r['start_ms']:7.1f} to {r['end_ms']:7.1f} ms, t-sum "
          f"{r['t_sum']:+9.2f}, p = {r['p']:.4f}"
          + ("   <- significant" if r["p"] <= ALPHA else ""))
print(f"                  spatio-temporal, {len(eeg)} channels:")
for r in st_rows[:3]:
    print(f"                    {r['start_ms']:7.1f} to {r['end_ms']:7.1f} ms, {r['n_ch']:2d} channels, "
          f"t-sum {r['t_sum']:+9.1f}, p = {r['p']:.4f}"
          + ("   <- significant" if r["p"] <= ALPHA else ""))
print(f"  BEHAVIOUR       reaction time target {rt_t.mean():.0f} ms vs standard {rt_s.mean():.0f} ms "
      f"(t({n - 1}) = {t_rt:.3f}, p = {p_rt:.3e}); accuracy {100 * acc.mean():.1f} %")
print(f"  QC              median {np.median(n_rej):.0f}/200 epochs rejected; target trials kept median "
      f"{np.median(n_t):.0f} (range {n_t.min()}-{n_t.max()}); {int(n_bad.sum())} channels interpolated; "
      f"median {np.median(n_ica):.0f} ICA components removed")
print(f"  PUBLISHED       TODO(confirm) -- not in data/directory.yaml or data/catalog/; every cell of the "
      f"comparison table in section 7 is TODO(confirm) and none was supplied from memory")
print()
print("PARAGRAPH TEMPLATE, filled with this run's values (published values stay TODO(confirm)):")
print()
print(f"  We measured the P3 oddball effect as {amp_mean.mean():+.3f} uV (95% CI "
      f"[{amp_mean.mean() - ci:+.3f}, {amp_mean.mean() + ci:+.3f}], dz = {dz:.3f}, n = {n}) as the mean "
      f"amplitude of the target-minus-standard difference wave over "
      f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms at {CH}, against a published value of TODO(confirm); the "
      f"difference is TODO(confirm) uV. Before attributing it to the sample, three analysis choices were "
      f"checked on our own data: the reference (a linked-mastoid stand-in gives {alt_ref:+.3f} uV, a "
      f"difference of {alt_ref - amp_mean.mean():+.3f} uV), the measurement window ({alt_win:+.3f} uV over "
      f"300-500 ms) and the measure itself (peak amplitude gives {alt_pk:+.3f} uV). Across the six analysis "
      f"variants we tried the measured effect spans {spread:.3f} uV, so any divergence smaller than that is "
      f"explained by analysis choices alone. Our cohort was {n} subjects of the 40 the dataset ships, with a "
      f"median of {np.median(n_rej):.0f} of 200 epochs rejected by a {L3.REJECT_PTP_UV:g} uV peak-to-peak "
      f"criterion and a median of {np.median(n_t):.0f} target trials retained per subject; the median "
      f"standardized measurement error of the target mean amplitude was {np.median(sme_t):.3f} uV, so "
      f"{reach} of {n} subjects met our stated precision threshold of {SME_THRESHOLD_UV:g} uV. "
      f"{cluster_sentence} Remaining candidate explanations, in the order we would test them: measurement "
      f"window and reference (quantified above); artifact handling and trial counts (ours documented, the "
      f"published criterion TODO(confirm)); the subject set ({n} of 40 here, TODO(confirm) published); and "
      f"only then the population or the recording itself.")
print()
print("Rubric: (1) pipeline unchanged from C2 -- helpers_l3.P3_PIPELINE, printed in section 2, defaults "
      "only; (2) windows fixed a priori -- helpers_l3.P3_WINDOW / P3_CHANNEL, module constants shared with "
      "every Level-3 notebook; (3) cluster interpretation -- section 8 and nb-3-7.")
nb-c3-replicate-erp-core -- C3 capstone numbers (draft; TODO(confirm) at author review)
Cohort: ds-erpcore P3, sub-001 to sub-020 (20 subjects; FULL_COHORT = False; set it True for all 40); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)
Pipeline: helpers_l3.P3_PIPELINE, unchanged (printed in section 2)
Measurement: mean amplitude, Pz, 300-600 ms, a-priori (helpers_l3.P3_WINDOW)

  AMPLITUDE       +2.623 uV  (SD 2.417, 95% CI [+1.492, +3.755]); peak amplitude +4.640 uV
  LATENCY         peak 468.0 ms (SD 69.5); 50% fractional-area 452.6 ms (SD 34.6); grand-average peak 475.6 ms
  EFFECT SIZE     Cohen dz = 1.085; t(19) = 4.853, p = 1.104e-04
  SME             median 0.859 uV (target), 0.467 uV (standard), 0.984 uV (difference); 13/20 subjects reach SME <= 1 uV with the trials they have
  CLUSTER TEST    Pz, threshold t = 2.0930, 10000 permutations, seed 20260918:
                    positive   295.9 to   635.7 ms, t-sum   +363.22, p = 0.0001   <- significant
                    positive  -157.2 to  -145.5 ms, t-sum     +8.79, p = 0.5045
                    positive    26.4 to    30.3 ms, t-sum     +4.22, p = 0.6905
                  spatio-temporal, 30 channels:
                      284.2 to   682.6 ms, 12 channels, t-sum   -1795.2, p = 0.0001   <- significant
                      295.9 to   729.5 ms, 12 channels, t-sum   +2017.4, p = 0.0001   <- significant
                      213.9 to   272.5 ms, 11 channels, t-sum    +244.7, p = 0.2270
  BEHAVIOUR       reaction time target 428 ms vs standard 396 ms (t(19) = 3.561, p = 2.086e-03); accuracy 94.4 %
  QC              median 2/200 epochs rejected; target trials kept median 40 (range 32-40); 2 channels interpolated; median 2 ICA components removed
  PUBLISHED       TODO(confirm) -- not in data/directory.yaml or data/catalog/; every cell of the comparison table in section 7 is TODO(confirm) and none was supplied from memory

PARAGRAPH TEMPLATE, filled with this run's values (published values stay TODO(confirm)):

  We measured the P3 oddball effect as +2.623 uV (95% CI [+1.492, +3.755], dz = 1.085, n = 20) as the mean amplitude of the target-minus-standard difference wave over 300-600 ms at Pz, against a published value of TODO(confirm); the difference is TODO(confirm) uV. Before attributing it to the sample, three analysis choices were checked on our own data: the reference (a linked-mastoid stand-in gives +5.453 uV, a difference of +2.830 uV), the measurement window (+2.648 uV over 300-500 ms) and the measure itself (peak amplitude gives +4.640 uV). Across the six analysis variants we tried the measured effect spans 3.168 uV, so any divergence smaller than that is explained by analysis choices alone. Our cohort was 20 subjects of the 40 the dataset ships, with a median of 2 of 200 epochs rejected by a 150 uV peak-to-peak criterion and a median of 40 target trials retained per subject; the median standardized measurement error of the target mean amplitude was 0.859 uV, so 13 of 20 subjects met our stated precision threshold of 1 uV. A cluster-based permutation test on the subject-level difference waves at Pz returned 1 significant cluster(s) (296-636 ms, t-sum +363, p = 0.0001), which establishes that the conditions differ somewhere in the tested window and says nothing about when the difference begins or ends. Remaining candidate explanations, in the order we would test them: measurement window and reference (quantified above); artifact handling and trial counts (ours documented, the published criterion TODO(confirm)); the subject set (20 of 40 here, TODO(confirm) published); and only then the population or the recording itself.

Rubric: (1) pipeline unchanged from C2 -- helpers_l3.P3_PIPELINE, printed in section 2, defaults only; (2) windows fixed a priori -- helpers_l3.P3_WINDOW / P3_CHANNEL, module constants shared with every Level-3 notebook; (3) cluster interpretation -- section 8 and nb-3-7.