Capstone C7, the full reproduction: a published Parkinson's resting-EEG result reproduced end to end from raw under a preregistered plan, with four tiers of what reproduction can mean, a QC report per participant, and every divergence from the paper listed before any is called the explanation

nb-c7-reproduction Level 7 · Applied Electives capstone ~8 min

Downloads from ds-iowapd 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-c7-reproduction · Capstone C7 — reproducing a published result end to end

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

This is the last notebook of the curriculum. Everything the eight levels built is pointed at one thing: take a paper with open data, write down what you are going to do before you do it, run it from raw, and say honestly what came out.

The capstone brief (spec §6, C7): choose a published EEG paper with open data; reproduce the primary result end-to-end from raw — preregistered analysis plan committed before analysis, a C2-style pipeline, QC reports, statistics, figures, and a written report with a reproducibility statement. Rubric: runs from a clean environment; every reporting-checklist item present; divergences from the paper explained; no claim exceeds the method.

The paper, by name:

Anjum, M. F., Espinoza, A. I., Cole, R. C., Singh, A., May, P., Uc, E. Y., Dasgupta, S., & Narayanan, N. S. (2024). Resting-state EEG measures cognitive impairment in Parkinson's disease. npj Parkinson's Disease 10, 6. DOI 10.1038/s41531-023-00602-0.

Data: Singh, A., Cole, R., Espinoza, A., Cavanagh, J., & Narayanan, N. Rest eyes open. OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0. Licence CC0, access: open (data/directory.yaml).

nb-c6-rigor-audit audited this paper's reporting. C7 attempts its result. That is deliberate: the audit established what the release does and does not let a reader reconstruct, and this capstone is what happens when you try anyway.

A word about what "reproduction" means here, before any number appears

Reproduction that lands on a different number is a normal scientific result, not a gotcha. It is also not a refutation. Section 2 sets out four tiers of what the word can mean, and this notebook is explicit at every step about which tier it is on, because most published arguments about "failed replications" are two people standing on different tiers.

What a divergence at the tier this notebook reaches does mean: an independent implementation, from the released raw data, with a pre-specified plan, obtained this number. What it does not mean: that the paper is wrong. The two analyses differ in their measure, their preprocessing and their sample, and every one of those differences is written down in section 7 rather than discovered afterwards.

Scope, stated up front. The paper's headline index is LEAPD, a linear-predictive-coding measure whose implementation is described in the paper and is not in the data release. This notebook does not reimplement it (§7 says what that costs), so it cannot reproduce the paper's ρ ≈ 0.68 as a like-for-like number. What it can do — and does — is test the paper's claim with a pre-specified spectral measure on the same raw recordings, and reproduce exactly the parts of the paper that the release does determine.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import hashlib
import importlib.util
import json
import platform
import subprocess
import sys
import tempfile
import time
import warnings
from datetime import datetime, timezone
from pathlib import Path

_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch", "specparam", "sklearn")
_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", "specparam==2.0.0rc4",
                                            "scikit-learn>=1.5"]
    subprocess.check_call(_cmd)

_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l6.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/capstones/ (or notebooks/) so that "
                            "_shared/helpers_l6.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1 as L1
import helpers_l2 as L2
import helpers_l5 as L5
import helpers_l6 as L6

HAVE_L7 = importlib.util.find_spec("helpers_l7") is not None
if HAVE_L7:
    import helpers_l7 as L7

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mne
from scipy import signal as sps, stats

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

try:
    import pooch

    pooch.get_logger().setLevel("WARNING")   # its INFO line carries an absolute cache path
except Exception:
    pass

# Spec section 11: a capstone runs on a documented subset of 10-20 subjects, with a FULL_COHORT
# switch for a local run.  FULL_COHORT = True has NOT been executed for the stored outputs; the
# final cell says what it would cost.
FULL_COHORT = False
N_PER_GROUP = 10 if not FULL_COHORT else 49       # 49 controls is the cohort's own cap
N_PERM = 10000
N_BOOT = 2000
SEED = L6.SEED
ALPHA = L6.ALPHA
RUN_STARTED = datetime.now(timezone.utc).isoformat(timespec="seconds")
T_NOTEBOOK = time.time()

print(f"MNE {mne.__version__}; helpers from notebooks/_shared (helpers_l7 present: {HAVE_L7})")
print(f"FULL_COHORT = {FULL_COHORT}: {N_PER_GROUP} participants per group "
      f"({2 * N_PER_GROUP} in total, of the 149 released)")
print(f"Download: about {2 * N_PER_GROUP * 36} MB, one recording at a time, deleted before the next "
      f"is fetched, so at most one recording is ever on disk.")
print(f"run started {RUN_STARTED}")
MNE 1.10.2; helpers from notebooks/_shared (helpers_l7 present: True)
FULL_COHORT = False: 10 participants per group (20 in total, of the 149 released)
Download: about 720 MB, one recording at a time, deleted before the next is fetched, so at most one recording is ever on disk.
run started 2026-09-18T23:37:11+00:00

1 · The target, and what the release determines

Three kinds of material are in play and they are not equally authoritative:

  1. The release itselfparticipants.tsv, the BIDS sidecars, the recordings. Primary.
  2. The site's catalog (data/catalog/datasets/iowapd.mddata/directory.yaml), which records the paper's own reported figures. Secondary but traceable, and the source of every published number quoted below.
  3. The paper's Methods, which this notebook does not read. Everything that would need it carries a literal TODO(confirm) naming the question a reader must take to the PDF.

The published figures this notebook treats as reproduction targets are printed from the catalog rather than typed here, so that a reader can see exactly what is being compared against.

In [2]:
import yaml

_DIR = next((d / "data" / "directory.yaml" for d in (Path.cwd(), *Path.cwd().parents)
             if (d / "data" / "directory.yaml").exists()), None)
DIRECTORY = {e["id"]: e for e in yaml.safe_load(_DIR.read_text())["datasets"]}
TARGET = DIRECTORY["ds-iowapd"]
print("ds-iowapd, as data/directory.yaml records it:")
for k in ("name", "device", "channels", "sfreq_hz", "reference", "online_filters", "mains_hz",
          "population", "paradigms", "sessions", "duration_note", "access", "bids"):
    if k in TARGET:
        print(f"   {k:16s} : {TARGET[k]}")
print(f"   licence          : {TARGET['license']['name']}")
print(f"   dataset DOI      : {TARGET['source']['dataset_doi']}")
print(f"   paper DOI        : {TARGET['citation']['paper_doi']}")
print("   caveats the catalog records, every one of which is part of this analysis:")
for c in TARGET.get("caveats", []):
    print(f"      - {c}")
print()
L5.print_licences("ds-iowapd", notes=True)
ds-iowapd, as data/directory.yaml records it:
   name             : Iowa Parkinson's disease resting EEG ("Rest eyes open")
   device           : Brain Vision system with 64-channel actiCAP (Brain Products)
   reference        : Pz (online)
   online_filters   : 0.1 Hz online high-pass
   mains_hz         : 60
   population       : 100 PD (on dopaminergic medication; 68 M / 32 F; ~68.5 y) + 49 controls (~70.9 y)
   paradigms        : ['rest-eo']
   sessions         : 1
   duration_note    : eyes-open rest, ~3 min on average, once per participant
   access           : open
   bids             : True
   licence          : CC0
   dataset DOI      : 10.18112/openneuro.ds004584.v1.0.0
   paper DOI        : 10.1038/s41531-023-00602-0
   caveats the catalog records, every one of which is part of this analysis:
      - PD recorded on medication (attenuates beta signatures).
      - 100 vs 49 group imbalance.
      - Pz reference channel is flat.

  ds-iowapd — Iowa Parkinson's disease resting EEG ("Rest eyes open"): licence CC0, access open (data/directory.yaml)
In [3]:
# The published figures this notebook tries to reproduce.  Source: data/catalog/datasets/iowapd.md,
# which attributes each to Anjum et al. (2024).  Nothing here is quoted from memory, and the paper's
# own PDF was not read by this notebook.
PUBLISHED = {
    "n_total": {"value": 149, "what": "participants in the release"},
    "n_pd": {"value": 100, "what": "Parkinson's disease, recorded ON dopaminergic medication"},
    "n_hc": {"value": 49, "what": "controls"},
    "age_pd": {"value": (68.53, 8.06), "what": "PD age, mean +- SD (years)"},
    "age_hc": {"value": (70.91, 7.62), "what": "control age, mean +- SD (years)"},
    "sex_pd": {"value": (68, 32), "what": "PD male / female"},
    "sex_hc": {"value": (26, 23), "what": "control male / female"},
    "moca_pd": {"value": (24.31, 4.02), "what": "PD MoCA, mean +- SD"},
    "moca_hc": {"value": (26.67, 1.86), "what": "control MoCA, mean +- SD"},
    "moca_cutoff": {"value": 26, "what": "the paper's dichotomy: impaired < 26, normal 26-30"},
    "rho_leapd_moca": {"value": 0.68, "what": "the PRIMARY RESULT: Spearman rho between the paper's "
                                              "LEAPD index and MoCA, p < 0.001"},
    "n_validation": {"value": 32, "what": "an out-of-sample PD validation set -- NOT part of the release"},
    "channels_analysed": {"value": 60, "what": "electrodes the paper analysed (Iz, I1, I2 and the Pz "
                                               "reference excluded from 64)"},
    "duration": {"value": "~3 min on average", "what": "recording length, eyes open"},
}
print("PUBLISHED FIGURES TREATED AS REPRODUCTION TARGETS")
print("(all from data/catalog/datasets/iowapd.md, attributed there to Anjum et al. 2024)\n")
for k, v in PUBLISHED.items():
    print(f"   {k:20s} {str(v['value']):>18s}   {v['what']}")
print()
print("TODO(confirm), and they matter for section 7: the paper's exact preprocessing chain (the catalog")
print("records FFT-based removal of 60/180/200 Hz components and a 6th-order Butterworth band-pass, and")
print("notes these are the authors' analysis steps rather than anything applied to the released files),")
print("its exact spectral measures, its cross-validation scheme, and whether LEAPD reference code was")
print("published.  None of those could be established from the release, and this notebook does not")
print("guess at any of them.")
PUBLISHED FIGURES TREATED AS REPRODUCTION TARGETS
(all from data/catalog/datasets/iowapd.md, attributed there to Anjum et al. 2024)

   n_total                             149   participants in the release
   n_pd                                100   Parkinson's disease, recorded ON dopaminergic medication
   n_hc                                 49   controls
   age_pd                    (68.53, 8.06)   PD age, mean +- SD (years)
   age_hc                    (70.91, 7.62)   control age, mean +- SD (years)
   sex_pd                         (68, 32)   PD male / female
   sex_hc                         (26, 23)   control male / female
   moca_pd                   (24.31, 4.02)   PD MoCA, mean +- SD
   moca_hc                   (26.67, 1.86)   control MoCA, mean +- SD
   moca_cutoff                          26   the paper's dichotomy: impaired < 26, normal 26-30
   rho_leapd_moca                     0.68   the PRIMARY RESULT: Spearman rho between the paper's LEAPD index and MoCA, p < 0.001
   n_validation                         32   an out-of-sample PD validation set -- NOT part of the release
   channels_analysed                    60   electrodes the paper analysed (Iz, I1, I2 and the Pz reference excluded from 64)
   duration              ~3 min on average   recording length, eyes open

TODO(confirm), and they matter for section 7: the paper's exact preprocessing chain (the catalog
records FFT-based removal of 60/180/200 Hz components and a 6th-order Butterworth band-pass, and
notes these are the authors' analysis steps rather than anything applied to the released files),
its exact spectral measures, its cross-validation scheme, and whether LEAPD reference code was
published.  None of those could be established from the release, and this notebook does not
guess at any of them.

2 · Four tiers, and which one this notebook reaches

"Did it reproduce?" is not one question. These four are the ones people mean, in increasing order of what they would establish and decreasing order of how often they are possible:

Tier What is re-used What it establishes Possible here?
T0 · Re-execution the authors' data and their code the paper's numbers come from its own pipeline No. No analysis code accompanies the release (nb-c6 records this as the single change that would move the most audit items).
T1 · Reproduction of what the release determines the released metadata the published cohort description matches the data as shared Yes, and section 4 does it exactly.
T2 · Independent reproduction of the claim the released raw data, an independent implementation, a plan fixed in advance whether the paper's claim survives a different analyst's reasonable choices Yes, sections 5 and 6, on a documented subset.
T3 · Replication a new sample whether the effect exists in the world rather than in this cohort No. The paper's own out-of-sample set of 32 patients is not in the release.

This notebook reaches T1 and T2. It says so at each result, and the reproducibility statement in section 9 says it again, because a T2 result reported as though it were T0 is the most common way this kind of work is over-claimed.

3 · The preregistration, written before any recording is downloaded

The rubric asks for a plan committed before the analysis. In a repository the evidence is a timestamped commit; here it is the order of the cells plus a SHA-256 digest that every later cell re-checks, so a plan edited after seeing a result announces itself.

At this point nothing but participants.tsv and the kilobyte-sized sidecars have been fetched, and neither contains any EEG. The plan is written against the design of the dataset, not against its signals.

In [4]:
PLAN = """
C7 PREREGISTERED REPRODUCTION PLAN -- Anjum et al. (2024), npj Parkinson's Disease 10:6
Data: OpenNeuro ds004584 v1.0.0 (ds-iowapd), CC0.  Written before any recording was downloaded.

1.  QUESTION      Does a resting-state EEG measure index cognitive function (MoCA) in this cohort, as
                  the paper's title claims?  This is a TIER-2 test of the CLAIM with an independent
                  measure; it is NOT a reimplementation of the paper's LEAPD index and is not
                  expected to reproduce its rho as a like-for-like number.

2.  TIER 1        Reproduce, exactly, every cohort figure the release determines: N per group, age
                  mean and SD per group, sex split per group, MoCA mean and SD per group, and the
                  count impaired (MoCA < 26) per group.  Agreement criterion: means and SDs within
                  0.01 of the published value (the precision they are published to), counts exact.

3.  SAMPLE        The first N_PER_GROUP participants of each group in participants.tsv order, with a
                  non-missing MoCA.  Order is the file's; no recording is inspected before the list
                  is fixed, and no participant is swapped for another afterwards.  A participant
                  whose recording fails the pipeline is REPORTED, not replaced.

4.  PIPELINE      Fixed here, in this order, and applied identically to every participant:
                  (a) drop non-scalp channels and any channel absent from the standard_1005 montage;
                  (b) drop the flat online reference (Pz) and, per the paper, Iz, I1 and I2;
                  (c) 1-45 Hz zero-phase FIR band-pass (the 60 Hz mains sits outside it, so no notch);
                  (d) drop electrodes whose robust amplitude is more than 6 MAD from the montage
                      median, or below a twentieth of it;
                  (e) average reference over the electrodes that remain;
                  (f) 4-s non-overlapping epochs from t = 10 s, up to 120 s;
                  (g) reject an epoch whose demeaned amplitude exceeds 150 uV on any retained channel;
                  (h) Welch PSD, one 4-s Hann window per epoch, averaged over epochs.

5.  MEASURE       PRIMARY: the aperiodic exponent of the 1-40 Hz spectrum (specparam, fixed mode) over
                  a posterior-central electrode set, which is a slope measure and therefore a
                  different quantity from the paper's LEAPD index.
                  SECONDARY: relative theta power, 4-8 Hz over 1-45 Hz, over the same electrodes.
                  Both are fixed here and neither is changed after any result is seen.

6.  TESTS         (i)  Spearman rho between each measure and MoCA, across all analysed participants
                       and within the PD group alone.  Two-sided.  alpha = 0.05.
                  (ii) The paper's own dichotomy: MoCA < 26 versus 26-30, tested as a group difference
                       (Hedges g, Welch t, and a 10,000-draw permutation test).
                  (iii) Group difference PD versus control on each measure, same statistics.
                  No correction across the three families; every p is reported and none is selected.

7.  DIRECTION     Two-sided throughout.  The catalog records that these patients were recorded ON
                  dopaminergic medication, which attenuates the off-state signatures of the disease,
                  so no direction is predicted for the group contrast.

8.  CONFOUNDS     Age and sex are checked in the analysed sample and reported whether or not they are
                  balanced.  The group imbalance (100 vs 49) and the medication state are stated with
                  every group result.

9.  STOPPING      Every participant in the section-3 list is analysed.  Nothing is added, dropped or
                  re-run on the basis of a result.

10. WHAT WOULD    A Spearman rho whose 95 % bootstrap interval excludes zero, in the direction that
    COUNT AS      more impairment accompanies a different value of the measure, would support the
    SUPPORT       paper's claim with an independent measure.  An interval spanning zero on a subset
                  this size would be UNINFORMATIVE rather than contradictory, and section 7 says so
                  before the number is known.
"""
PLAN_DIGEST = hashlib.sha256(PLAN.encode("utf-8")).hexdigest()
PLAN_WRITTEN = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(PLAN)
print(f"plan SHA-256 : {PLAN_DIGEST}")
print(f"written at   : {PLAN_WRITTEN} (UTC)")
print(f"nothing but participants.tsv has been fetched at this point")
C7 PREREGISTERED REPRODUCTION PLAN -- Anjum et al. (2024), npj Parkinson's Disease 10:6
Data: OpenNeuro ds004584 v1.0.0 (ds-iowapd), CC0.  Written before any recording was downloaded.

1.  QUESTION      Does a resting-state EEG measure index cognitive function (MoCA) in this cohort, as
                  the paper's title claims?  This is a TIER-2 test of the CLAIM with an independent
                  measure; it is NOT a reimplementation of the paper's LEAPD index and is not
                  expected to reproduce its rho as a like-for-like number.

2.  TIER 1        Reproduce, exactly, every cohort figure the release determines: N per group, age
                  mean and SD per group, sex split per group, MoCA mean and SD per group, and the
                  count impaired (MoCA < 26) per group.  Agreement criterion: means and SDs within
                  0.01 of the published value (the precision they are published to), counts exact.

3.  SAMPLE        The first N_PER_GROUP participants of each group in participants.tsv order, with a
                  non-missing MoCA.  Order is the file's; no recording is inspected before the list
                  is fixed, and no participant is swapped for another afterwards.  A participant
                  whose recording fails the pipeline is REPORTED, not replaced.

4.  PIPELINE      Fixed here, in this order, and applied identically to every participant:
                  (a) drop non-scalp channels and any channel absent from the standard_1005 montage;
                  (b) drop the flat online reference (Pz) and, per the paper, Iz, I1 and I2;
                  (c) 1-45 Hz zero-phase FIR band-pass (the 60 Hz mains sits outside it, so no notch);
                  (d) drop electrodes whose robust amplitude is more than 6 MAD from the montage
                      median, or below a twentieth of it;
                  (e) average reference over the electrodes that remain;
                  (f) 4-s non-overlapping epochs from t = 10 s, up to 120 s;
                  (g) reject an epoch whose demeaned amplitude exceeds 150 uV on any retained channel;
                  (h) Welch PSD, one 4-s Hann window per epoch, averaged over epochs.

5.  MEASURE       PRIMARY: the aperiodic exponent of the 1-40 Hz spectrum (specparam, fixed mode) over
                  a posterior-central electrode set, which is a slope measure and therefore a
                  different quantity from the paper's LEAPD index.
                  SECONDARY: relative theta power, 4-8 Hz over 1-45 Hz, over the same electrodes.
                  Both are fixed here and neither is changed after any result is seen.

6.  TESTS         (i)  Spearman rho between each measure and MoCA, across all analysed participants
                       and within the PD group alone.  Two-sided.  alpha = 0.05.
                  (ii) The paper's own dichotomy: MoCA < 26 versus 26-30, tested as a group difference
                       (Hedges g, Welch t, and a 10,000-draw permutation test).
                  (iii) Group difference PD versus control on each measure, same statistics.
                  No correction across the three families; every p is reported and none is selected.

7.  DIRECTION     Two-sided throughout.  The catalog records that these patients were recorded ON
                  dopaminergic medication, which attenuates the off-state signatures of the disease,
                  so no direction is predicted for the group contrast.

8.  CONFOUNDS     Age and sex are checked in the analysed sample and reported whether or not they are
                  balanced.  The group imbalance (100 vs 49) and the medication state are stated with
                  every group result.

9.  STOPPING      Every participant in the section-3 list is analysed.  Nothing is added, dropped or
                  re-run on the basis of a result.

10. WHAT WOULD    A Spearman rho whose 95 % bootstrap interval excludes zero, in the direction that
    COUNT AS      more impairment accompanies a different value of the measure, would support the
    SUPPORT       paper's claim with an independent measure.  An interval spanning zero on a subset
                  this size would be UNINFORMATIVE rather than contradictory, and section 7 says so
                  before the number is known.

plan SHA-256 : c572880ff910cabc289ce9798cf5b456e3a9af25dbead13d508212f531a1ed6a
written at   : 2026-09-18T23:37:11+00:00 (UTC)
nothing but participants.tsv has been fetched at this point

4 · Tier 1 — the cohort, reproduced exactly

The first test needs no signal at all. If the released participants.tsv does not reproduce the cohort description in the paper, nothing further is worth doing; and if it does, that is a real result — many releases do not.

In [5]:
assert hashlib.sha256(PLAN.encode("utf-8")).hexdigest() == PLAN_DIGEST, "the plan changed after it was written"
L6.disk_report("before any download", folders={"course downloads": L1.download_dir()})
parts = L6.iowapd_participants()
print(f"\nparticipants.tsv: {len(parts)} rows x {len(parts.columns)} columns {list(parts.columns)}\n")

checks = []


def compare(name, got, published, *, tol=0.011, unit=""):
    if isinstance(published, tuple):
        ok = all(abs(g - p) <= tol for g, p in zip(got, published))
        got_s = " +- ".join(f"{g:.2f}" for g in got)
        pub_s = " +- ".join(f"{p:.2f}" for p in published)
    else:
        ok = (got == published) if isinstance(published, int) else abs(got - published) <= tol
        got_s, pub_s = f"{got}", f"{published}"
    checks.append({"figure": name, "released data": got_s + unit, "published": pub_s + unit,
                   "agrees": ok})
    return ok


compare("N total", len(parts), PUBLISHED["n_total"]["value"])
compare("N Parkinson's disease", int((parts.GROUP == "PD").sum()), PUBLISHED["n_pd"]["value"])
compare("N control", int((parts.GROUP == "Control").sum()), PUBLISHED["n_hc"]["value"])
for grp, key in (("PD", "age_pd"), ("Control", "age_hc")):
    a = parts.loc[parts.GROUP == grp, "AGE"].astype(float)
    compare(f"age {grp}", (a.mean(), a.std(ddof=1)), PUBLISHED[key]["value"], unit=" y")
for grp, key in (("PD", "moca_pd"), ("Control", "moca_hc")):
    m = parts.loc[parts.GROUP == grp, "MOCA"].astype(float).dropna()
    compare(f"MoCA {grp}", (m.mean(), m.std(ddof=1)), PUBLISHED[key]["value"])
for grp, key in (("PD", "sex_pd"), ("Control", "sex_hc")):
    s = parts.loc[parts.GROUP == grp, "GENDER"]
    counts = (int((s.astype(str).str.upper().str[0] == "M").sum()),
              int((s.astype(str).str.upper().str[0] == "F").sum()))
    checks.append({"figure": f"sex {grp} (M/F)", "released data": f"{counts[0]}/{counts[1]}",
                   "published": f"{PUBLISHED[key]['value'][0]}/{PUBLISHED[key]['value'][1]}",
                   "agrees": counts == tuple(PUBLISHED[key]["value"])})
tier1 = pd.DataFrame(checks)
print("TIER 1 -- the cohort description, released data against the published figures:\n")
print(tier1.to_string(index=False))
n_ok = int(tier1.agrees.sum())
print(f"\n{n_ok} of {len(tier1)} figures agree within the plan's criterion (means and SDs to 0.01, "
      f"counts exact).")
near = tier1[[a != b for a, b in zip(tier1["released data"], tier1["published"])]]
if len(near):
    print(f"   {len(near)} differ in the last printed digit and are inside that tolerance: "
          f"{', '.join(near.figure)}.  A published mean rounded from the same numbers can differ by")
    print(f"   one in the last place, so this is agreement, not a discrepancy -- and the criterion was")
    print(f"   fixed in the plan before the comparison, which is the only reason that sentence is")
    print(f"   allowed to be written after seeing it.")
if n_ok < len(tier1):
    print("The ones that do not are a FINDING and are left as they fell; nothing below is adjusted to")
    print("make them agree.  Section 7 takes each one up.")
free disk before any download: 1.96 GB  (course downloads 0.2 MB)

participants.tsv: 149 rows x 9 columns ['participant_id', 'GROUP', 'ID', 'EEG', 'AGE', 'GENDER', 'MOCA', 'UPDRS', 'TYPE']

TIER 1 -- the cohort description, released data against the published figures:

               figure   released data       published  agrees
              N total             149             149    True
N Parkinson's disease             100             100    True
            N control              49              49    True
               age PD 68.53 +- 8.06 y 68.53 +- 8.06 y    True
          age Control 70.92 +- 7.62 y 70.91 +- 7.62 y    True
              MoCA PD   24.31 +- 4.02   24.31 +- 4.02    True
         MoCA Control   26.67 +- 1.86   26.67 +- 1.86    True
         sex PD (M/F)           68/32           68/32    True
    sex Control (M/F)           26/23           26/23    True

9 of 9 figures agree within the plan's criterion (means and SDs to 0.01, counts exact).
   1 differ in the last printed digit and are inside that tolerance: age Control.  A published mean rounded from the same numbers can differ by
   one in the last place, so this is agreement, not a discrepancy -- and the criterion was
   fixed in the plan before the comparison, which is the only reason that sentence is
   allowed to be written after seeing it.
In [6]:
# The paper's own dichotomy, reproduced from the same table.
cut = PUBLISHED["moca_cutoff"]["value"]
m = parts.dropna(subset=["MOCA"]).copy()
m["impaired"] = m.MOCA < cut
tab = pd.crosstab(m.GROUP, m.impaired).rename(columns={False: f"MoCA >= {cut}", True: f"MoCA < {cut}"})
print(f"Cognitive impairment as the paper defines it (MoCA < {cut}), from participants.tsv:\n")
print(tab.to_string())
print(f"\n   MoCA missing for {int(parts.MOCA.isna().sum())} of {len(parts)} participants")
chi = stats.chi2_contingency(tab.to_numpy())
print(f"   chi-square({chi.dof}) = {chi.statistic:.2f}, p = {chi.pvalue:.2e} -- the groups differ in")
print(f"   how often they are impaired, which is the premise the paper starts from rather than a result")
print(f"   of it.")
Cognitive impairment as the paper defines it (MoCA < 26), from participants.tsv:

impaired  MoCA >= 26  MoCA < 26
GROUP                          
Control           38         11
PD                47         53

   MoCA missing for 0 of 149 participants
   chi-square(1) = 11.31, p = 7.71e-04 -- the groups differ in
   how often they are impaired, which is the premise the paper starts from rather than a result
   of it.

5 · Tier 2 — the pipeline, from raw

Section 4 of the plan, applied to every participant in section 3's list, one recording at a time. The QC table below is the deliverable the rubric asks for: for every participant, what was dropped, how much survived, and what the amplitudes looked like — enough for a reader to see which recordings carried the result.

In [7]:
assert hashlib.sha256(PLAN.encode("utf-8")).hexdigest() == PLAN_DIGEST, "the plan changed after it was written"

# --- plan line 4, written out as code, once ---------------------------------------------------
PAPER_EXCLUDED = ["Iz", "I1", "I2"]        # plan 4(b): the paper's own exclusions, plus the Pz reference
BAND = (1.0, 45.0)
EPOCH_S, EPOCH_REJECT_UV = 4.0, 150.0
SEGMENT_T0, SEGMENT_DUR = 10.0, 120.0
MEASURE_PICKS = ("P3", "Pz", "P4", "CP1", "CP2", "C3", "Cz", "C4", "POz", "O1", "Oz", "O2")
EXPONENT_RANGE = (1.0, 40.0)
THETA, TOTAL = (4.0, 8.0), (1.0, 45.0)


def preprocess(raw):
    """Plan 4(a)-(e).  Returns (raw, qc)."""
    qc = {}
    raw = raw.copy()
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        raw.pick("eeg", exclude=[])
        raw.set_montage("standard_1005", on_missing="ignore", verbose=False)
        mont = raw.get_montage()
        placed = set(mont.ch_names) if mont is not None else set(raw.ch_names)
        drop = [c for c in raw.ch_names
                if c not in placed or c.upper() in L6.NON_SCALP_NAMES or c in PAPER_EXCLUDED]
        qc["dropped_by_rule"] = drop
        if drop:
            raw.drop_channels(drop)
        d = raw.get_data()
        flat = [raw.ch_names[i] for i in range(len(raw.ch_names)) if float(np.std(d[i])) < 1e-9]
        qc["flat"] = flat
        if flat:
            raw.drop_channels(flat)
        raw.filter(BAND[0], BAND[1], picks="eeg", method="fir", fir_design="firwin", phase="zero",
                   verbose=False)
        d = raw.get_data()
        amp = np.median(np.abs(d - np.median(d, axis=1, keepdims=True)), axis=1)
        med = float(np.median(amp))
        mad = float(np.median(np.abs(amp - med))) or float(np.std(amp)) or 1e-30
        z = (amp - med) / (1.4826 * mad)
        bad = [raw.ch_names[i] for i in range(len(raw.ch_names))
               if z[i] > 6.0 or amp[i] < med / 20.0]
        qc["bad_amplitude"] = bad
        if bad and len(bad) < len(raw.ch_names) // 3:
            raw.drop_channels(bad)
    qc["n_channels"] = len(raw.ch_names)
    return raw, qc


def spectra(raw):
    """Plan 4(f)-(h).  Returns (freqs, psd (n_epochs, n_ch, n_freqs), channels, qc)."""
    sf = float(raw.info["sfreq"])
    t1 = min(raw.times[-1], SEGMENT_T0 + SEGMENT_DUR)
    x = raw.get_data(tmin=SEGMENT_T0, tmax=t1) * 1e6
    x = x - x.mean(axis=0, keepdims=True)                        # plan 4(e): average reference
    n = int(round(EPOCH_S * sf))
    n_ep = x.shape[1] // n
    X = x[:, :n_ep * n].reshape(x.shape[0], n_ep, n).transpose(1, 0, 2)
    keep = np.abs(X - X.mean(axis=-1, keepdims=True)).max(axis=(1, 2)) <= EPOCH_REJECT_UV
    if keep.sum() < 4:
        raise RuntimeError(f"only {int(keep.sum())} of {n_ep} epochs survive the criterion")
    freqs, psd = sps.welch(X[keep], fs=sf, window="hann", nperseg=n, noverlap=0,
                           detrend="constant", scaling="density", axis=-1)
    qc = {"n_epochs": int(n_ep), "n_epochs_kept": int(keep.sum()),
          "segment_s": round(float(t1 - SEGMENT_T0), 1), "sfreq": sf,
          "max_uv": float(np.abs(X - X.mean(axis=-1, keepdims=True)).max()),
          "median_uv": float(np.median(np.abs(X - X.mean(axis=-1, keepdims=True))))}
    return freqs, psd, list(raw.ch_names), qc


def measures(freqs, psd, ch_names):
    """Plan 5: the primary and the secondary measure."""
    picks = [i for i, c in enumerate(ch_names) if c in MEASURE_PICKS]
    if len(picks) < 4:
        raise RuntimeError(f"only {len(picks)} of the {len(MEASURE_PICKS)} measurement electrodes survive")
    P = psd.mean(axis=0)[picks].mean(axis=0)
    fit = L1.fit_specparam(freqs, P, freq_range=EXPONENT_RANGE)
    def area(band):
        mm = (freqs >= band[0]) & (freqs <= band[1])
        return float(np.trapezoid(P[mm], freqs[mm]))
    return {"exponent": float(fit["exponent"]), "r_squared": float(fit["r_squared"]),
            "theta_rel": area(THETA) / area(TOTAL), "n_measure_ch": len(picks)}


def analyse(subject):
    files = {}
    try:
        raw, files = L1.load_iowapd(subject, return_paths=True, verbose=False)
        dur = float(raw.times[-1])
        raw, qc1 = preprocess(raw)
        freqs, psd, chs, qc2 = spectra(raw)
        out = {"subject": subject, "ok": True, "reason": "", "duration_s": round(dur, 1)}
        out |= measures(freqs, psd, chs)
        out |= {k: (", ".join(v) if isinstance(v, list) else v) for k, v in {**qc1, **qc2}.items()}
        return out
    except Exception as exc:                                     # noqa: BLE001
        return {"subject": subject, "ok": False, "reason": f"{type(exc).__name__}: {exc}"}
    finally:
        heavy = [p for p in files.values() if Path(p).suffix in (".set", ".fdt")]
        if heavy:
            L1.cleanup(heavy, verbose=False)


# plan line 3: the sample, fixed before any recording is read
have_moca = parts.dropna(subset=["MOCA"])
pd_ids = have_moca.loc[have_moca.GROUP == "PD", "participant_id"].tolist()[:N_PER_GROUP]
hc_ids = have_moca.loc[have_moca.GROUP == "Control", "participant_id"].tolist()[:N_PER_GROUP]
print(f"plan line 3 sample ({len(pd_ids)} PD, {len(hc_ids)} Control), participants.tsv order, "
      f"non-missing MoCA:")
print(f"   PD      : {', '.join(pd_ids)}")
print(f"   Control : {', '.join(hc_ids)}")
t0 = time.time()
rows = []
for sid in pd_ids + hc_ids:
    r = analyse(sid)
    rows.append(r)
    msg = (f"exponent {r['exponent']:.3f}, theta_rel {r['theta_rel']:.4f}, "
           f"{r['n_epochs_kept']}/{r['n_epochs']} epochs" if r["ok"] else r["reason"])
    print(f"  {sid}: {msg}  (free {helpers.free_disk_mb('.') / 1000:.2f} GB)", flush=True)
res = pd.DataFrame(rows)
res["group"] = ["PD"] * len(pd_ids) + ["Control"] * len(hc_ids)
res = res.merge(parts[["participant_id", "AGE", "GENDER", "MOCA", "UPDRS"]],
                left_on="subject", right_on="participant_id", how="left")
COHORT_SECONDS = time.time() - t0
print(f"\n{int(res.ok.sum())} of {len(res)} recordings completed the pipeline in {COHORT_SECONDS:.0f} s")
L6.disk_report("after the cohort loop", folders={"course downloads": L1.download_dir()})
plan line 3 sample (10 PD, 10 Control), participants.tsv order, non-missing MoCA:
   PD      : sub-001, sub-002, sub-003, sub-004, sub-005, sub-006, sub-007, sub-008, sub-009, sub-010
   Control : sub-101, sub-102, sub-103, sub-104, sub-105, sub-106, sub-107, sub-108, sub-109, sub-110
  sub-001: exponent 0.928, theta_rel 0.1663, 25/30 epochs  (free 1.96 GB)
  sub-002: exponent 1.227, theta_rel 0.4254, 29/30 epochs  (free 1.96 GB)
  sub-003: exponent 1.531, theta_rel 0.4610, 14/30 epochs  (free 1.96 GB)
  sub-004: exponent 1.771, theta_rel 0.4754, 23/30 epochs  (free 1.96 GB)
  sub-005: exponent 0.687, theta_rel 0.1388, 9/30 epochs  (free 1.96 GB)
  sub-006: exponent 1.076, theta_rel 0.1550, 24/30 epochs  (free 1.96 GB)
  sub-007: exponent 1.389, theta_rel 0.1264, 17/30 epochs  (free 1.96 GB)
  sub-008: RuntimeError: only 2 of 30 epochs survive the criterion  (free 1.96 GB)
  sub-009: RuntimeError: only 3 of 30 epochs survive the criterion  (free 1.96 GB)
  sub-010: exponent 1.237, theta_rel 0.5179, 30/30 epochs  (free 1.96 GB)
  sub-101: exponent 0.784, theta_rel 0.0887, 12/30 epochs  (free 1.95 GB)
  sub-102: exponent 1.406, theta_rel 0.0682, 5/30 epochs  (free 1.95 GB)
  sub-103: exponent 1.148, theta_rel 0.1613, 9/30 epochs  (free 1.95 GB)
  sub-104: exponent 1.309, theta_rel 0.1089, 5/30 epochs  (free 1.95 GB)
  sub-105: exponent 0.477, theta_rel 0.0722, 11/30 epochs  (free 1.95 GB)
  sub-106: exponent 1.135, theta_rel 0.1424, 20/30 epochs  (free 1.95 GB)
  sub-107: exponent 0.808, theta_rel 0.0805, 27/30 epochs  (free 1.95 GB)
  sub-108: exponent 1.219, theta_rel 0.1166, 5/30 epochs  (free 1.94 GB)
  sub-109: exponent 0.606, theta_rel 0.0545, 29/30 epochs  (free 1.94 GB)
  sub-110: exponent 0.535, theta_rel 0.1091, 7/30 epochs  (free 1.94 GB)
18 of 20 recordings completed the pipeline in 63 s
free disk after the cohort loop: 1.94 GB  (course downloads 0.2 MB)
Out[7]:
{'free_gb': 1.941766144, 'folders_mb': {'course downloads': 0.154769}}
In [8]:
# The QC report the rubric asks for.
qc_cols = ["subject", "group", "ok", "duration_s", "n_channels", "n_epochs", "n_epochs_kept",
           "median_uv", "max_uv", "flat", "bad_amplitude", "r_squared", "reason"]
qc = res[[c for c in qc_cols if c in res.columns]].copy()
for c in ("median_uv", "max_uv", "r_squared"):
    if c in qc:
        qc[c] = qc[c].astype(float).round(3)
print("QC report -- every participant, what was dropped and what survived:\n")
print(qc.to_string(index=False))
ok = res[res.ok]
print()
print(f"summary: {len(ok)} of {len(res)} usable; "
      f"{ok.n_channels.min()}-{ok.n_channels.max()} channels retained (median "
      f"{ok.n_channels.median():.0f} of the 64 recorded, after the {len(PAPER_EXCLUDED)} the paper "
      f"excludes and the flat reference);")
print(f"         {ok.n_epochs_kept.min()}-{ok.n_epochs_kept.max()} epochs of "
      f"{ok.n_epochs.min()}-{ok.n_epochs.max()} kept (median {ok.n_epochs_kept.median():.0f}); "
      f"specparam r^2 {ok.r_squared.min():.3f}-{ok.r_squared.max():.3f}")
print()
print(f"What plan line 4(g) cost, stated because the QC table shows it: the 150 uV criterion is")
print(f"evaluated over the WHOLE retained montage, so one bad electrode in a 4-s window rejects the")
print(f"window.  Median {ok.n_epochs_kept.median():.0f} of {ok.n_epochs.median():.0f} epochs survive "
      f"({100 * ok.n_epochs_kept.median() / ok.n_epochs.median():.0f} %), and two recordings kept too few")
print(f"to analyse at all.  nb-7-5 section 8 measures this failure mode directly.  The criterion is NOT")
print(f"revised here: it was pre-specified, and revising a criterion after seeing which subjects it")
print(f"excludes is the thing preregistration exists to prevent.  A per-channel criterion would be the")
print(f"better pre-specification NEXT time, and that is what this sentence is for.")
low_r2 = ok[ok.r_squared < 0.8]
if len(low_r2):
    print(f"\n{len(low_r2)} specparam fit(s) below r^2 = 0.8 "
          f"({', '.join(f'{r.subject} {r.r_squared:.3f}' for _, r in low_r2.iterrows())}); their exponents")
    print(f"are kept and reported because the plan did not pre-specify a fit-quality exclusion, and")
    print(f"adding one now would be a post-hoc rule.  A reader who wants them dropped can see which.")
if (~res.ok).any():
    print(f"\nFAILED, reported and not replaced (plan line 3):")
    print(res.loc[~res.ok, ["subject", "group", "reason"]].to_string(index=False))
    print(f"   The analysed sample is therefore {int((ok.group == 'PD').sum())} PD and "
          f"{int((ok.group == 'Control').sum())} Control, not {N_PER_GROUP} and {N_PER_GROUP}.")
    print(f"   Every number below is computed on the sample that survived, and the sample that survived")
    print(f"   is printed beside it -- the Phase 3 defect notebooks/README.md records is a cohort that")
    print(f"   silently shrank.")
QC report -- every participant, what was dropped and what survived:

subject   group    ok  duration_s  n_channels  n_epochs  n_epochs_kept  median_uv   max_uv flat bad_amplitude  r_squared                                                  reason
sub-001      PD  True       281.7        62.0      30.0           25.0      6.361  167.562                AF8      0.903                                                        
sub-002      PD  True       326.0        63.0      30.0           29.0      5.813  161.070                         0.987                                                        
sub-003      PD  True       252.4        62.0      30.0           14.0      7.963  322.948                CPz      0.989                                                        
sub-004      PD  True       264.0        63.0      30.0           23.0      4.181  211.334                         0.991                                                        
sub-005      PD  True       249.5        63.0      30.0            9.0      5.649 1662.911                         0.705                                                        
sub-006      PD  True       262.1        63.0      30.0           24.0      5.084 2306.512                         0.987                                                        
sub-007      PD  True       239.8        62.0      30.0           17.0      7.904  216.611                 P2      0.988                                                        
sub-008      PD False         NaN         NaN       NaN            NaN        NaN      NaN  NaN           NaN        NaN RuntimeError: only 2 of 30 epochs survive the criterion
sub-009      PD False         NaN         NaN       NaN            NaN        NaN      NaN  NaN           NaN        NaN RuntimeError: only 3 of 30 epochs survive the criterion
sub-010      PD  True       342.7        60.0      30.0           30.0      4.431   78.335                         0.994                                                        
sub-101 Control  True       268.9        63.0      30.0           12.0      4.911  390.495                         0.901                                                        
sub-102 Control  True       210.9        63.0      30.0            5.0      4.672  359.748                         0.975                                                        
sub-103 Control  True       236.5        60.0      30.0            9.0      4.940 1500.316                         0.957                                                        
sub-104 Control  True       217.9        60.0      30.0            5.0      5.857  742.706                         0.962                                                        
sub-105 Control  True       234.2        60.0      30.0           11.0      5.247 1914.573                         0.643                                                        
sub-106 Control  True       212.9        60.0      30.0           20.0      4.432  407.761                         0.979                                                        
sub-107 Control  True       225.6        60.0      30.0           27.0      3.483  232.823                         0.963                                                        
sub-108 Control  True       262.7        60.0      30.0            5.0      3.727  287.093                         0.961                                                        
sub-109 Control  True       236.3        60.0      30.0           29.0      3.576  179.971                         0.939                                                        
sub-110 Control  True       214.8        60.0      30.0            7.0      3.385  234.488                         0.695                                                        

summary: 18 of 20 usable; 60.0-63.0 channels retained (median 61 of the 64 recorded, after the 3 the paper excludes and the flat reference);
         5.0-30.0 epochs of 30.0-30.0 kept (median 16); specparam r^2 0.643-0.994

What plan line 4(g) cost, stated because the QC table shows it: the 150 uV criterion is
evaluated over the WHOLE retained montage, so one bad electrode in a 4-s window rejects the
window.  Median 16 of 30 epochs survive (52 %), and two recordings kept too few
to analyse at all.  nb-7-5 section 8 measures this failure mode directly.  The criterion is NOT
revised here: it was pre-specified, and revising a criterion after seeing which subjects it
excludes is the thing preregistration exists to prevent.  A per-channel criterion would be the
better pre-specification NEXT time, and that is what this sentence is for.

3 specparam fit(s) below r^2 = 0.8 (sub-005 0.705, sub-105 0.643, sub-110 0.695); their exponents
are kept and reported because the plan did not pre-specify a fit-quality exclusion, and
adding one now would be a post-hoc rule.  A reader who wants them dropped can see which.

FAILED, reported and not replaced (plan line 3):
subject group                                                  reason
sub-008    PD RuntimeError: only 2 of 30 epochs survive the criterion
sub-009    PD RuntimeError: only 3 of 30 epochs survive the criterion
   The analysed sample is therefore 8 PD and 10 Control, not 10 and 10.
   Every number below is computed on the sample that survived, and the sample that survived
   is printed beside it -- the Phase 3 defect notebooks/README.md records is a cohort that
   silently shrank.

6 · The tests, in the order the plan wrote them

Nothing below is chosen after the fact. Plan line 6 fixed three families of test and line 9 fixed the stopping rule; all three families are reported whatever they say.

In [9]:
assert hashlib.sha256(PLAN.encode("utf-8")).hexdigest() == PLAN_DIGEST, "the plan changed after it was written"
rng = np.random.default_rng(SEED)


def spearman_with_ci(x, y, *, n_boot=N_BOOT, seed=SEED):
    x = np.asarray(x, float)
    y = np.asarray(y, float)
    m = np.isfinite(x) & np.isfinite(y)
    x, y = x[m], y[m]
    if len(x) < 4:
        return {"n": len(x), "rho": np.nan, "p": np.nan, "ci": (np.nan, np.nan)}
    rho, p = stats.spearmanr(x, y)
    g = np.random.default_rng(seed)
    draws = []
    for _ in range(n_boot):
        i = g.integers(0, len(x), len(x))
        if len(np.unique(x[i])) < 3 or len(np.unique(y[i])) < 3:
            continue
        draws.append(stats.spearmanr(x[i], y[i]).statistic)
    ci = (float(np.percentile(draws, 2.5)), float(np.percentile(draws, 97.5))) if draws else (np.nan, np.nan)
    return {"n": len(x), "rho": float(rho), "p": float(p), "ci": ci}


print("TEST (i) -- plan line 6(i): Spearman rho between each measure and MoCA\n")
corr_rows = []
for measure in ("exponent", "theta_rel"):
    for label, sub in (("all analysed", ok), ("PD only", ok[ok.group == "PD"])):
        r = spearman_with_ci(sub[measure], sub["MOCA"])
        corr_rows.append({"measure": measure, "sample": label, "n": r["n"],
                          "Spearman rho": round(r["rho"], 4),
                          "95% CI (bootstrap)": f"[{r['ci'][0]:+.3f}, {r['ci'][1]:+.3f}]",
                          "p": round(r["p"], 4),
                          "excludes zero": not (r["ci"][0] <= 0 <= r["ci"][1])})
corr = pd.DataFrame(corr_rows)
print(corr.to_string(index=False))
print()
print(f"The paper's PRIMARY RESULT, for comparison and NOT as a like-for-like target: "
      f"rho = {PUBLISHED['rho_leapd_moca']['value']} (p < 0.001) between its LEAPD index and MoCA,")
print(f"on the full cohort of {PUBLISHED['n_total']['value']}.  Section 7 lists every way in which the")
print("two numbers are answers to different questions.")
TEST (i) -- plan line 6(i): Spearman rho between each measure and MoCA

  measure       sample  n  Spearman rho 95% CI (bootstrap)      p  excludes zero
 exponent all analysed 18       -0.1411   [-0.541, +0.272] 0.5766          False
 exponent      PD only  8        0.3333   [-0.600, +0.842] 0.4198          False
theta_rel all analysed 18       -0.4368   [-0.701, +0.024] 0.0699          False
theta_rel      PD only  8       -0.1905   [-0.801, +0.655] 0.6514          False

The paper's PRIMARY RESULT, for comparison and NOT as a like-for-like target: rho = 0.68 (p < 0.001) between its LEAPD index and MoCA,
on the full cohort of 149.  Section 7 lists every way in which the
two numbers are answers to different questions.
In [10]:
print("TEST (ii) -- plan line 6(ii): the paper's own dichotomy, MoCA < 26 versus 26-30\n")
di_rows = []
for measure in ("exponent", "theta_rel"):
    a = ok.loc[ok.MOCA < cut, measure].astype(float).dropna().to_numpy()
    b = ok.loc[ok.MOCA >= cut, measure].astype(float).dropna().to_numpy()
    if min(len(a), len(b)) < 3:
        di_rows.append({"measure": measure, "n impaired": len(a), "n normal": len(b),
                        "Hedges g": np.nan, "95% CI": "n/a", "Welch p": np.nan, "perm p": np.nan})
        continue
    g = L6.hedges_g(a, b)
    t_obs, p_par = stats.ttest_ind(a, b, equal_var=False)
    pooled = np.r_[a, b]
    gg = np.random.default_rng(SEED)
    null = np.array([stats.ttest_ind(p[:len(a)], p[len(a):], equal_var=False).statistic
                     for p in (gg.permutation(pooled) for _ in range(N_PERM))])
    p_perm = (1 + int((np.abs(null) >= abs(t_obs)).sum())) / (1 + N_PERM)
    di_rows.append({"measure": measure, "n impaired": len(a), "n normal": len(b),
                    "impaired mean": round(a.mean(), 4), "normal mean": round(b.mean(), 4),
                    "Hedges g": round(g["g"], 4),
                    "95% CI": f"[{g['ci'][0]:+.3f}, {g['ci'][1]:+.3f}]",
                    "Welch p": round(float(p_par), 4), "perm p": round(p_perm, 4)})
dicho = pd.DataFrame(di_rows)
print(dicho.to_string(index=False))
print(f"\n({N_PERM:,} permutations, seed {SEED}; the permutation p is the one to read, because n is small "
      f"and the Welch p assumes more than the data support.)")
TEST (ii) -- plan line 6(ii): the paper's own dichotomy, MoCA < 26 versus 26-30

  measure  n impaired  n normal  impaired mean  normal mean  Hedges g           95% CI  Welch p  perm p
 exponent           7        11         1.1738       1.0051    0.4428 [-0.516, +1.401]   0.3376  0.3362
theta_rel           7        11         0.2860       0.1333    1.0319 [+0.026, +2.038]   0.0729  0.0953

(10,000 permutations, seed 20260918; the permutation p is the one to read, because n is small and the Welch p assumes more than the data support.)
In [11]:
print("TEST (iii) -- plan line 6(iii): the group contrast, PD versus control\n")
grp_rows = []
for measure in ("exponent", "theta_rel"):
    a = ok.loc[ok.group == "PD", measure].astype(float).dropna().to_numpy()
    b = ok.loc[ok.group == "Control", measure].astype(float).dropna().to_numpy()
    g = L6.hedges_g(a, b)
    t_obs, p_par = stats.ttest_ind(a, b, equal_var=False)
    gg = np.random.default_rng(SEED + 1)
    pooled = np.r_[a, b]
    null = np.array([stats.ttest_ind(p[:len(a)], p[len(a):], equal_var=False).statistic
                     for p in (gg.permutation(pooled) for _ in range(N_PERM))])
    p_perm = (1 + int((np.abs(null) >= abs(t_obs)).sum())) / (1 + N_PERM)
    grp_rows.append({"measure": measure, "n PD": len(a), "n HC": len(b),
                     "PD mean": round(a.mean(), 4), "HC mean": round(b.mean(), 4),
                     "Hedges g": round(g["g"], 4),
                     "95% CI": f"[{g['ci'][0]:+.3f}, {g['ci'][1]:+.3f}]",
                     "Welch p": round(float(p_par), 4), "perm p": round(p_perm, 4)})
group = pd.DataFrame(grp_rows)
print(group.to_string(index=False))
print()
print("Plan line 7: no direction was predicted, because these patients were recorded ON dopaminergic")
print("medication and the catalog records that this attenuates the disease's oscillatory signatures.")
print("A null here is therefore weak evidence about Parkinson's disease and strong evidence about")
print("nothing at all -- which is why the plan made the MoCA correlation, not this contrast, the test")
print("of the paper's claim.")
print()
print("Plan line 8: the confounds in the ANALYSED sample.")
for col in ("AGE",):
    a = ok.loc[ok.group == "PD", col].astype(float)
    b = ok.loc[ok.group == "Control", col].astype(float)
    t, p = stats.ttest_ind(a, b, equal_var=False, nan_policy="omit")
    print(f"   {col:5s}: PD {a.mean():.1f} +- {a.std(ddof=1):.1f}, "
          f"Control {b.mean():.1f} +- {b.std(ddof=1):.1f}; Welch t = {t:.2f}, p = {p:.3f}")
print(f"   sex  : {ok.groupby(['group', 'GENDER']).size().to_dict()}")
print(f"   MoCA : PD {ok.loc[ok.group == 'PD', 'MOCA'].mean():.2f}, "
      f"Control {ok.loc[ok.group == 'Control', 'MOCA'].mean():.2f} "
      f"(cohort values {PUBLISHED['moca_pd']['value'][0]} and {PUBLISHED['moca_hc']['value'][0]})")
TEST (iii) -- plan line 6(iii): the group contrast, PD versus control

  measure  n PD  n HC  PD mean  HC mean  Hedges g           95% CI  Welch p  perm p
 exponent     8    10   1.2307   0.9427    0.8045 [-0.162, +1.771]   0.0952  0.0882
theta_rel     8    10   0.3083   0.1002    1.6720 [+0.594, +2.750]   0.0118  0.0004

Plan line 7: no direction was predicted, because these patients were recorded ON dopaminergic
medication and the catalog records that this attenuates the disease's oscillatory signatures.
A null here is therefore weak evidence about Parkinson's disease and strong evidence about
nothing at all -- which is why the plan made the MoCA correlation, not this contrast, the test
of the paper's claim.

Plan line 8: the confounds in the ANALYSED sample.
   AGE  : PD 71.8 +- 8.7, Control 71.3 +- 6.2; Welch t = 0.12, p = 0.904
   sex  : {('Control', 'F'): 6, ('Control', 'M'): 4, ('PD', 'F'): 2, ('PD', 'M'): 6}
   MoCA : PD 23.38, Control 26.40 (cohort values 24.31 and 26.67)
In [12]:
fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.3))

ax = axes[0]
for grp, colour in (("Control", "tab:blue"), ("PD", "tab:orange")):
    sub = ok[ok.group == grp]
    ax.scatter(sub.MOCA, sub.exponent, s=48, alpha=0.85, color=colour,
               label=f"{grp} (n = {len(sub)})")
xs = ok.MOCA.astype(float).to_numpy()
ys = ok.exponent.astype(float).to_numpy()
mfin = np.isfinite(xs) & np.isfinite(ys)
if mfin.sum() >= 3:
    b1, b0 = np.polyfit(xs[mfin], ys[mfin], 1)
    xr = np.linspace(xs[mfin].min(), xs[mfin].max(), 20)
    ax.plot(xr, b0 + b1 * xr, color="k", lw=1.3)
ax.axvline(cut, color="0.6", lw=1.0, ls="--")
ax.text(cut + 0.15, ax.get_ylim()[1] * 0.98, f"MoCA = {cut}", fontsize=7.5, va="top", color="0.4")
rho_all = corr[(corr.measure == "exponent") & (corr["sample"] == "all analysed")].iloc[0]
ax.set(xlabel="MoCA (points, 0–30)", ylabel="Aperiodic exponent, 1–40 Hz (dimensionless)",
       title=f"Primary measure against cognition\nSpearman ρ = {rho_all['Spearman rho']:+.3f} "
             f"{rho_all['95% CI (bootstrap)']}")
ax.legend(fontsize=8)
ax.grid(alpha=0.3)

ax = axes[1]
for i, grp in enumerate(("Control", "PD")):
    v = ok.loc[ok.group == grp, "exponent"].astype(float).to_numpy()
    ax.scatter(np.full(len(v), i) + rng.normal(0, 0.05, len(v)), v, s=44, alpha=0.85)
    ax.hlines(v.mean(), i - 0.22, i + 0.22, color="k", lw=2.2)
g_exp = group[group.measure == "exponent"].iloc[0]
ax.set_xticks([0, 1], ["Control", "PD (on medication)"], fontsize=8)
ax.set(ylabel="Aperiodic exponent, 1–40 Hz (dimensionless)",
       title=f"Group contrast (not the claim being tested)\ng = {g_exp['Hedges g']:+.2f} "
             f"{g_exp['95% CI']}, permutation p = {g_exp['perm p']:.3f}")
ax.grid(alpha=0.3)

ax = axes[2]
labels, vals, cis = [], [], []
for _, r in corr.iterrows():
    labels.append(f"{r['measure']}\n{r['sample']} (n={int(r['n'])})")
    vals.append(r["Spearman rho"])
    lo, hi = (float(x) for x in r["95% CI (bootstrap)"].strip("[]").split(","))
    cis.append((r["Spearman rho"] - lo, hi - r["Spearman rho"]))
ax.errorbar(range(len(vals)), vals, yerr=np.array(cis).T, fmt="o", capsize=4, lw=1.4, ms=7)
ax.axhline(0, color="k", lw=0.9)
ax.axhline(PUBLISHED["rho_leapd_moca"]["value"], color="tab:red", lw=1.3, ls="--")
ax.text(len(vals) - 0.5, PUBLISHED["rho_leapd_moca"]["value"] + 0.02,
        f"the paper's LEAPD ρ = {PUBLISHED['rho_leapd_moca']['value']}\n(a different measure, full cohort)",
        fontsize=7, color="tab:red", ha="right")
ax.set_xticks(range(len(vals)), labels, fontsize=7)
ax.set(ylabel="Spearman ρ with MoCA (dimensionless)", ylim=(-1.05, 1.05),
       title="This notebook's correlations, with bootstrap intervals")
ax.grid(alpha=0.3, axis="y")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-c7-reproduction, an output plot. The text around it states what it shows and the units of every axis.

7 · Divergence — what it does and does not imply

This is the section the rubric means by divergences from the paper explained, and it is written to a rule: every difference is listed before any of them is called the explanation. A divergence attributed to the one difference that flatters the analyst is not an explanation.

In [13]:
DIFFERENCES = [
    ("The measure is not the paper's.",
     "The paper's primary index is LEAPD, a linear-predictive-coding measure; the primary measure here is "
     "the aperiodic exponent of the 1-40 Hz spectrum.  These are different quantities computed from the "
     "same recordings, so their correlations with MoCA are not two estimates of one thing.  This is the "
     "LARGEST difference on the list and it is a deliberate scope decision, stated in section 0.",
     "fatal to a like-for-like comparison of rho"),
    ("The sample is a documented subset.",
     f"{N_PER_GROUP} per group by the plan's fixed rule against the paper's {PUBLISHED['n_total']['value']}. "
     f"nb-6-3-power-sim measured, ON THIS DATASET, that a pilot of this size implies a RANGE of effect "
     f"sizes wide enough that its point estimate is close to useless alone.  FULL_COHORT = True raises it.",
     "widens every interval; does not bias any estimate"),
    ("The preprocessing chain differs and cannot be matched.",
     "The catalog records that the authors removed 60, 180 and 200 Hz components by FFT subtraction and "
     "band-passed with a 6th-order Butterworth -- and notes these are their ANALYSIS steps, not something "
     "applied to the released files.  The plan's chain is a 1-45 Hz zero-phase FIR with no notch (the "
     "mains sits outside the band).  TODO(confirm): the exact cutoffs and the epoching.",
     "unknown size; a slope measure is sensitive to the band it is fitted over"),
    ("The electrode set differs slightly.",
     f"The paper analysed {PUBLISHED['channels_analysed']['value']} electrodes (dropping Iz, I1, I2 and the "
     f"Pz reference).  This plan drops the same four and then drops amplitude outliers per recording, so "
     f"the count varies by participant and is reported per participant in the QC table.",
     "small"),
    ("The statistical test is not identical.",
     "A Spearman correlation on a subset against whatever the paper used for its rho -- TODO(confirm), "
     "since the release does not carry the analysis code and this notebook did not read the Methods.",
     "unknown"),
    ("The out-of-sample validation set is not in the release.",
     f"The paper additionally validated on {PUBLISHED['n_validation']['value']} further PD patients who are "
     f"not part of ds004584.  No tier-3 replication is possible from the release at all.",
     "closes tier 3 entirely"),
]
print("EVERY difference between this analysis and the paper's, listed before any is used as an "
      "explanation:\n")
for i, (what, detail, size) in enumerate(DIFFERENCES, 1):
    print(f"{i}. {what}")
    print(f"   {detail}")
    print(f"   expected effect on the comparison: {size}\n")
print("WHAT THIS NOTEBOOK'S RESULT DOES ESTABLISH")
print("   An independent analyst, working from the released raw data with a plan fixed in advance, "
      "obtained")
print("   the numbers in section 6 for the measures in section 5.  That is a tier-2 result and it is a "
      "real one.")
print()
print("WHAT IT DOES NOT ESTABLISH")
print("   - Nothing about whether LEAPD correlates with MoCA at 0.68.  That was not measured.")
print("   - Nothing about whether the paper is right or wrong.  A different measure landing somewhere")
print("     else is the expected outcome of measuring something else.")
print("   - Nothing about the effect in a new sample, which is tier 3 and needs data the release does")
print("     not contain.")
print()
print("WHAT WOULD MOVE IT")
print("   - Reimplementing LEAPD from the paper's description would make the comparison like-for-like")
print("     and would turn any remaining difference into information.  That is a defensible next step")
print("     and a substantial one: it is a research project, not a cell in a notebook.")
print("   - The authors publishing their analysis code would collapse the whole of tier 0 into an")
print("     afternoon.  nb-c6-rigor-audit records this as the single change that would move the most")
print("     items in its audit, and it is the same change.")
EVERY difference between this analysis and the paper's, listed before any is used as an explanation:

1. The measure is not the paper's.
   The paper's primary index is LEAPD, a linear-predictive-coding measure; the primary measure here is the aperiodic exponent of the 1-40 Hz spectrum.  These are different quantities computed from the same recordings, so their correlations with MoCA are not two estimates of one thing.  This is the LARGEST difference on the list and it is a deliberate scope decision, stated in section 0.
   expected effect on the comparison: fatal to a like-for-like comparison of rho

2. The sample is a documented subset.
   10 per group by the plan's fixed rule against the paper's 149. nb-6-3-power-sim measured, ON THIS DATASET, that a pilot of this size implies a RANGE of effect sizes wide enough that its point estimate is close to useless alone.  FULL_COHORT = True raises it.
   expected effect on the comparison: widens every interval; does not bias any estimate

3. The preprocessing chain differs and cannot be matched.
   The catalog records that the authors removed 60, 180 and 200 Hz components by FFT subtraction and band-passed with a 6th-order Butterworth -- and notes these are their ANALYSIS steps, not something applied to the released files.  The plan's chain is a 1-45 Hz zero-phase FIR with no notch (the mains sits outside the band).  TODO(confirm): the exact cutoffs and the epoching.
   expected effect on the comparison: unknown size; a slope measure is sensitive to the band it is fitted over

4. The electrode set differs slightly.
   The paper analysed 60 electrodes (dropping Iz, I1, I2 and the Pz reference).  This plan drops the same four and then drops amplitude outliers per recording, so the count varies by participant and is reported per participant in the QC table.
   expected effect on the comparison: small

5. The statistical test is not identical.
   A Spearman correlation on a subset against whatever the paper used for its rho -- TODO(confirm), since the release does not carry the analysis code and this notebook did not read the Methods.
   expected effect on the comparison: unknown

6. The out-of-sample validation set is not in the release.
   The paper additionally validated on 32 further PD patients who are not part of ds004584.  No tier-3 replication is possible from the release at all.
   expected effect on the comparison: closes tier 3 entirely

WHAT THIS NOTEBOOK'S RESULT DOES ESTABLISH
   An independent analyst, working from the released raw data with a plan fixed in advance, obtained
   the numbers in section 6 for the measures in section 5.  That is a tier-2 result and it is a real one.

WHAT IT DOES NOT ESTABLISH
   - Nothing about whether LEAPD correlates with MoCA at 0.68.  That was not measured.
   - Nothing about whether the paper is right or wrong.  A different measure landing somewhere
     else is the expected outcome of measuring something else.
   - Nothing about the effect in a new sample, which is tier 3 and needs data the release does
     not contain.

WHAT WOULD MOVE IT
   - Reimplementing LEAPD from the paper's description would make the comparison like-for-like
     and would turn any remaining difference into information.  That is a defensible next step
     and a substantial one: it is a research project, not a cell in a notebook.
   - The authors publishing their analysis code would collapse the whole of tier 0 into an
     afternoon.  nb-c6-rigor-audit records this as the single change that would move the most
     items in its audit, and it is the same change.

8 · The reproducibility statement

Everything a second person needs in order to obtain these numbers, and everything that would stop them. Generated from the run rather than written by hand, because a statement that is typed can be stale.

In [14]:
def digest_of(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]


versions = L2.package_versions(("specparam", "sklearn", "statsmodels"))
STATEMENT = f"""
REPRODUCIBILITY STATEMENT -- nb-c7-reproduction

WHAT WAS DONE
  A tier-2 independent reproduction (see section 2) of the CLAIM of Anjum et al. (2024),
  npj Parkinson's Disease 10:6, DOI 10.1038/s41531-023-00602-0, using the released raw data and a
  pre-specified plan.  Tier 1 (the cohort description) was reproduced exactly where it could be:
  {int(tier1.agrees.sum())} of {len(tier1)} published cohort figures agree with the released
  participants.tsv.

DATA
  ds-iowapd / OpenNeuro ds004584 v1.0.0, DOI {TARGET['source']['dataset_doi']}, licence
  {TARGET['license']['name']}, access {TARGET['access']}.  Downloaded per participant over HTTPS from the
  public OpenNeuro bucket; each recording was deleted before the next was fetched.  No dataset file is
  redistributed by this notebook and no derived asset is shipped.
  Participants analysed: {int(res.ok.sum())} of {len(res)} attempted, selected by the plan's fixed rule
  (the first {N_PER_GROUP} of each group in participants.tsv order with a non-missing MoCA).
  FULL_COHORT = {FULL_COHORT}.

PLAN
  SHA-256 {PLAN_DIGEST}
  written {PLAN_WRITTEN} (UTC), before any recording was downloaded, and re-checked by assertion at the
  head of every analysis cell.  The plan text is printed in full in section 3.

CODE
  This notebook plus notebooks/_shared/helpers.py, helpers_l1.py, helpers_l2.py, helpers_l5.py and
  helpers_l6.py from the same repository checkout.  No absolute paths; the notebook runs from
  notebooks/capstones/ or notebooks/.

ENVIRONMENT
  python {platform.python_version()} on {platform.system()} {platform.machine()}
  {', '.join(f'{k} {v}' for k, v in sorted(versions.items()))}

RANDOMNESS
  seed {SEED} for every generator; {N_PERM:,} permutations per permutation test and {N_BOOT:,} bootstrap
  draws per interval.  The permutation and bootstrap results are reproducible to the digit with this seed
  and this NumPy version; they will move in the last digits with a different NumPy Generator
  implementation, which is a property of the method and not of the result.

WHAT A SECOND PERSON NEEDS
  The repository, an internet connection, about {2 * N_PER_GROUP * 36} MB of transfer and about
  {(time.time() - T_NOTEBOOK) / 60:.1f} minutes of wall clock on the machine that produced these
  outputs (of which {COHORT_SECONDS / 60:.1f} minutes is the cohort loop, download included).
  No account, no data-use agreement, no licence to accept.

WHAT WOULD STOP THEM REPRODUCING THE PAPER ITSELF
  No analysis code accompanies the release; LEAPD is described in the paper and not implemented here; and
  the paper's out-of-sample validation set of {PUBLISHED['n_validation']['value']} patients is not in the
  release.  Sections 2 and 7 give the full list.

SCOPE OF CLAIM
  No claim in this notebook exceeds a tier-2 reproduction on a documented subset with a different measure.
  Every number is reported with the sample it came from and an interval; nothing is described as
  confirming or refuting the paper.
"""
print(STATEMENT)
print(f"statement digest {digest_of(STATEMENT)}   (this run: {RUN_STARTED})")
REPRODUCIBILITY STATEMENT -- nb-c7-reproduction

WHAT WAS DONE
  A tier-2 independent reproduction (see section 2) of the CLAIM of Anjum et al. (2024),
  npj Parkinson's Disease 10:6, DOI 10.1038/s41531-023-00602-0, using the released raw data and a
  pre-specified plan.  Tier 1 (the cohort description) was reproduced exactly where it could be:
  9 of 9 published cohort figures agree with the released
  participants.tsv.

DATA
  ds-iowapd / OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0, licence
  CC0, access open.  Downloaded per participant over HTTPS from the
  public OpenNeuro bucket; each recording was deleted before the next was fetched.  No dataset file is
  redistributed by this notebook and no derived asset is shipped.
  Participants analysed: 18 of 20 attempted, selected by the plan's fixed rule
  (the first 10 of each group in participants.tsv order with a non-missing MoCA).
  FULL_COHORT = False.

PLAN
  SHA-256 c572880ff910cabc289ce9798cf5b456e3a9af25dbead13d508212f531a1ed6a
  written 2026-09-18T23:37:11+00:00 (UTC), before any recording was downloaded, and re-checked by assertion at the
  head of every analysis cell.  The plan text is printed in full in section 3.

CODE
  This notebook plus notebooks/_shared/helpers.py, helpers_l1.py, helpers_l2.py, helpers_l5.py and
  helpers_l6.py from the same repository checkout.  No absolute paths; the notebook runs from
  notebooks/capstones/ or notebooks/.

ENVIRONMENT
  python 3.13.5 on Darwin arm64
  asrpy 0.0.8, autoreject 0.5.0, matplotlib 3.10.6, mne 1.10.2, mne_bids 0.19.0, mne_icalabel 0.9.0, numpy 2.1.3, onnxruntime 1.30.0, pyprep 0.9.0, python 3.13.5, scipy 1.15.3, sklearn 1.6.1, specparam 2.0.0rc4, statsmodels 0.14.4

RANDOMNESS
  seed 20260918 for every generator; 10,000 permutations per permutation test and 2,000 bootstrap
  draws per interval.  The permutation and bootstrap results are reproducible to the digit with this seed
  and this NumPy version; they will move in the last digits with a different NumPy Generator
  implementation, which is a property of the method and not of the result.

WHAT A SECOND PERSON NEEDS
  The repository, an internet connection, about 720 MB of transfer and about
  1.3 minutes of wall clock on the machine that produced these
  outputs (of which 1.1 minutes is the cohort loop, download included).
  No account, no data-use agreement, no licence to accept.

WHAT WOULD STOP THEM REPRODUCING THE PAPER ITSELF
  No analysis code accompanies the release; LEAPD is described in the paper and not implemented here; and
  the paper's out-of-sample validation set of 32 patients is not in the
  release.  Sections 2 and 7 give the full list.

SCOPE OF CLAIM
  No claim in this notebook exceeds a tier-2 reproduction on a documented subset with a different measure.
  Every number is reported with the sample it came from and an interval; nothing is described as
  confirming or refuting the paper.

statement digest 99dc9238542361a4   (this run: 2026-09-18T23:37:11+00:00)
In [15]:
# The rubric, item by item, against what this run produced.
RUBRIC = {
    "runs from a clean environment": (
        True, "the setup cell installs the pinned stack when it is absent and imports the helpers "
              "through a relative sys.path insert; no absolute path appears in source or output"),
    "a preregistered plan committed before analysis": (
        True, f"SHA-256 {PLAN_DIGEST[:16]}..., written before any recording was downloaded and asserted "
              f"at the head of sections 4, 5 and 6"),
    "a C2-style pipeline": (
        True, "plan line 4, eight named steps applied identically to every participant, with the "
              "per-participant consequences in the QC table"),
    "QC reports": (
        True, f"section 5: {len(qc)} participants x {len(qc.columns)} recorded quantities, including "
              f"what was dropped and why"),
    "statistics": (
        True, f"three pre-specified families: {len(corr)} correlations, {len(dicho)} dichotomy contrasts "
              f"and {len(group)} group contrasts, each with an interval and a permutation p"),
    "figures": (True, "section 6, three panels, units on every axis"),
    "a written reproducibility statement": (True, f"section 8, digest {digest_of(STATEMENT)}"),
    "every reporting-checklist item present": (
        None, "nb-c6-rigor-audit holds the checklist and audits THIS paper against it; C7 does not "
              "repeat the audit.  What C7 adds is the other half: the items c6 found missing from the "
              "release are exactly the ones that stopped this reproduction at tier 2."),
    "divergences from the paper explained": (
        True, f"section 7 lists {len(DIFFERENCES)} differences before naming any as an explanation"),
    "no claim exceeds the method": (
        True, "section 2 fixes the tier, section 7 states what the result does and does not establish, "
              "and the statement in section 8 repeats the scope"),
}
print("C7 RUBRIC\n")
for item, (ok_, detail) in RUBRIC.items():
    mark = "PASS" if ok_ else ("OPEN" if ok_ is None else "FAIL")
    print(f"[{mark}] {item}\n        {detail}")
print(f"\n{sum(1 for v in RUBRIC.values() if v[0])} of {len(RUBRIC)} items pass; "
      f"{sum(1 for v in RUBRIC.values() if v[0] is None)} is answered by another capstone.")
C7 RUBRIC

[PASS] runs from a clean environment
        the setup cell installs the pinned stack when it is absent and imports the helpers through a relative sys.path insert; no absolute path appears in source or output
[PASS] a preregistered plan committed before analysis
        SHA-256 c572880ff910cabc..., written before any recording was downloaded and asserted at the head of sections 4, 5 and 6
[PASS] a C2-style pipeline
        plan line 4, eight named steps applied identically to every participant, with the per-participant consequences in the QC table
[PASS] QC reports
        section 5: 20 participants x 13 recorded quantities, including what was dropped and why
[PASS] statistics
        three pre-specified families: 4 correlations, 2 dichotomy contrasts and 2 group contrasts, each with an interval and a permutation p
[PASS] figures
        section 6, three panels, units on every axis
[PASS] a written reproducibility statement
        section 8, digest 99dc9238542361a4
[OPEN] every reporting-checklist item present
        nb-c6-rigor-audit holds the checklist and audits THIS paper against it; C7 does not repeat the audit.  What C7 adds is the other half: the items c6 found missing from the release are exactly the ones that stopped this reproduction at tier 2.
[PASS] divergences from the paper explained
        section 7 lists 6 differences before naming any as an explanation
[PASS] no claim exceeds the method
        section 2 fixes the tier, section 7 states what the result does and does not establish, and the statement in section 8 repeats the scope

9 of 10 items pass; 1 is answered by another capstone.
In [16]:
try:
    print("nb-c7-reproduction -- C7 numbers (draft; TODO(confirm) at author review)")
    print()
    print(f"TARGET: Anjum et al. (2024), npj Parkinson's Disease 10:6, "
          f"DOI {TARGET['citation']['paper_doi']}")
    print(f"DATA  : ds-iowapd, OpenNeuro ds004584 v1.0.0, DOI {TARGET['source']['dataset_doi']}, "
          f"licence {TARGET['license']['name']}")
    print(f"TIER  : 1 and 2 of 4 (section 2).  Tier 0 is closed by the absence of analysis code; "
          f"tier 3 by the absence of the validation cohort.")
    print()
    print(f"1. TIER 1 -- the cohort description, {int(tier1.agrees.sum())} of {len(tier1)} published "
          f"figures reproduce from participants.tsv:")
    for _, r in tier1.iterrows():
        print(f"     {'OK ' if r['agrees'] else 'NO '} {r['figure']:24s} released {r['released data']:>18s}"
              f"   published {r['published']:>18s}")
    print()
    print(f"2. PIPELINE -- {int(res.ok.sum())} of {len(res)} recordings completed "
          f"({int((ok.group == 'PD').sum())} PD, {int((ok.group == 'Control').sum())} Control); "
          f"median {ok.n_channels.median():.0f} channels and {ok.n_epochs_kept.median():.0f} of "
          f"{ok.n_epochs.median():.0f} epochs kept")
    if (~res.ok).any():
        for _, r in res[~res.ok].iterrows():
            print(f"     FAILED {r['subject']}: {r['reason']}")
    print()
    print("3. TEST (i) -- Spearman rho with MoCA (the test of the paper's claim):")
    for _, r in corr.iterrows():
        print(f"     {r['measure']:10s} {r['sample']:12s} n = {int(r['n']):2d}   "
              f"rho = {r['Spearman rho']:+.4f} {r['95% CI (bootstrap)']}   p = {r['p']:.4f}   "
              f"{'interval excludes zero' if r['excludes zero'] else 'interval spans zero'}")
    print(f"     the paper reports rho = {PUBLISHED['rho_leapd_moca']['value']} (p < 0.001) for LEAPD on "
          f"all {PUBLISHED['n_total']['value']} participants -- a DIFFERENT MEASURE on a LARGER SAMPLE, "
          f"so this is a comparison of two questions, not two answers")
    print()
    print(f"4. TEST (ii) -- the paper's dichotomy, MoCA < {cut} versus {cut}-30:")
    for _, r in dicho.iterrows():
        if np.isfinite(r["Hedges g"]):
            print(f"     {r['measure']:10s} impaired n = {int(r['n impaired']):2d} "
                  f"({r['impaired mean']:+.4f}) vs normal n = {int(r['n normal']):2d} "
                  f"({r['normal mean']:+.4f})   g = {r['Hedges g']:+.4f} {r['95% CI']}   "
                  f"permutation p = {r['perm p']:.4f}")
        else:
            print(f"     {r['measure']:10s} not estimable "
                  f"({int(r['n impaired'])} impaired, {int(r['n normal'])} normal)")
    print()
    print("5. TEST (iii) -- PD versus control (NOT the claim; patients were ON medication):")
    for _, r in group.iterrows():
        print(f"     {r['measure']:10s} PD {r['PD mean']:+.4f} vs Control {r['HC mean']:+.4f}   "
              f"g = {r['Hedges g']:+.4f} {r['95% CI']}   permutation p = {r['perm p']:.4f}")
    print()
    print("6. DIVERGENCE: the measure is not the paper's, and that is the first item on section 7's list.")
    print("   A reproduction that lands somewhere else is a normal scientific result.  What this one")
    print("   establishes is a tier-2 number from the released raw data under a plan fixed in advance;")
    print("   what it does not establish is anything about LEAPD, about the paper's correctness, or")
    print("   about a new sample.")
    print()
    print(f"7. REPRODUCIBILITY: plan {PLAN_DIGEST[:16]}..., statement {digest_of(STATEMENT)}, "
          f"seed {SEED}, FULL_COHORT = {FULL_COHORT}")
    print(f"   FULL_COHORT = True would analyse 49 per group (98 recordings, about "
          f"{98 * 36 / 1000:.1f} GB of transfer, roughly "
          f"{COHORT_SECONDS / max(int(res.ok.sum()), 1) * 98 / 60:.0f} minutes at this run's rate) "
          f"and has NOT been executed for these stored outputs.")
    print(f"   rubric: {sum(1 for v in RUBRIC.values() if v[0])} of {len(RUBRIC)} items pass")
finally:
    L6.disk_report("at the end of nb-c7", folders={"course downloads": L1.download_dir()})
nb-c7-reproduction -- C7 numbers (draft; TODO(confirm) at author review)

TARGET: Anjum et al. (2024), npj Parkinson's Disease 10:6, DOI 10.1038/s41531-023-00602-0
DATA  : ds-iowapd, OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0, licence CC0
TIER  : 1 and 2 of 4 (section 2).  Tier 0 is closed by the absence of analysis code; tier 3 by the absence of the validation cohort.

1. TIER 1 -- the cohort description, 9 of 9 published figures reproduce from participants.tsv:
     OK  N total                  released                149   published                149
     OK  N Parkinson's disease    released                100   published                100
     OK  N control                released                 49   published                 49
     OK  age PD                   released    68.53 +- 8.06 y   published    68.53 +- 8.06 y
     OK  age Control              released    70.92 +- 7.62 y   published    70.91 +- 7.62 y
     OK  MoCA PD                  released      24.31 +- 4.02   published      24.31 +- 4.02
     OK  MoCA Control             released      26.67 +- 1.86   published      26.67 +- 1.86
     OK  sex PD (M/F)             released              68/32   published              68/32
     OK  sex Control (M/F)        released              26/23   published              26/23

2. PIPELINE -- 18 of 20 recordings completed (8 PD, 10 Control); median 61 channels and 16 of 30 epochs kept
     FAILED sub-008: RuntimeError: only 2 of 30 epochs survive the criterion
     FAILED sub-009: RuntimeError: only 3 of 30 epochs survive the criterion

3. TEST (i) -- Spearman rho with MoCA (the test of the paper's claim):
     exponent   all analysed n = 18   rho = -0.1411 [-0.541, +0.272]   p = 0.5766   interval spans zero
     exponent   PD only      n =  8   rho = +0.3333 [-0.600, +0.842]   p = 0.4198   interval spans zero
     theta_rel  all analysed n = 18   rho = -0.4368 [-0.701, +0.024]   p = 0.0699   interval spans zero
     theta_rel  PD only      n =  8   rho = -0.1905 [-0.801, +0.655]   p = 0.6514   interval spans zero
     the paper reports rho = 0.68 (p < 0.001) for LEAPD on all 149 participants -- a DIFFERENT MEASURE on a LARGER SAMPLE, so this is a comparison of two questions, not two answers

4. TEST (ii) -- the paper's dichotomy, MoCA < 26 versus 26-30:
     exponent   impaired n =  7 (+1.1738) vs normal n = 11 (+1.0051)   g = +0.4428 [-0.516, +1.401]   permutation p = 0.3362
     theta_rel  impaired n =  7 (+0.2860) vs normal n = 11 (+0.1333)   g = +1.0319 [+0.026, +2.038]   permutation p = 0.0953

5. TEST (iii) -- PD versus control (NOT the claim; patients were ON medication):
     exponent   PD +1.2307 vs Control +0.9427   g = +0.8045 [-0.162, +1.771]   permutation p = 0.0882
     theta_rel  PD +0.3083 vs Control +0.1002   g = +1.6720 [+0.594, +2.750]   permutation p = 0.0004

6. DIVERGENCE: the measure is not the paper's, and that is the first item on section 7's list.
   A reproduction that lands somewhere else is a normal scientific result.  What this one
   establishes is a tier-2 number from the released raw data under a plan fixed in advance;
   what it does not establish is anything about LEAPD, about the paper's correctness, or
   about a new sample.

7. REPRODUCIBILITY: plan c572880ff910cabc..., statement 99dc9238542361a4, seed 20260918, FULL_COHORT = False
   FULL_COHORT = True would analyse 49 per group (98 recordings, about 3.5 GB of transfer, roughly 6 minutes at this run's rate) and has NOT been executed for these stored outputs.
   rubric: 9 of 10 items pass
free disk at the end of nb-c7: 1.94 GB  (course downloads 0.2 MB)