# WP44 — Code audit (Agent N, Reproducibility Auditor), 2026-11-03 Scope: read-only audit of the 14 pipeline scripts listed below against `CLAUDE.md` §1/§6, `RESEARCH_PLAN.md` and `protocol/PROTOCOL.md` (v1.0 + amendments A-000…A-004). No file outside `reports/audit/` was modified; the compute node was not touched. Formula and logic checks were replicated on synthetic data on the laptop (DuckDB 1.5.2, numpy 2.4, pandas 3.0, statsmodels 0.14, networkx 3.x); scratch scripts live in `/tmp/wp44_audit/` and are not part of the repo. Machine-readable list: `reports/audit/code_issues.csv` (67 issues: 2 blocking, 20 major, 45 minor). Severity: **blocking** = produces wrong or missing results silently / crashes a pre-registered analysis; **major** = biases an estimand, contradicts the protocol, or loses data in a way that must be fixed before Phase 4 results enter the manuscript; **minor** = hygiene, documentation, fragility, provenance. ## 0. Verification checks performed (synthetic data) | Check | Result | |---|---| | Eligibility SQL of `define_corpus.py` with NULL `type` / NULL `source_name` | `eligible` becomes NULL → rows vanish from `WHERE eligible` **and** from every exclusion bucket; PRISMA sequential sum 4 of 6 | | `JOIN … USING (year,type,sf)` with NULL `sf` (`03_citation_impact.py`) | 1 of 3 rows survive | | Gini SQL (`03_citation_impact.py` L35) vs reference `2Σ i·x_(i)/(nΣx) − (n+1)/n`, with ties | identical (0.575 = 0.575); NaN when Σ = 0 | | Directed weighted modularity (`02_category_flows.py`) vs `networkx.community.modularity` | identical to 1e-6 | | Bai–Perron DP (`structural_change.py`) vs brute force, n = 30, m = 2, h = 5 | identical SSR and break indices; BIC and LWZ select m = 2 | | ITS design matrix | `post`, `t_post` (0 at event, +1/month), 11 month dummies — correct | | Placebo candidate months (default window 2018-01 → 2026-06) | 66 candidates, **30 after the true event**; placebo p = 0.015 vs 0.000 with baseline-only placebos | | `features_at` (emerging detection) invariance to columns > T | invariant (look-ahead-safe); trailing z-window = 25 months, not 24 | | Cutoff lists (emerging detection) | train 2020-01…2021-10, test 2023-01…2024-10; h = 24 → last test label month 2026-10, last train label month 2023-10 | | `series_categories` reindex on the boundary month (`run_structural_change.py`) | `ValueError: cannot reindex on an axis with duplicate labels` | | BOCPD update equations (NIG / Student-t) | correct by inspection; unit tests exist | | Embedding normalisation assumed by `semantic_atlas.py` | `embed_corpus.py` uses `normalize_embeddings=True` → cosine via dot product valid | Two documented facts in the repository corroborate findings: `data/metadata/linkage_report.json` shows `dblp_with_arxiv: 0` (dead linkage path), and no script ingests `api_increment/pages/` (window end mismatch). ## 1. Per-file findings ### ingestion/openalex_snapshot.py — no blocking/major issue | Line | Sev. | Finding | Fix | |---|---|---|---| | 148–157 | minor | A partial download larger than `expected_len` is resumed with `curl -C -` forever; the part stays in `error` on every run | delete `raw_path` on size mismatch before retrying | | 160–164 | minor | SHA-256 recorded but never compared (manifest has no hash) | capture the S3 ETag and compare; label the hash as provenance | | 188–189 | minor | works with NULL `publication_year` excluded from the CS subset without being counted | ledger column `n_null_year` (PRISMA S0) | | 236–239 | minor | `runs` table lacks git commit / environment hash | add both | Design is sound: resumable SQLite ledger, atomic `.part` renames, bad-line counter, slim table for all 465 M works. ### ingestion/build_corpus.py — no blocking/major issue | Line | Sev. | Finding | Fix | |---|---|---|---| | 69–76 | minor | `ids["arxiv"]` is not in the OpenAlex schema (dead branch); DOI branch keeps `v\d+` while the URL branch strips it | remove dead branch, strip version everywhere | | 144 | minor | `is_primary` by URL equality → 0 or several primaries when URLs are NULL | compare source id + URLs | | 173–176 | minor | no Parquet metadata (snapshot release, commit) | write key/value metadata | ### ingestion/link_sources.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 36, 50–53 | **major** | DBLP→arXiv path yields **0** links (`dblp_with_arxiv: 0`): DBLP CoRR `ee` is now `https://doi.org/10.48550/arXiv.`; the DBLP–arXiv version relation is unavailable to the merge step | derive `arxiv_id` from the DOI `10.48550/arxiv.` too | | 54–60, 67–72, 81–83 | **major** | title+year linkage without the first-author surname required by plan S1 (180 k DBLP, 2.1 k arXiv, 14.9 k ACL links) | add first-author check; report precision by method in the H-1 sample | | 52–83 | minor | `NOT IN (SELECT …)` fragile to NULL keys | `NOT EXISTS` | | 85–118 | minor | report has no `pass` field; committed file is the pass-1 state | add pass + input hashes | ### systematic_review/define_corpus.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 52, 55, 65–66 | **BLOCKING** | NULL `type` or NULL `source_name` (non-DBLP, non-arXiv) → `e2_type`/`e7_generic_deposit` NULL → **`eligible` NULL**: silently dropped by every `WHERE eligible` and by every PRISMA bucket; sequential counts do not sum (verified) | `coalesce(type,'')`, `coalesce(source_name,'')`; assert `count(*) WHERE eligible IS NULL = 0`; PRISMA identity test | | 38–39 | major | `pub` CTE: `NOT is_arxiv_doi` is NULL when DOI is NULL → published records without DOI (common for ML conference venues in OpenAlex) can never absorb their preprint → double counting of the most AI-central population | `coalesce(is_arxiv_doi, FALSE)` | | 58, 63–66 | major | preprint flagged `e5_duplicate` even when the matched published record is itself excluded (E1/E2/E7/NULL) → the work disappears entirely | match only against published records passing e1–e4/e7 | | 36–43 | major | merge by exact normalised title only; arXiv-id relation not used; `version_relationship` (plan S2) not produced; retitled VoRs stay duplicated | add arXiv-id pairs + fuzzy fallback; produce `version_relationship`; report duplicate rate | | 50, 56 | major | `first_date = least(...)` lets a year-precision `YYYY-01-01` beat a precise later arXiv date; `date_precision` is defined on `publication_date`, not on the date used for `first_month` | treat year-precision dates as NULL in `least` when a precise date exists; define precision on `first_date` | | 16, 51 | major | `WINDOW_END = 2026-11-02` but data = snapshot 2026-10-14; the API increment is never ingested | ingest the increment or amend the protocol with the effective cut-off | | 51, 84 | minor | NULL `publication_date` counted as E1 | separate reason code | | 33 | minor | `any_value()` nondeterministic for multi-arXiv works | `arg_min(..., created)` | | 65–69 | minor | eligible works with `period='pre'` in yearly but not monthly tables | decide and document | | 67–68 | minor | boundary 2022-11-30 (day) vs ITS event 2022-12-01 (month) | state it | ### bibliometrics/01_volume_series.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 22–27 | major | indexing-lag table produced but no last-complete-month / completeness correction derived (plan §6); driver hard-codes `--last-complete 2026-06` | estimate completeness from the lag profile; emit `last_complete_month` JSON used by econometrics and emerging detection | | 22–27 | minor | lag computed on year-precision dates too | restrict to `date_precision='month'` | | 13 | minor | `first_month` vs `date_precision` inconsistency (upstream) | — | ### bibliometrics/02_collaboration.py — no formula error found | Line | Sev. | Finding | Fix | |---|---|---|---| | 19–20 | minor | fractional counting = 1/#distinct countries per work, not author-fractional | state the rule | | 21, 24–25 | minor | `share_intl`, `share_company` conditional on affiliation coverage, which drifts by year | add coverage columns | | 14 | minor | retracted and `period='pre'` included | document | ### bibliometrics/03_citation_impact.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 26–29 | major | `JOIN USING (year,type,sf)` drops all works with NULL subfield (verified) | `IS NOT DISTINCT FROM` / coalesce | | 4–5, 20–22, 35 | major | `c12` = calendar years y..y+1 (13–24 months), `c36` = y..y+2 (25–36 months); H5 prescribes a 24-month Gini, code uses c36 | rename to calendar-year windows, use y..y+1 for H5, state the publication-month dependence | | 19, 32–37 | major | truncated windows (c36: 2024–2026; c12: 2025–2026) reported without flag | `window_complete` column; NULL when incomplete | | 19 | major | retracted works included (protocol E6 excludes them from impact analyses) | `AND NOT e6_retracted` | | 20–23 | major | citations of the merged preprint version not added to the published node (plan S2 one node per work) | aggregate `counts_by_year` over {id, preprint_work_id} | | 28 | minor | no minimum cell size for NCS normalisation | threshold + fallback | | 35 | — | Gini formula **correct** (verified) | — | ### knowledge_graph/01_citation_graph.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 19–26 | major | citations to the merged preprint become external edges → merged node loses inbound citations; propagates to 02 and emerging detection | remap `ref_id` via `duplicate_of` | | 23–26 | minor | dangling references (ids not in slim, e.g. merged ids) dropped silently; `merged_ids` not used | remap + report `n_dangling` | | 31–32 | minor | negative citation ages retained | flag/clip | ### knowledge_graph/02_category_flows.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 28–30 | major | inner join on `work_primary` drops unclassified works; shares conditional on classification | `unclassified` pseudo-category + share per year | | 13–17 | — | modularity formula **correct** (directed, weighted; verified) | — | | 52 | minor | no bootstrap CI (P6) | edge-resampling CIs | | 40–41 | minor | `n_month_precision` vs `first_month` inconsistency (upstream) | — | ### econometrics/structural_change.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 43–49 | major | placebo months span the whole sample (30/66 post-event); protocol: baseline only; pre-event placebos estimated on a sample containing the true break | restrict to baseline; fit on pre-event data (or control for the true event) | | 61–101 | major | Bai–Perron: DP + BIC/LWZ correct, but no supF/UDmax/sequential tests, no critical values, no seasonal regressors → H3 "5 % level" untestable | add BP tests/critical values or bootstrap p-values; add month dummies | | 32–36 | minor | fixed NW `maxlags=6` (auto rule → 4); design verified | document; lag sensitivity | | 64, 92–100 | minor | break index convention (first of new regime); `h = max(8, 0.15n)` | document | | 104–109 | minor | PELT penalty uses full-sample variance (inflated by the break) | robust scale | | 112–135 | minor | BOCPD data-dependent prior (first 6 obs), fixed hazard; equations correct | document; hazard sensitivity | ### econometrics/run_structural_change.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 23–28 | **BLOCKING** | `series_categories` sets `first_month` as index without aggregating; the boundary month 2022-11 has `baseline` and `post` rows → duplicate index → `reindex` raises `ValueError` → **all category-level runs (H3) fail** | `g.groupby("first_month")[col].sum()` | | 36, 44 | minor | `log(n+1)` and Bai–Perron/PELT on unadjusted log series for sparse category series | Poisson/NB ITS robustness; deseasonalise | | 53 | minor | `--first 2018-02` unexplained | document or use 2018-01 | | 63 | minor | `sens_tmp` expression always true; sensitivity runs overwrite each other's `placebo_total.csv`/`fit_total.csv` | name outputs by cut-off | | 56–67 | minor | no provenance stamp in `its_results.json` | add hashes/commit | ### taxonomy/classify.py — deterministic; no formula error (tests pass by construction) | Line | Sev. | Finding | Fix | |---|---|---|---| | 108 | minor | `row_number() OVER ()` without `ORDER BY` → chunk composition not reproducible | `ORDER BY id` | | 120–129, 142–143 | minor | shard mode writes no report; outputs not version-stamped (docstring claims it) | stamp ontology version + sha256 | | 146–157 | minor | stale `input_named` symlinks across `--prepare` re-runs | clear in `prepare()` | | 28, 55–57 | minor | regexes compiled without flags; 273/277 patterns carry `(?i)` | compile with `re.I` or lint | ### experiments/emerging_detection.py | Line | Sev. | Finding | Fix | |---|---|---|---| | 27–28, 22–23 | major | `KNOWN_SET` empty; H8 lead-time evaluation (`known_set_lead_times.csv`) not implemented | fill `known_set.csv`; implement lead time | | 78, 83–86, 66–74 | major | panel to 2026-10 (incomplete) while econometrics stops at 2026-06: h = 24 test labels truncated; last train label window (2023-10) overlaps test features (from 2023-01) | `T+h ≤ last_complete`; `last_train+h ≤ first_test` | | 31–38 | major (declare) | units = OpenAlex topics (2024 taxonomy that knows which topics emerged); cutoff-specific semantic clusters of the plan not implemented. Features are look-ahead-safe (verified) | implement cutoff-specific clusters; declare the unit-definition look-ahead | | 40–41 | minor | universe selected with full-period counts | per-cutoff selection | | 56–57 | minor | z-window 25 months, docstring 24 | align | | 85 | minor | train cutoffs 2020-01…2021-10, docstring ≤ 2021-12 | align | | 98, 108–113 | minor | unscaled logistic features; pooled AUC; no CIs | scaler pipeline; per-cutoff bootstrap | Label definition (top decile of `log1p(post) − log1p(pre)` among topics with ≥ 20 works in the last 12 months, and post ≥ 2·pre) matches the docstring and uses only (T−h, T] and (T, T+h] (verified). ### embeddings/semantic_atlas.py — no blocking/major issue | Line | Sev. | Finding | Fix | |---|---|---|---| | 4, 66 | minor | "stratified sample" is a simple random sample | stratify or fix docstring | | 81–87 | minor | split-half ARI inside one UMAP (not bootstrap; UMAP not refitted) | refit per half | | 49–61 | minor | novelty centroids include year-precision months | restrict to month precision | | 30 | minor | duplicate ids across shards silently overwritten | assert uniqueness | | 41 | minor | baseline/post by calendar year instead of `period` | use `period` | | 67–69, 88 | minor | no version stamp (umap/numba/pynndescent) | record versions | ## 2. Cross-cutting observations 1. **Preprint–publication merge is only half-implemented.** `define_corpus.py` marks the preprint as a duplicate but no downstream script (citation graph, citation windows, category flows, emerging detection) re-attributes the preprint's references/citations/`counts_by_year` to the surviving node. Under plan S2 ("one node per work") this undercounts early citations and inter-category flows exactly where CS preprints dominate (AI/ML). This is the single most consequential conceptual gap after the two blocking bugs. 2. **Date semantics are split across three columns** (`publication_date`, `first_date/first_month`, `date_precision`): monthly series use `first_month` but the precision flag is defined on `publication_date`; yearly tables use `publication_year`. The same work can therefore be counted in different years in yearly vs monthly outputs. 3. **Right truncation is handled by a fixed cut-off (2026-06) in econometrics only**; citation windows, emerging-detection labels and the semantic atlas use the data up to the snapshot without a completeness flag. The plan's "indexing-delay profile correction" is not implemented. 4. **Effective data end is 2026-10-14**, not 2026-11-02: the API increment is harvested but never ingested. 5. **Retracted works** are excluded from impact analyses by the protocol but not by the code. 6. **Provenance**: no script stamps the git commit, input hashes or package versions into its outputs; `tests/` covers the pure-python toolkit and the classifier but no DuckDB fixture tests exist for the SQL stages (which is where both blocking bugs sit). Seeds are set where randomness exists (GBM 44, UMAP/HDBSCAN/sample 44); `row_number() OVER ()` and `any_value()` are the only nondeterministic constructs. 7. **Inference**: protocol P6 promises bootstrap/HAC uncertainty on all estimates; ITS has HAC, but Bai–Perron, modularity, cross-area shares, Gini and emerging-detection metrics have no intervals. ## 3. Prioritised fix list **P0 — before any Phase 4 number is produced** 1. `define_corpus.py` L52/L55: `coalesce()` on `type` and `source_name`; add `eligible IS NULL` assertion and a PRISMA identity test (identified = Σ sequential exclusions + after_exclusions). Re-run `define_corpus.py` and every downstream script; compare `prisma_counts.json` before/after. 2. `run_structural_change.py` L28: aggregate by `first_month` (`groupby().sum()`), then re-run. **P1 — before results enter the manuscript** 3. Merge semantics: `coalesce(is_arxiv_doi, FALSE)` in the `pub` CTE; mark `e5_duplicate` only against eligible published records; add arXiv-id-based pairs and `version_relationship`; remap `ref_id` via `duplicate_of` in `01_citation_graph.py`; sum `counts_by_year` over both versions in `03_citation_impact.py`. 4. `03_citation_impact.py`: NULL-safe normalisation join; `window_complete` flag; exclude `e6_retracted`; rename windows to calendar-year semantics and compute H5 on the y..y+1 window. 5. `structural_change.py`: baseline-only placebos (fit on pre-event data); add BP supF/sequential tests or bootstrap p-values and month dummies so that H3 can be tested at the stated level. 6. Right truncation: derive `last_complete_month` from the lag profile in `01_volume_series.py` and consume it in `run_structural_change.py` and `emerging_detection.py` (labels require `T+h ≤ last_complete`; enforce `last_train+h ≤ first_test`). 7. Window end: ingest the API increment or amend the protocol to the effective cut-off 2026-10-14. 8. `first_date`/`date_precision` coherence (year-precision dates must not win in `least()`; precision defined on the chosen date); then re-derive `period`, `first_month`. 9. `link_sources.py`: arXiv id from the DBLP DOI; first-author check for title+year links; report precision by method. 10. `emerging_detection.py`: fill `KNOWN_SET` and implement lead times (H8); implement (or formally drop, by amendment) cutoff-specific semantic clusters and declare the topic-unit look-ahead. 11. `02_category_flows.py`: `unclassified` pseudo-category; bootstrap CIs. **P2 — hygiene / reproducibility** 12. Provenance stamps (git commit, input sha256, package versions) in every output; `ORDER BY id` in `classify.py` chunking; version stamp in classification outputs; DuckDB fixture tests for `define_corpus.py` and `03_citation_impact.py`; docstring/code alignments (z-window 25 vs 24, cutoffs, "stratified", "bootstrap"); `sens_tmp` naming; `--first 2018-02`; NW lag sensitivity; PELT robust penalty. ## 4. Summary Two blocking defects: (i) NULL propagation in the eligibility flags of `define_corpus.py` silently removes works with NULL `type` or NULL `source_name` from the corpus and from the PRISMA counts; (ii) `run_structural_change.py` crashes on every category series because the boundary month appears twice in `category_counts_monthly.csv`. Twenty major issues cluster around four themes: the preprint–publication merge is not propagated to citations/flows; citation windows mislabel calendar-year windows, ignore right truncation and include retracted works; the placebo design includes post-event months and Bai–Perron has no test at the 5 % level; the emerging-detection labels for h = 24 use incomplete months and the preregistered known-emergence evaluation is absent. Verified correct: Gini formula, directed modularity, Bai–Perron dynamic programme and BIC/LWZ penalties, ITS design matrix, BOCPD updates, feature look-ahead safety.