Changelog
Source:NEWS.md
popgenVCF 1.0.16.9000 development
Colour-blind-safe colours beyond 8 groups: the
accessibility-firstandstandard-colorfigure styles carry 8 colours; with 9 or more populations or clusters every figure fell back tohcl.colors("Dark 3"), equal-luminance hues that are close to indistinguishable under colour-vision deficiency (smallest pairwise CIELAB distance 2.1 under simulated deuteranopia/protanopia at 12 colours). 9-12 groups now use the 12-colour CARTO “Safe” palette (smallest distance >= 10.8 through 11 colours, 5.8 at 12); above 12 a warning says colour alone cannot separate the groups. The publication ancestry barplot and K-selection plot, which used"Dark 3"at every K, now follow the same palettes. Figures with 8 or fewer groups are unchanged, except those two ancestry plots.Smaller HTML report: the HTML report base64-embeds every gallery figure, and figures are written at
output.dpi(600 by default) – far more pixels than a browser displays. PNG figures wider than 1600px are now resampled to that width for the HTML report only (the originals infigures/and the PDF report are untouched), and an SVG over 2MB yields to its PNG sibling, as an oversized PDF already did in the PDF report. Uses thepngpackage (new inSuggests); without it the report still renders, at full size, with a warning.gzip instead of xz for saved R objects:
analysis_results.rdsand the checkpoint/ledger/workspace persistence files are now written withcompress = "gzip"– much faster to write and read at a modest size cost. Files written by earlier versions still load (readRDS()detects the compression itself).
popgenVCF 1.0.16
popgenVCF 1.0.16 is released. DOI 10.5281/zenodo.22867369 (concept DOI 10.5281/zenodo.21747067), 2026-09-21. Published under the maintainer’s own account via RELEASE_TOKEN; Zenodo’s GitHub integration deposited it exactly once (no orphaned deposit).
-
Fourth code pass, increment 2 (a full run with deliberately awkward metadata: a singleton population, a 2-sample population, partly missing coordinates under custom column names, aliases). All 22 modules completed; two results were wrong:
-
bottleneck.R– every population with fewer samples than frequency classes was reported “mode-shifted”. The smallest attainable minor allele frequency is 1/(2n), so with fewer thanbottleneck_n_binscalled samples (10 by default) the lowest class cannot hold a single locus, the mode can never fall in it, andmode_shiftedwasTRUEby construction – a guaranteed false “possible recent bottleneck” warning for any small population, and for a singleton (every segregating locus has MAF 0.5). The call is now withheld (mode_shifted = NA) with the reason in a newmode_shift_statuscolumn (tested/too_few_samples/no_segregating_loci), alongsiden_samples_called. Figure caption, wiki, vignette and report note say so. Earlier “mode-shifted” calls for populations under 10 samples should be disregarded. -
diversity.R– one tiny population erased everyone’s allelic richness. Rarefaction goes down to the smallest population’s gene-copy count for every population, so a single singleton population rarefied the whole table to 2 copies, where richness is just 1 + He (every population ~1.28 instead of ~1.84-1.93 on the quickstart data). Populations under 5 samples are now left out of the rarefaction and getNA, with a pipeline notice; if fewer than two populations reach 5 samples, all are kept as before. Separately, the “hierfstat is not installed” warning was also emitted when allelic richness had simply been switched off in the config. -
population_assignment.R: the only member of a singleton population got no assignment at all. Setting it aside for the leave-one-out step empties its own population, and that emptied population then failed the “usable in every population” locus test at every locus, leaving no likelihood under any population. The emptied population is now dropped as a candidate, the sample is assigned among the rest, and itsmismatchisNArather than counted as a suspected migrant.
-
Golden gates fail closed:
compare_golden_outputs()reported a gating golden with no observed output, and a requested identifier with no golden entry, asskippedand left the overall status"passed"– a module that silently stopped producing its output went through the gate. The first is nowfailed, the seconderror, and either makes the overall status"failed". Only diagnostic goldens may still beskipped.Fourth code pass, increment 17 (locale independence): base
sort()andorder()collate strings by the session’sLC_COLLATE, so everything the package calls canonical – record fingerprints, “must be sorted” validators, manifests, the orientation of population pairs – depended on the machine’s locale ("S-2"sorts before"S1"underC, after it underen_US). Inside the package both now order character data in C-locale, matching data.table, which already did most of the pipeline’s ordering that way; the publication Fst output orients pairs the same way instead of withpmin()/pmax(). A test-suite run with collation forced toen_USshowed no behavioural dependence on the old order. Visible effect: where a table or figure was ordered by base R, names now sort upper-case first ("Pop_b"before"pop_a"), as data.table-ordered outputs always have.Fourth code pass, increment 16 (project integrity):
validate_popgenvcf_project()compared only the results digests; the parameters, modules, artifacts and reports digests were recorded and never checked, so a project edited after construction (maf0.05 -> 0.5, an added artifact) validated, wrote and read back as if untouched. All recorded component digests are now verified. Doing so exposedset_project_fair_metadata(), which added an artifact without refreshing the artifacts digest; it now does.validate_artifact_lineage()likewise never re-derived the lineage digest, so “immutable” lineage could be edited (an artifact’s SHA-256, its consumers) and still be embedded in projects and FAIR exports under its original digest; it is now recomputed from the records, with the DAG edges rebuilt rather than trusted.Fourth code pass, increment 15 (manuscript text): author text passed as several paragraphs (a character vector) kept only its first element – the rest of an abstract, section, significance/novelty statement or declaration was dropped silently, and a two-paragraph declaration crashed
validate_journal_submission(). Paragraphs are now joined with a blank line, declarations are normalized when the manuscript is built, and the JATS writer gives each paragraph its own<p>.Fourth code pass, increment 14 (submission companions): every row of
companions-manifest.tsvcarriedrole = NA; the role lookup was keyed by role and indexed by file name. Roles are now recorded.Fourth code pass, increment 13 (manuscript regeneration plans): a section’s
reasonandsource_changeswere overwritten by whichever dependency edge was applied last, and only when that edge did not lower the section’s state. A section with its own changed input and a changed upstream section reported only one of them, and amanual_reviewsection never listed changes arriving through aregenerateedge. Plans now list every changed input that reaches a section, directly or transitively, and every changed section it depends on. Section states are unchanged.Fourth code pass, increment 12 (content fingerprints):
new_execution_plan()/new_execution_transition()identifiers were a leftover placeholder – the sum of the serialized bytes – so plans differing only by a rearrangement of characters collided (maf = "0.05"and"0.50"shared aplan_id) andvalidate_execution_plan()accepted a plan edited that way. They are nowsha256-<digest>. The Phase 10 public API fingerprint hashed the wholeserialize()stream, whose header records the R version and the session’s native encoding: a request written in a UTF-8 session failedread_public_api_record()in a C-locale session, and would after an R upgrade. Both now hash the header-free serialization, so previously stored Phase 10 fingerprints and plan identifiers change once.Fourth code pass, increment 11 (external-process records): a result from
run_supervised_external_command_in_workspace()under the default policy could never be validated, written or read back. The workspace is removed on success, andvalidate_external_command()rebuilt the command through the public constructor, which requires the working directory to exist – so any persisted process result also became unreadable once its directory was gone or on another machine. A recorded command is now checked against its own fingerprint; the directory is checked at launch instead, and all three runners report a missing one aslaunch_failed(the basic runner used to throw fromsetwd()). Workspace input manifests are ordered in C-locale (radix) order: with default collation the same inputs produced a different workspace identifier underen_USandC, and a record written under one locale failed the canonical-order check under the other.Fourth code pass, increment 10 (external-process runners): a NUL byte in a child’s output could hang the pipeline forever.
processx3.9.0’s pipe reader never consumes a NUL, sorun_supervised_external_command()(the runner behind ADMIXTURE and fastStructure) spun indefinitely after the child had already exited – its own timeout could not fire – and the async runner returnedstatus = "success"with empty output, discarding the clean lines as well. Both runners now capture stdout/stderr to private files and read them back as bytes, dropping NULs and escaping invalid UTF-8, so the output is always regex-safe. The async capture directory is removed when the run is finalized.run_external_command()decodes its output the same way (a NUL used to cut the line short and one Latin-1 byte made every latergrepl()on the output fail).workspace_contents_fingerprint()no longer depends on where the workspace lives: absolute paths leaked into the digest as data-frame row names, and a trailing slash on the path mis-cut every relative name.-
Fourth code pass, increment 9 (execution timeouts and cancellation):
-
execution-timeout.R– the documentation promised more than R can deliver. The help page said timeout handling is “fail-closed” and that “R elapsed-time limits are reliable for interruptible R and native code”. The budget is enforced withsetTimeLimit(), which R checks only when control returns to the interpreter: it stops R-level code, but a single blocking call – a compiled routine, or a wait on an external process – runs to completion however small the budget. Measured directly: one compiled call ran 422 seconds under a 1-second budget. Nearly all of this package’s long-running work is compiled (SNPRelate, adegenet, phangorn) or external, so the limit guards far less than the wording implied. This is inherent to R, so the fix is the documentation: both the roxygen block and the hand-maintainedman/execution_timeout.Rdnow say what the budget does and does not bound, and point to the genuinely enforcedtimeout_secondson the external ancestry backends. No behaviour change. -
execution-cancellation.Rwas read and probed; cancellation propagates correctly. A cancellation requested from inside a concurrently forked module would be lost (the token is an environment, copied into the worker), but no registered module isparallel_safe, so module batches never fork today. Noted in the scheduler beside the existing warning about that same dead path; nothing to fix now.
-
-
Fourth code pass, increment 8 (execution retries) – a regression from the 1.0.15 per-module RNG change, caught here:
-
execution-scheduler.R/execution-retry.R– retries could no longer recover a stochastic failure. Since 1.0.15 every module starts from a deterministic seed derived fromcompute.seedand its own name. That made each retry of a failed module draw exactly the numbers the failed attempt had drawn, so a module that failed for a stochastic reason (a resampled replicate too degenerate to fit, an optimizer’s unlucky start) failed identically on every attempt – confirmed directly: six attempts, one distinct draw. The attempt number is now folded into the seed. Attempt 1 adds nothing, so ordinary runs keep exactly the seeds they had, and each later attempt is itself reproducible. Checkpoint--resumedeliberately still re-seeds identically, since its purpose is a result identical to an uninterrupted run.run_pipeline()does not use the retry API, so no pipeline result is affected.
-
-
Fourth code pass, increment 7 (the Quarto and pandoc rendering paths):
-
report_engine.R,publication_report_execution.R,manuscript_pandoc_rendering.R– three renderers passed user-controlled paths to a shell unquoted.system2()builds one shell command line and quotes only the executable. An output or manuscript directory containing a space was split into several arguments (the render simply failed), and shell metacharacters in a path were interpreted. This is the defect already fixed inrun_external_command()andcompress_report_pdf(); these three call sites were missed. Every argument is nowshQuote()d at the call, leaving the argument builders – whose output is recorded verbatim – untouched. The pandoc path has a real end-to-end test (a manuscript rendered inside a directory named with a space and$(dir)); Quarto is not installed in the development environment, so its two call sites are tested through a stand-in executable that records the arguments it receives – which also givesquarto_publication_report_renderer()’s render closure its first test of any kind. The two remaining Quarto calls (dashboard.R,regression_reporting.R) pass only fixed basenames and were already safe.
-
-
Fourth code pass, increment 6 (module validators, then
sexbias.R, which one of them led to):-
sexbias.R– the sex-biased dispersal test was wrong for any dataset whose samples are not grouped by population in VCF order. hierfstat requires population-sorted rows and does not say so.hierfstat::AIc()builds its per-population correction withrep(means, table(pop))– population-grouped order – and subtracts it row by row, so on unsorted rows each sample had the wrong population’s mean subtracted: the per-sample assignment indices (60_sexbias_AIc_by_sample.tsv), the t-test statistic and p-value, and the figure were all wrong, on the default settings. Withsexbias_permutations > 0,samp.within()additionally shuffled values across populations instead of within them, and theFST/FIStests failed outright with “invalid ‘times’ argument”. Rows are now sorted by population before hierfstat is called. Verified directly: sorted and unsorted copies of the same data now agree exactly, and did not before. The module is on by default whenever the metadata has asexcolumn. The quickstart VCF happens to be grouped by population, so its documented result is unchanged – which is why this went unnoticed; any earlier sex-bias result from a VCF in another sample order should be regenerated. Population-specific FST (betas()) and allelic richness were checked the same way and are order-independent. -
dapc.R– a sample with no homozygous-alternate call was treated as haploid by DAPC and crashed AMOVA.genlight_from_gds(), shared by both modules, let adegenet infer ploidy per individual from its largest genotype value. A sample whose genotypes never reach 2 was therefore taken to be haploid: each of its heterozygous calls enteredglPca()/dapc()as allele frequency 1.0 instead of 0.5 (confirmed directly), andpoppr.amova()stopped with “min(ploidy(x)) == max(ploidy(x)) is not TRUE”. Unlikely on a large outbred panel, routine for a small marker set or a highly homozygous sample called against a close reference. Ploidy is now pinned to 2 (the package is diploid-only by design); the clonality code already did this. Found because AMOVA crashed on a synthetic matrix while I was checking the other population-grouped modules for the sex-bias ordering problem – AMOVA, clonality and Nei’s distance all turned out to be order-independent. -
validators.R: an undefined (NaN) sex-bias p-value – whatt.test()legitimately returns when the assignment index has no variation – was a validation error, and a validation error aborts the entire pipeline run. It is now a warning; an out-of-range p-value is still an error. The kinship validator’s hard 0.5 ceiling was tested against duplicate pairs with heavily biased, asymmetric missingness and holds exactly, so it stays.
-
Fourth code pass, increments 3-5 (a multi-contig run in deliberately non-natural contig order, a rerun into the same output directory, and the HTML report rendered from the awkward-metadata run). The multi-contig run (first end-to-end exercise of LD-based Ne, the bootstrap CIs, per-chromosome summaries, and the 1.0.15 LD-decay ordering fix on real data) and the rerun (233 stale files cleared, GDS cache reused) were both clean. One gap: the report warned about “possible recent bottleneck” populations without ever showing the table behind the warning –
49_bottleneck_mode_shift.tsvexisted only on disk. The report now has a “Bottleneck mode-shift screen” table, including the newmode_shift_status.-
Fourth code pass, increment 1 (a full default quickstart run audited against the documentation):
-
Documentation made stale by the 1.0.15 SFS binning fix. The wiki and vignette still said all 8 quickstart populations are mode-shifted under the default MAF filter and 3 (ITU, STU, YRI) under
qc.maf = 0. With boundary MAFs binned correctly it is 3 of 8 (CHB, ITU, STU) under the default filter and none underqc.maf = 0; at n = 20 half of all possible MAF values sit exactly on a class boundary, so the old error moved most of the lowest class up by one. The text and the48_site_frequency_spectrumfigure are updated; the MAF-filter caveat they illustrate stands. - The genome-scan text gave the windowed FST range as 0.051-0.172; the lowest estimated window is 0.019 (0.051 is the fourth lowest), and only 19 of the 20 windows hold any SNPs. Every other quoted quickstart number (global FST/Nm/Jost’s D/Dxy/beta, Mantel and partial Mantel, pcadapt, population assignment, sex check, kinship, ROH, Tajima’s D, windowed heterozygosity) was re-checked against this run and matches.
-
Documentation made stale by the 1.0.15 SFS binning fix. The wiki and vignette still said all 8 quickstart populations are mode-shifted under the default MAF filter and 3 (ITU, STU, YRI) under
-
Third code pass, increment 7 (report rendering), found by running the whole pipeline end to end without metadata while the test suite was also running:
-
report.R– simultaneous popgenVCF runs on one machine broke each other’s PDF report. The report was rendered in place, and LaTeX writes its.aux/.toc/.logbeside the input document regardless ofintermediates_dir/output_dir– i.e. into the installed package’s own template directory, one fixed path shared by every process. Two runs compiling at the same moment read each other’s half-written.auxand failed with “Missing \begin{document}” or “Extra }, or forgotten \endgroup” (confirmed directly: four concurrent renders, four failures), after all analyses had completed. It also made the PDF report impossible wherever the package library is read-only. Each render now works from a private temporary copy of the template. This is the realistic multi-job HPC case, and also the source of the straypopulation_genomics_report.aux/.tocfiles that kept appearing in the source tree during development.
-
-
Third code pass, increment 6 (ancestry plotting without metadata):
-
admixture.R– ADMIXTURE, fastStructure and sNMF crashed in metadata-free runs. The capability table offers all three without any metadata (“available from VCF sample IDs”), butplot_q_matrix_views()requested the population-organized membership view unconditionally, which stops with “Population-organized membership plots require a population column” – after the backend had already finished. Without population labels only the data-driven view is drawn.
-
-
Third code pass, increment 5 (QC reporting):
-
qc.R– the QC retention table booked the autosome restriction to LD pruning. Withqc.autosome_only(the default), markers on the configured sex chromosomes leave the analysis set between the missingness filter and LD pruning, but07_QC_sequential_counts.tsv,06_QC_independent_counts.tsv, the04_SNP_retentionfigure, and the report’s Quality control table had no row for it. On the quickstart data “After LD pruning” showed 63,228 markers removed; 61,616 of those were chromosome X/Y markers and pruning itself took 1,969 down to 357. An “After autosome restriction” row (and “Autosomal analysis set” criterion) now appears whenever the restriction removes anything; runs without sex-chromosome markers keep identical tables. Quickstart figure, wiki, vignette, and report note updated. No analysis result changes.
-
-
Third code pass, increment 4 (metadata import):
-
io.R– metadata header auto-detection missed most non-standard headers. It compared the raw lowercased first row against a short fixed list, soSample ID/Sample.ID, theindividual_idsynonym accepted further down, quoted CSV headers, and a BOM-prefixed first column were all read as data rows. Worst, a custom header was never recognized, so configuringinput.sample_column/population_column– whose whole purpose is a non-standard header – failed with “require headered metadata” unlessmetadata_header: yeswas also set. Detection now uses the normalized form, the full synonym list, and any configured column names.
-
-
Third code pass, increment 3 (execution infrastructure):
-
config.R– a fresh run left the previous run’s execution checkpoint in place. A run only writes its own checkpoint after its first module batch completes; a crash before that, followed by--resume, loaded the old run’s checkpoint – its config, completed-module list, and results – and finalized a report from it. The stale checkpoint and its.sha256sidecar are now removed with the other stale outputs when a fresh run starts. -
utils.R:log_msg()joined its parts withpaste(), whose default separator doubled every space callers already supply (“Starting pca”, “popgenVCF v 1.0.15”) in the console andpipeline.log. Nowpaste0().
-
-
Third code pass, increment 2 (ancestry backends and reruns into an existing output directory):
-
pipeline.R/config.R– a rerun into an existing output directory published the previous run’s leftovers. The report’s figure gallery embeds every figure file infigures/, andtables/,trees/,chromosomes/are handed over as-is, so anything the new run did not overwrite (a K value outside a narrowed range, a module since disabled, a population gone after samples were removed) appeared alongside the new results. A fresh run now clears those four pipeline-owned directories first and logs the count;cache/is kept and--resumeis unaffected. Documented in the wiki. -
ancestry_plink_integration.R– cached PLINK and sNMF inputs were keyed on sample and SNP ids only. SNP ids are positional integers, so a corrected or re-called VCF with the same sites and samples produced the identical key and every ancestry backend silently reused the previous file’s genotypes. The key now includes the source VCF’s sha256 (from the GDS cache manifest). -
faststructure_runtime.R:chooseK.pyglobs everyfaststructure.<K>.*file beside the output prefix, so models left by an earlier, wider-K run leaked into the model-complexity choice (or failed the module with “must use evaluated K values”). Stale per-K outputs are removed before the run.
-
-
A third code pass, in increments. Increment 1 (ordination and clonality):
-
ordination.R– the Tracy-Widom test was run on a truncated eigenvalue spectrum. Each statistic scales the k-th eigenvalue by the sum (and sum of squares) of every eigenvalue from k onward, but only the top 100 were computed and passed in, so every p-value changed for cohorts above 101 samples. On the quickstart data (160 samples) the top-100 slice gave 15 significant components; the full 159-value spectrum gives 19. The whole non-trivial spectrum is now computed whenever the test runs;n_pcs: "auto"still retains at most 100. The quickstart figure, wiki, and vignette are updated (19 of 159). Cohorts of 101 samples or fewer are unaffected. -
clonality.R– Ia/rbarD permutation p-values were not reproducible.poppr::poppr()runs in a forked child for crash isolation, andmcparallel()’s defaultmc.set.seed = TRUEreseeds that child from the process, discarding theset.seed()made just before it. Withclonality_ia_permutations > 0, identical runs gave different p-values. The child now inherits the seeded state.
-
man/publication_ordination_outputs.Rd(after thev1.0.15tag): thevariance_explained_unitargument added in the first review pass was never added to this hand-maintained Rd page, soR CMD checkreported a code/documentation mismatch WARNING and the R-CMD-check workflow failed onmain. Documentation only; the tagged 1.0.15 code is unaffected.tools::codoc()is clean for the whole package again.
popgenVCF 1.0.15
popgenVCF 1.0.15 is released. DOI 10.5281/zenodo.22848587 (concept DOI 10.5281/zenodo.21747067), 2026-09-19. Published under the maintainer’s own account via RELEASE_TOKEN; Zenodo’s GitHub integration deposited it exactly once (no orphaned duplicate). Verified the same way as every prior release: the Zenodo record API’s title, version, license, creator, and keywords match .zenodo.json, its related identifier points at the v1.0.15 GitHub tag, and the DOI resolves via a real doi.org redirect.
-
A second full code pass over
R/, this time concentrating on scientific formulas and SNPRelate ordering assumptions rather than defensive-coding gaps. Each fix ships with a regression test verified against the pre-fix code.-
ne_ld.R– LD-based Ne was biased low in every release since the module was added. Waples (2006, Table 1) gives the expected sampling contribution to r-squared as1/S + 3.19/S^2for S >= 30, and a separate Ne formula,(0.308 + sqrt(0.308^2 - 2.08 r2')) / (2 r2'), for S < 30. The module used a bare1/Sabove 30 and the S >= 30 Ne formula for every S. The two published E(r2) branches meet at S = 30 only with the second-order term (0.03688 vs 0.03697; a bare1/Sgives 0.03333), and the omission is large relative to the signal: at S = 50 it inflates the drift r-squared by 0.0013 against a true signal of ~0.0033 for Ne = 100, pulling Ne down by roughly a third. Both branches now follow the published table. Ne(LD) values from earlier releases should be regenerated. -
ld_decay.R– LD values were binned at the wrong distances whenever the VCF’s contig order was not natural order.SNPRelate::snpgdsLDMat()returns its band matrix in the GDS file’s own SNP order regardless of thesnp.idorder requested (confirmed directly). The module pre-sorted SNPs into natural chromosome order and labelled the matrix columns with that sorted order, so for an assembly listing e.g.scaffold_10beforescaffold_2, every column past the first out-of-order contig carried the wrong SNP’s chromosome and position, and genuine cross-chromosome pairs were averaged in as same-chromosome ones. Columns are now labelled from the returnedsnp.id. VCFs already in natural contig order (including the quickstart) are unaffected. -
metadata_capabilities.R/config.R– the Mantel and spatial-autocorrelation modules were skipped for two reasons unrelated to the data. The capability gate hardcodedlatitude/longitudeinstead of readinginput.geographic_columns, and that setting was never normalized the way metadata headers are on import, sogeographic_columns: [Lat, Long]could never match the already-lowercased columns. Separately, both modules were disabled whenever population labels were missing or incomplete, although neither needs them (the partial Mantel control is simply skipped). Coordinates in the configured columns are now sufficient;input.geographic_columnsis validated as two distinct names and normalized. -
execution-scheduler.R– module results could depend on which other modules ran. A module drawing random numbers without its ownset.seed()inherited whatever RNG state earlier modules left behind, so its output changed with the set of enabled modules, with the execution backend (a forked worker never advances the parent’s RNG; a sequential run does), and between a fresh and a checkpoint-resumed run. Every module now starts from a deterministic state derived fromcompute.seedand its own name, and the caller’s RNG state is restored afterwards. -
roh.R– ROH sites were filtered on missingness among samples that sample QC had already excluded.bcftools viewevaluates--excludeagainst the original sample set, before-Ssubsetting (confirmed directly). In the module’s single-pass call,F_MISSINGtherefore still counted every QC-excluded sample – typically the high-missingness ones – so sites were dropped for missingness among samples not in the analysis, and the ROH site set disagreed with the GDS-side variant QC. In the regression fixture one fully-missing excluded sample removed every site. Subsetting and filtering are now two passes. Runs where sample QC excluded nobody are unaffected. -
ml_tree.R– bootstrap support was not reproducible withcompute.threads > 1.bootstrap.pml(multicore = TRUE)resamples inside forked workers, whose Mersenne-Twister streams do not derive fromset.seed(); two identical calls disagreed at 7 of 10 nodes. Multi-threaded runs now select and seed L’Ecuyer-CMRG for the bootstrap (restoring the previous generator afterwards). Single-threaded results are unchanged. The module’s genotype extraction also now pinssnpfirstdim = FALSElike every other call site. -
spatial_autocorrelation.R– co-located sample pairs and the farthest pairs were silently left out of the correlogram. The distance classes were copied fromPopGenReport::spautocor(), whose first class has a strict> 0lower bound and whose class width comes from the distance range while the classes start at 0. With population-level coordinates (one point per sampling site, the usual case) every within-site pair sits at distance 0 and was dropped – the shortest-distance class the correlogram exists to show – and the farthest pairs fell past the last class (3 of 780 in the reference fixture). The first class now includes distance 0 and the last is open-ended, so every pair is classified; classes 1-7 of the reference fixture are unchanged. Permutation p-values now use(b + 1) / (m + 1)instead ofb / m, which reportedp = 0. On the quickstart example the first class now holds the 2,720 co-located pairs (r = 0.084, p = 0.001) instead of being empty; the wiki, vignette, and figure are updated. -
bottleneck.R: a MAF sitting exactly on a class boundary was binned one class too high (1 - 0.7is0.30000000000000004, soceiling(maf / 0.05)gave 7, not the right-closed class 6). Boundary values are common at small sample sizes (n = 10: every MAF is a multiple of 0.05), where this could move the modal class and the mode-shift call. -
report.R/cli.R: PDF compression installed its result withfile.copy(overwrite = TRUE), which truncates the report before writing and then logged success whatever happened; it now stages beside the target and renames.write_default_config()reported “Wrote default configuration” even when the copy failed.parse_admixture_cv()no longer errors on a log holding more than one CV line. -
ordination.R– the Tracy-Widom “significant components” count was floored at 2. The floor exists because a PC1-vs-PC2 plot needs two axes, but it was applied to the test result itself, so a dataset with no significant structure at all – an important result – was logged, stored (tracy_widom_significant), and captioned as “2 of N component(s) significant (Patterson, Price & Reich 2006)”. The stored and reported count is now the true one (0 and 1 included); the two-axis floor applies only to how many components are retained, and the figure says so when the two differ. -
genome_scan.R: windowed FST included singleton populations that the genome-wide FST excludes, so the two described different sample sets; the scan now applies the same N >= 2 rule.
-
-
A Fable 5 review of the results/interpretation documentation (
wiki/Results-and-Interpretation.md,vignettes/interpreting-results.Rmd), cross-checked against the actual R source, found several places where the guidance could lead a user to a wrong scientific conclusion or was simply missing – since applied to both documents and, where the same explanatory text is duplicated into the actual HTML/PDF report (inst/rmarkdown/templates/popgenvcf_report/skeleton/skeleton.Rmd), there too:- Dxy was presented as “absolute nucleotide divergence” with no caveat that it’s an average over segregating SNPs only, not per base pair – off by ~300x from literature values a user might compare against (wiki/vignette only; the report’s own FST table note never went into this level of detail).
- Wright’s Nm had no caveat that it’s a rescaled FST, not a literal migrant count (the formula’s island-model assumptions are routinely violated in real data) – fixed in the wiki, the vignette, and the report’s own “Population differentiation” table note.
- The pipeline’s own bootstrap CI outputs (FST and diversity) were never mentioned in the wiki/vignette, despite both telling users to report confidence intervals; added, along with the single-chromosome-dataset caveat.
- “(BIC, elbow, parsimony)” for ADMIXTURE K-selection was wrong in the wiki/vignette – there is no BIC there, just the CV-error optimum, elbow, and plateau/one-SE choice.
- “Interpret bootstrap support exactly like PCA’s percent-variance-explained” was a misleading analogy in the wiki/vignette (a stability measure is not a variance decomposition).
- The per-chromosome analysis module (on by default) had no interpretation guidance at all in the wiki/vignette; added a new section to both. The report’s own “Chromosome-specific results” table note existed but omitted the same marker-density-first caveat and the silent
chromosome_min_snpsexclusion; strengthened to match. - The Weir-Cockerham/Weir-Goudet FST “agreement to 9 significant figures” was framed in the wiki/vignette as generally expected; it’s an artifact of the quickstart’s perfectly balanced design, not a general property.
- The wiki called BH-FDR correction “the base-R equivalent of Storey’s q-value” – it isn’t equivalent (BH is more conservative).
- The wiki claimed the ML tree fixedly uses GTR; it’s actually configurable (the vignette already had this right) – the report’s own figure-family note for the ML tree had the same fixed-GTR claim, fixed the same way.
- The vignette’s ROH section forward-referenced an FIS discussion that was never written; added it.
- Wiki alt text mislabeled a per-sample heterozygosity figure as “by population”.
-
“fix the remaining findings too” — the lower-severity items from the Fable 5 review, starting with three more shell/path-safety gaps in the same family as the two already-fixed security findings.
-
report.R:compress_report_pdf()’ssystem2(gs, ...)call passed the report’s own output path unquoted – the samesystem2()args-are-not-shell-quoted gap already fixed inrun_external_command(), here affecting the user/config-controlled output directory. Fixed withshQuote()on both the ghostscript output path and the input PDF path. Also hardenedrender_standard_report_format()’s ownfile.copy()return value being silently ignored (a failed copy – full disk, permissions,output_dirremoved mid-render – previously returned a path to a report that was never actually written). -
regression_archive.R:write_benchmark_archive()used the caller-suppliedrecord$releasestring directly as a directory name – validated only as “one non-empty string”, with no character-set restriction, so a release id containing../could escapereleases_direntirely. Confirmed directly:release = "../../escape-marker-xyz"made the function create a real directory two levels up, landing directly inside the shared system temp directory. Fixed by sanitizing the on-disk directory name the same waywrite_chromosome_results()(chromosome.R) already does for chromosome names; the manifest’s own recorded file path (whichverify_benchmark_archive()resolves files from) is updated to use the same sanitized name, and the release id itself is left untouched everywhere it’s just informational text (error messages, the written metadata). -
projects.R:extract_project_bundle()calledutils::untar()with no confinement check on the archive’s own entry names – a crafted.popgenvcfbundle (a portable file that can be shared or downloaded, not necessarily one this package itself wrote) containing an entry like"../evil.txt"is only ever stopped by whichever externaltarbinary R’suntar()happens to shell out to on the host, and even then only via a warning, not a catchable R error; the function then returned a silently-incomplete extraction directory as if extraction had fully succeeded. Fixed by listing the archive’s entries first and refusing any absolute or..-containing entry before extraction is ever attempted, rather than depending on a particular external tool’s own behavior.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection. -
-
Nine more findings from the full-repo review’s first batch (ancestry/canonical-validation modules), fixed the same way:
-
amova.R:ade4::randtest()’s permutation test was wrapped intryCatch(..., error = function(e) NULL)with no warning at all – a genuine failure leftpermutationas a quietly empty table, so a report could publish AMOVA variance components with the significance test simply missing. Now logs a warning naming the underlying failure. -
ancestry_backend.R:run_ancestry()’sfail_if_noneonly errors when no requested backend is available – a partial drop (one of several explicitly requested backends genuinely unavailable) previously proceeded with nothing indicating it happened. Now logs which requested backend(s) were skipped. -
analysis_publication.R/publication_ordination_outputs.R: both had the same fraction-vs-percent guessing heuristic (“<=1 means a fraction, multiply by 100”) for auto-generated manuscript text – wrong for a genuine small percentage (a PC legitimately explaining under 1% of variance is realistic for a large, weakly-structured SNP panel), silently inflating it 100x. Both gained an explicitunit/variance_explained_unitparameter ("fraction"/"percent") a caller who knows their own units can pass to bypass the guess; the default"auto"behavior, including its documented ambiguity, is unchanged for existing callers. -
publication_ordination_outputs.R: separately,new_publication_ordination_output()’s centroid computation usedscores[[group_column]] == groupto select each group’s rows – for any sample whose own group value is NA, that comparison is NA (not FALSE) for every group compared against, and a data.frame’s[treats an NA logical index as “return an all-NA row” rather than omitting it. One NA-labeled sample silently corruptedmean()to NA for every published group’s centroid, not just the one that sample would have belonged to. Fixed with%in%, which never produces NA for this comparison. -
benchmark_registry.R: two bugs.benchmark_default_compare()’sobserved <- observed[names(reference)]silently inserted NA for any reference metric name absent fromobserved(same length, different names passes the earlier length check), andrun_benchmark()’s ownif (numerical_pass && ...)then crashed with “missing value where TRUE/FALSE needed” instead of a clear failure – now validated explicitly before the lookup. Separately, a missingpassedcolumn from a custom comparator hit a hardstop()outsiderun_benchmark()’s owntryCatch(), crashing the whole run instead of degrading to the same"error"status result every other comparator failure path already produces. -
ancestry_publication.R:ancestry_write_devices()opened each of its three graphics devices (PDF/SVG/PNG) with a baredraw(); dev.off()and noon.exit/tryCatch– adraw()error left that device open for the rest of the R session, confirmed directly (dev.list()still showed it open after the error propagated), silently corrupting whatever unrelated plotting ran next. Each device now opens and closes within its own scope viaon.exit(). -
attempt-ledger-persistence.R:ledger[module == name]is ambiguous once the ledger carries a column literally named “name” (extra columns are permitted) – data.table resolves a bare symbol against its own columns before the calling environment, so the validation loop’s ownnamevariable silently resolved to that column instead, comparing against the wrong values and returning zero rows for every module (confirmed directly), silently skipping the terminal-state check for all of them. data.table’s own..nameescape turned out not to resolve reliably for afor-loop variable in this data.table version; fixed instead by indexing the plainmodule/status/attemptvectors already extracted earlier in the function with base R, which has no such ambiguity for either operand. -
benchmark.Randcanonical_ancestry_three_backend.R: minor cleanup – an unusedgc(reset = TRUE)return value and a stale@returndoc; two lambda parameters namedc(shadowingbase::c, though never actually called as a function within those specific lambdas) renamed topair.
-
-
Six more findings from the full-repo review’s second batch (canonical-validation/CLI modules):
-
canonical_change_approval.R: three related bugs.decided_atwas completely unvalidated inset_canonical_change_status()(any string accepted, not even checked for non-emptiness) and only checked for non-emptiness – not ISO-8601 date format – innew_canonical_change_request(), even though.resolve_governing_approval()’s own tie-breaking logic depends on lexicographic string comparison correctly ordering real dates; both now requireYYYY-MM-DD. Separately,reconcile_canonical_changes()built its “missing expected change” check from every approval’s metric ids regardless of the approval’s own expected classification – an approval that specifically expected a metric to stay"stable"(an allowed expected classification, i.e. “pre-approving no change”) was flaggedmissing_expected_changeand blockedrelease_readywhen the metric correctly stayed stable, exactly backwards. Now excludes metrics whose governing approval expects"stable". -
canonical_drift_detection.R:canonical_drift_history()’s cumulative-drift aggregation pre-filtered to only finitenormalized_driftvalues before summing – a metric with even oneInf(“breaking”) transition among otherwise-finite ones had that transition silently dropped from its own cumulative sum, and a metric whose every transition wasInfvanished from the table entirely, easily misread as “no drift data” rather than “unbounded drift”. Removed the pre-filter;sum()’s own naturalInfpropagation handles both cases correctly. -
clonality.R: the genotype-accumulation-curve figure’s caption unconditionally described a dashed reference line (“dashed line: NA MLGs…”) even on thepoppr::poppr()32-bit-crash fallback path, wheren_mlg_totalisNAand the line is never actually drawn. The caption now omits that clause entirely on the fallback path instead of describing a line that isn’t there. -
chromosome.R: two chromosome names that sanitize to the same on-disk file stem (e.g."chr 1"and"chr_1", both becoming"chr_1") previously let the second chromosome’s per-chromosome tables silently overwrite the first’s. Now runs every sanitized name throughmake.unique()first. -
cli.R: a value-taking CLI flag (e.g.--config) unconditionally consumed the next token as its value even when that token was itself another flag –--config --no-reportsilently setconfig = "--no-report"and dropped--no-reportentirely, surfacing later only as a confusing “file not found” error. Now rejects a flag-shaped next token with a clear “missing value” error. -
core_results.R:as_core_result()usedis.list(x)to decide whetherxwas a pre-built named list of constructor arguments to splat – true for a data.frame too (a data.frame IS a list under the hood in R), so a bare data.framex(e.g. a legacy module’s results table, meant as one positional constructor argument) was splatted column-by-column instead of passed through as a single argument. Now excludesis.data.frame(x)from that branch.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection. -
-
Five more findings from the full-repo review’s third batch (execution/external-process infrastructure):
-
external-process-supervision.R/external_process_async.R:processx::run()/processx::process$new()’s ownenvparameter replaces the entire child environment by default (confirmed directly:env = c(FOO = "bar")alone left a child process with noPATH/HOME/etc. at all), unlikerun_external_command()’s additivesystem2(env = ...)path – the samecommand$environmentoverride therefore had opposite semantics depending on which backend actually ran it. Both now prefix with the unnamed"current"element, processx’s own documented merge mechanism. -
external_reference_validation.R:external_reference_numeric_table()’sobserved[names(reference)]silently inserted NA for any reference metric name absent fromobserved, andrun_external_reference()’s ownif (spec$role == "diagnostic" || numerical_pass)then crashed with “missing value where TRUE/FALSE needed” for a non-diagnostic role instead of a clear, catchable failure – the same bug class already fixed this pass incanonical_real_data.Randbenchmark_registry.R. Now validated explicitly before the lookup. -
execution-ledger-persistence.R:validate_execution_ledger()’sallowed_statuslist omitted"timed_out", even thoughclassify_timeout_ledger()(execution-timeout.R) rewrites a failed row’s status to literally that value – persisting the final ledger of anyexecute_analysis_plan_with_timeouts()run through this exported constructor was rejected as an “unsupported status”, contradicting that exported API’s own real output. -
execution-scheduler.R:run_execution_batch()’s multicore path was the one realmclapply()fork site in the package not guarded bycheck_mclapply_results()(every other one –diversity.R,fst.R,dapc.R,genome_scan.R,report.R– already is). An OOM-killed forked worker returnsNULLfor its slot rather than a catchable error; currently unreachable in production (no registered module setsparallel_safe = TRUEyet), but must not stay unguarded once one does.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection. -
-
Six more findings from the full-repo review’s fourth batch (manuscript/publication and structure-inference modules):
-
manuscript_asset_integration.R:manuscript_cross_reference_table()usednamed_vector[[id]] %||% idto look up an artifact’s label/caption –[[on a named vector throws “subscript out of bounds” for a missing key (confirmed directly), so%||%’s fallback was never actually reached; any artifact with no matching captions row crashed the whole table (and everything built on it:write_manuscript(), JATS, submission packaging) instead of falling back to the artifact id. Fixed with an explicit%in% names()check. -
mantel_ibd.R: the partial Mantel test’s population-dissimilarity matrix was built fromouter(population, population, "!=")without excludingNApopulation labels first –io.Rdeliberately converts an empty population string toNA_character_, a real, reachable value.vegan::mantel.partial()on the resulting NA-containing distance object crashed with “missing observations in cov/cor”. Now excludes unlabeled samples before building that matrix (re-checking the usable count and that at least two real populations remain). -
kinship.R:data.table::setorder()’s own default isna.last = FALSE(NA/NaN sorts FIRST, confirmed directly – the opposite of base R’sorder()) – a pair with undefined (NaN) KING-robust kinship sorted to the very top of the published pairs table, presented as the most related pair when it was actually undefined. Addedna.last = TRUE. -
journal_submission_profiles.R: three related bugs, all from the same “NA reaches a bareif” failure class already fixed elsewhere this pass.journal_profile_roles()validatedanyNA(x)aftersort(), whose own default silently drops NA entirely, so a genuinely NA role name vanished instead of being rejected.keyword_min = NA(explicitly documented as a supported “no minimum” value) andhighlight_min = NAwith no other highlight limits configured both crashedvalidate_journal_submission()outright instead of correctly treating the check as satisfied/skipped. -
ibs_publication.R:write_ibs_publication_artifacts(..., k = 1)produces coordinates with noMDS2column at all (compute_ibs_mds()’s own contract: return exactlykaxes).graphics::plot(x, y = NULL)does not error (confirmed directly) but silently falls back to plottingxagainst an implicit sample index – while the title and axis labels still described a genuine 2-D MDS scatter that was never computed. Now plots the single retained axis against a constant, honestly labeled as a single-axis result.
As with every fix in this pass, each ships with a new regression test, verified via
git stashbisection where the underlying bug is a crash (four of the five); theibs_publication.Rfix corrects a silent mislabeling rather than a crash, so its test instead confirms thek = 1path completes correctly end-to-end. -
-
Five more findings from the full-repo review’s fifth batch (ancestry-export and Phase 9 governance modules):
-
nonhuman_plink_export.R: classified whether SNPRelate’s direct BED export failed for the expected “chromosome option” reason by matchingconditionMessage()text – base R’s own error messages (“missing value where TRUE/FALSE needed”) are translated under a non-EnglishLANGUAGEsetting (confirmed directly: French renders as “valeur manquante là où TRUE / FALSE est requis”), so this classification silently failed on such a system, re-throwing instead of falling back to the portable PED-to-BED conversion path this file exists to provide for exactly these non-human-chromosome users. Now forcesLANGUAGE=enfor the duration of the classified call (R’s translation lookup is checked dynamically per call, confirmed directly, not fixed at session startup). -
ordination.R: two fixes.bootstrap_nj_ibs_tree()was the onesnpgdsGetGeno()call site in the package not pinningsnpfirstdim = FALSEexplicitly (every other one does) – safe today only because this package’s own VCF-to-GDS conversion always writes sample-major files. Separately,pca_tracy_widom_significant_count()’s sequential significance scan usedwhich(tw$pvalues >= alpha), which silently skips an NA p-value (LEA can emit these for a degenerate trailing eigenvalue) rather than stopping there –which()only returns indices of TRUE, never NA – so an indeterminate p-value inside the leading run was counted as significant and the scan continued past it, inflating the significant-PC count undern_pcs = "auto". -
phase9_closure_review.R/phase9_closure_evidence.R: two related bugs, both in these files’ internal validation and self-consistency helpers (Phase 9 closure/governance record-keeping, not user-facing analysis). A caller-suppliedfingerprintargument tophase9_closure_review()andphase9_roadmap_handoff()was accepted verbatim via%||%with no verification against the record’s own actual content – an arbitrary or stale fingerprint could be stamped onto a record, defeating the point of a fingerprint as a self-consistency check; both now recompute unconditionally and verify a supplied value matches. Separately, three call sites (phase9_closure_review()’s ownrequired_ids, itsphase9_roadmap_handoff()’sscalar_ids, andphase9_closure_evidence.R’s ownscalar_ids) flatten several scalar arguments viac()before checkinglengths(...) != 1L–lengths()on an already-flattened atomic vector reports 1 for every element unconditionally, so that check could never actually catch a caller passing a non-scalar argument; two of the three lacked the compensatinglength(...) != Ncheck the first already had, now added to all three.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection where the underlying bug is a crash; thesnpfirstdimpin is a defense-in-depth consistency fix with no observable behavior change for this package’s own GDS files, verified only against the existing test suite. -
-
Five more findings from the full-repo review’s sixth batch (publication-output modules):
-
publication_companion.R:set_project_publication_bundle()mutatesproject$artifacts(adding$publication_bundle) but never recomputedcomponent_digests$artifactsafterward – its siblingset_project_artifact_lineage()already does this correctly for the same mutation. Any downstream digest-based change comparison saw no change in$artifactsdespite a real one. -
population_specific_fst.R:hierfstat::betas()is not documented to guarantee a finite beta for every population; a non-finite value previously flowed silently into the published table and figure. Now logged as a warning (not filtered – an NA/NaN beta is itself real, reportable information, the same convention this package uses for LD-based Ne and kinship). -
publication_amova_outputs.R:new_publication_amova_output()sorted variance-source rows alphabetically for “determinism” – real AMOVA labels are hierarchical, not alphabetical, and this moved a real"Total variations"row to the very front of the published table (its leading “T” sorts before every"Variations..."row’s leading “V”), scrambling a scientific table’s canonical order into something actively misleading. Now preserves the caller-supplied row order instead (the fingerprint is consequently sensitive to that order, a deliberate tradeoff). -
publication_narrative_completeness.R:publication_narrative_reason()’s%||%chain only coalescesNULL, not a length-0 vector (module$reason = character(0), a real shape an optional field can take) – left in, the followingif (!is.na(reason) && ...)crashed outright instead of falling through to the state-based default reason text. -
publication_figure_styles.R:new_publication_figure_style_profile()validatedshapesfor NA but notlinetypes, despite the same “non-empty deterministic palettes” error covering both.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection. -
-
Three more findings from the full-repo review’s seventh batch (silent-data-loss and reproducibility-gap fixes):
-
roh.R:run_roh()remapped bcftools’ internal sample index back to the caller’s public sample id viaruns[, sample := public_ids[match(sample, sample_ids)]]– any run whose raw id failed to match (a torn or interleaved captured write that still passed the earlier field-count check) silently becameNAand then vanished entirely at the latermerge()with no trace, understating that sample’s FROH with no indication anything was dropped. Now counts and logs a warning naming the unmatched-row count before filtering them out, rather than lettingmerge()do it invisibly. -
regression_reporting.R:compare_release_benchmarks()caught anycompare_performance_baseline()failure (e.g. mismatched benchmark identifiers between the two performance results being compared) and silently downgraded to a bare digest-diff row, with nothing in the output indicating a richer performance comparison was even attempted or why it fell back. Now logs a warning naming the component and the underlying error message before falling back. -
release_reconciliation.R:release_reconciliation_roxygen_exports()andrelease_reconciliation_dynamic_exports()both usednormalizePath(root, ...)directly as an unescapedsub()/grepl()regex pattern to strip the repo-root prefix from file paths – a checkout path containing regex metacharacters (.,+,(,), etc. are all valid in a Unix path) either failed to match at all (leaving the full absolute path un-stripped in the audit’sfilecolumn) or matched something other than intended. Confirmed directly with a checkout root named.../release-reconciliation-root.name+test(v2)[final]-.../pkg. Addedrelease_reconciliation_regex_escape(), applied to the root portion of both patterns.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection. -
-
The full-repo review’s eighth and final batch, closing out “fix the remaining findings too”:
-
validators.R:validate_membership_collection()had two bugs.for (nm in names(collection))never executes at all for an unnamed collection (names()returnsNULL) – an unnamed list of Q matrices was silently validated as fully valid regardless of content, a vacuous pass. Separately,q[, ..cols]is data.table’s own NSE escape and only resolves inside a data.table[call – a plain data.frame (explicitly accepted by this same function’sis.data.frame(q)check) crashed with “object ‘..cols’ not found” instead of being validated (confirmed directly). Both fixed: positional iteration with an index-derived fallback label for unnamed entries, and downgrading throughas.data.frame()before the column subset so the same[semantics apply to both input types. -
vcf_input.R:prepare_vcf_input()’s “reuse this compressed file as-is” fast path trusted an existing Tabix/CSI index viavcf_index_is_valid()unconditionally –bcftools index --nrecordsonly inspects the index file’s own internal metadata, never the VCF’s actual content, so it reports success (and the old record count) against an index left over from before the.vcf.gzwas overwritten in place at the same path (a real scenario: a re-run pipeline producing a differently-sized VCF). Confirmed directly: a region-restricted query against such a stale index either hard-crashes (“Invalid BGZF header … BCF read error”) or silently returns zero records for a region that genuinely has data, because the new content’s BGZF block layout no longer matches the old index’s virtual file offsets.vcf_index_is_valid()gained an opt-incheck_mtimeargument (index mtime must not predate the VCF’s own), applied at this one call site; the function’s other, self-managed index-pair call site is unaffected. -
sexbias.R/spatial_autocorrelation.R: both calledset.seed()directly for their own permutation tests with no restore afterward, permanently re-seeding the global RNG for the rest of the pipeline run – every subsequently executed randomized module (bootstrap trees, other permutation tests) silently became coupled to whether/when these modules happened to run. Both now save and restore.Random.seedaround the seeded call, matching this package’s own established convention (tree_bootstrap_replicate_seeds(),R/tree_bootstrap.R). -
utils.R: three fixes.parse_int_range()’s"low:high"branch was the only one of its three input forms that didn’tsort(unique(...))its result – a reversed range (a plausible typo, e.g."10:2"meant as"2:10") silently returned a descending sequence instead, breaking that contract for callers (e.g.ancestry_k_selection.R’s plateau detection) relying on ascending K order.wrap_plot_text()used||across possibly-vector operands (is.na(text),!nzchar(text)) – as of R >= 4.3 this is a hard error for a length > 1 input (“‘length = N’ in coercion to ‘logical(1)’”, confirmed directly), a plausible input for any of this function’s 30+ call sites building a caption/title/subtitle dynamically without an explicit collapse; now recurses element-wise instead.manhattan_layout()’s cumulative x-axis offset usedmax()/range()with nona.rm, so a singleNAposition on one chromosome poisoned the cumulative offset for every chromosome laid out after it too (cumaccumulates), not merely that one chromosome’s own tick. -
submission_companions.R:write_submission_companions(overwrite = TRUE)skipped the “directory is not empty” check but never actually cleared the directory first, unlikewrite_scientific_release_bundle()’s ownoverwrite = TRUEhandling (R/scientific_release_bundle.R), which already does. A stale file left over from a previous write survived untouched and was silently swept into the new render’s own manifest, as if it were part of the current companion set. Nowunlink()s the directory first, matching that established convention. -
scientific_object_cache.R:.cache_contract_fingerprint()hex-encoded the entire serialized byte stream one byte at a time via an R-levelsprintf()loop – not actually a compact fingerprint, but a literal, invertible re-encoding that doubled in size with the object being fingerprinted, and scaled the per-byte loop linearly with it. Now usesdigest::digest(..., algo = "sha256"), matching this package’s own established convention for every other checksum/manifest (hash_file(),R/utils.R). While adding this file’s first-ever test coverage, also found and fixed a real, unrelated, and more serious pre-existing bug in the same constructor:new_scientific_cache_key()’s own documented default,dependency_fingerprints = character(), unconditionally crashed with “argument 1 is not a vector” (order(NULL), sincenames(character(0))isNULL) – the function could never actually be called with its own supported “no dependencies” default.
As with every fix in this pass, each ships with a new regression test verified via
git stashbisection. -
-
The two security-severity issues from the full-repo review, fixed first.
Quarto/YAML injection via report title (
R/dashboard.R:130,R/report_engine.R:89): both built the YAML front matter’s title line aspaste0("title: \"", gsub("\"", "'", title), "\""), which only neutralizes literal double quotes. A title containing a newline (both functions accept a caller-suppliedtitle, not necessarily typed by a trusted author) could close the quoted scalar, inject new YAML keys, or open a fenced```{r}code chunk thatquarto renderwould execute — confirmed directly: a proof-of-concept title reliably broke a 2-fence front matter into 3 and would have executed an injectedsystem()call on render. Fixed by addingyaml_scalar_line()(R/utils.R), which builds the line viayaml::as.yaml()instead of hand-rolled quoting: for multi-line content,as.yaml()emits a|-block scalar, and YAML block scalars consume every indented line as literal content regardless of what it contains, so injected front-matter or chunk syntax stays inert. New regression test (test-report-engine.R) round-trips a malicious multi-line title through the realwrite_population_genomics_report()path and confirms it survives as one literal title value with the template’s ownexecute.echo: falseuntouched; confirmed viagit stashbisection that it fails 3 ways against the pre-fix code (extra front-matter fence, YAML parse error) and passes clean against the fix.Six silently-wrong-result scientific-correctness bugs, fixed next (each confirmed live via
git stashbisection against a new regression test that fails on the pre-fix code and passes on the fix):-
population_assignment.R: cross-population log-likelihoods were summed over a different number of loci per candidate population – a locus with zero calls in one population was excluded only from that population’s score, not from every population’s. Since every per-locus term is negative, a population simply missing more data at a locus accumulated fewer penalty terms and could out-score a genuinely better-fitting population by omission alone. Fixed by determining, per sample, the set of loci usable by every candidate population (the same symmetric locus-exclusion conventionpopulation_tree.Ralready uses for Nei’s distance) before scoring any of them, so every population’s log-likelihood is always summed over the identical locus set. New fixture: a sample whose only comparable locus clearly favors its recorded population, but which a second locus (present only in that population) drags down under the old code – confirmed the old code flipped a genuine self-match into a spurious mismatch. -
performance_benchmarks.R:measure_performance_once()’s peak-memory reading indexedgc()’s output matrix by the bare column name"(Mb)", which appears three times (used / gc trigger / max used) –matrix[, "(Mb)"]silently returns only the first match (“used”, i.e. current resident memory), not “max used”, even though the precedinggc(reset = TRUE)exists specifically to make a max-used reading meaningful. Peak memory was systematically understated, silently defeating thememory_regressionrelease gate. Fixed by selecting the last"(Mb)"column. Confirmed directly: allocating and freeing a 229 MiB vector mid-run raised the reported peak by 228.9 MB after the fix, versus exactly 0 before it. -
ancestry_k_selection.R:select_ancestry_k()’s plateau-K heuristic recommended one K more complex than intended – it indexed the K the negligible improvement step arrived at, not the K it departed from, contradicting the function’s own stated goal (“the first stable model with negligible fit loss … over unnecessarily complex models”). Confirmed against a real fixture (CV error 0.50/0.40/0.399/0.3985 at K=2-5, 2% plateau threshold): recommended K=4 where K=3 was correct. Also hardened the same search to require the improvement step be non-negative, not just small – a negative (regressing) step from ordinary replicate noise is always “< plateau_fraction” too, and without this guard could stop the search at a noisy dip before the real plateau instead of the genuine one. -
spatial_autocorrelation.R: matched the function’s own VCF-nativesample_idsargument againstmetadata$public_samplewhenever that column existed – which, sincenormalize_sample_aliases()always adds it, is effectively always. Any sample carrying a metadata alias never matches its own VCF id there and was silently dropped; if most samples in a run were aliased, the whole module degraded to the misleading “valid latitude/longitude columns were unavailable” skip a genuinely coordinate-less run gets, with nothing indicating the real cause was ID aliasing. Fixed by always matching onmetadata$sample, matching this function’s actual, VCF-native argument contract (distinct frommantel_ibd.R’s superficially identical-looking logic, which is correct because its ids are genuinely public-facing). -
ancestry_consensus.R:consensus_ancestry()identified the alignment reference replicate by comparingrep$replicate == reference$replicate(by value) instead of by position – so any non-reference replicate that happened to carry the same replicate ID as the true reference (duplicate replicate IDs were never rejected when a plain list, rather than a validatedPopgenVCFAncestryResult, was passed in) had its own Q matrix silently discarded and replaced with the reference aligned to itself. Confirmed directly: a duplicate-ID replicate’s genuinely different Q matrix came back bit-identical to the reference’s instead of contributing its own data to the consensus mean. Fixed by identifying the reference by its array position, and by rejecting duplicate replicate IDs outright (mirroring the checknew_ancestry_result()already applies, which a plain-list input bypasses). -
canonical_real_data.R:compare_external_results()’s documented “scalar or named vector”tolerancecontract silently broke for the named case –as.numeric(tolerance)discarded the names, and the resulting plain vector was then re-labeled purely by the position ofvalue_cols, sotolerance = c(fst = 0.01, pi = 1e-6)compared againstvalue_cols = c("pi", "fst")(a different order) silently swapped which tolerance applied to which metric in the scientific-concordance evidence chain. Fixed by looking up eachvalue_col’s tolerance by name whentoleranceis named (erroring loudly on any missing name), keeping the original recycle-by-position behavior only for the common unnamed/scalar case.
Six more silently-wrong-result bugs, found in the same review pass, fixed the same way (each with a new regression test, confirmed via
git stashbisection to fail against the pre-fix code and pass against the fix):-
fair_export.R: the RO-Crate dataset’slicensefield was built asmetadata$rights_uri %||% metadata$license– but%||%coalescesNULLonly, andrights_uri’s own default is the real valueNA_character_, notNULL. Every default-configured FAIR bundle (no explicitrights_urisupplied, the common case) published a literal"license": nullinstead of the declared license. Fixed with an explicitis.na()check. -
regression_reporting.R:.component_validation_regressed()’s own regression check used!all(obs_vals, na.rm = TRUE)– so an observedpassedcolumn that went from all-TRUE to partly-NA (checks that errored or returned an indeterminate result, rather than a clean pass) had its NAs silently dropped byna.rm = TRUE, and the function concluded “not regressed”, in the code whose specific job is catching exactly this silent-NA failure class. Fixed with!isTRUE(all(obs_vals))(nona.rm), which fails closed on any NA-poisoned result while leaving a genuinely all-TRUE result unaffected. -
projects.R:write_popgenvcf_project()had two related bugs. Its overwrite guard checkedfile.exists(path)before appending the.popgenvcfextension, so a caller-supplied path lacking it bypassedoverwrite = FALSEentirely and the unconditionalunlink()a few lines later silently deleted the real, existing bundle. Separately, a relative destination path resolved inside the function’s own temp staging directory rather than the caller’s working directory –normalizePath(path, mustWork = FALSE)on a path that does not yet exist on disk does not resolve it against the working directory at all (confirmed directly: it returns the input completely unchanged), so simply moving that call earlier wasn’t sufficient on its own; a still-relative path now needs to be joined onto the working directory explicitly before the function’ssetwd()into its temp staging area, whose later cleanup was silently deleting the bundle the caller was told had been written. An absolute path (e.g. anything built fromtempfile()) was never affected by either bug, which is why both went unnoticed. -
io.R:read_metadata()’s own header-detection tokenizer correctly splits a whitespace-separated header line on[[:space:]]+, but the same empty-stringsepwas then passed straight through todata.table::fread(), which does not treatsep = ""as “split on any whitespace” – it read each entire line as one single column instead (confirmed directly: a genuine"sample population"file produced one column literally named"sample population", no row ever split). A real space-separated metadata file either failed with a misleading “must contain a sample column” or produced sample IDs that could never match a VCF sample ID. Fixed by passingfread()its ownsep = "auto"(which correctly handles whitespace, including irregular runs of multiple spaces) specifically for this case; tab/comma detection is unambiguous once found and was left untouched. -
qc.R: the variant-missingness QC figure (02_variant_missingness) drew its “maximum retained missingness” dashed threshold line, and its subtitle, from a hardcoded literal0.2/"20%"–config.R’s own default forqc.max_variant_missing, which is why this went unnoticed on every default-configured run. The MAF figure and the sample-missingness figure in the same function already draw their own threshold lines fromcfg$qc$maf/cfg$qc$max_sample_missingrespectively; this was the one figure that was missed, so any user who actually changedqc.max_variant_missinggot a publication figure whose stated threshold silently disagreed with the filter genuinely applied. -
manuscript_submission_package.R:submission_package_role()’s asset check tested whether a file’s path started with"figures/","tables/", or"supplementary/"directly – butmanuscript_copy_assets()(the function that actually places these files) always nests them one level deeper, under"assets/figures/..."etc. Every real figure/table/supplementary asset in a generated submission package was silently mislabeled"supporting_metadata"instead of"asset", so any submission-profile logic keyed on the asset role (or an asset count) saw zero assets in every package. Fixed by matching the real"assets/<folder>/..."prefix.
Shell injection in
run_external_command()(R/external-process.R): its own docstring claims “no shell command string … quoting is unambiguous”, butbase::system2()onlyshQuote()s the executable itself (paste(c(env, shQuote(command), args), collapse = " "), per its own source) — theargsandenvparameters are pasted into the shell command line it builds on Unix completely unquoted. Confirmed live before the fix: an arg like"; touch <marker>; echo"actually created the marker file. No current production caller was affected (ADMIXTURE/fastStructure go throughrun_supervised_external_command()’sprocessx::run()path instead, which spawns without a shell), butrun_external_command()is an exported, validated-looking API — and the package’s own test suite had already worked around the bug by pre-shQuote()-ing test script paths before passing them in, which would have been a real hazard if that habit were ever copied into production code. Fixed byshQuote()-ing each arg individually (a shQuote()’d string is exactly one shell word, so argument boundaries stay correct) and, for environment variables, quoting only the value (NAME=+shQuote(value)) — quoting the whole"NAME=value"string stops the shell from recognizing it as an environment-assignment prefix at all, confirmed directly. New regression test round-trips an injected arg and a space-and-semicolon-containing env value through a real subprocess (Rscript reporting its owncommandArgs()/Sys.getenv()) and confirms both survive as literal values with no injected file created;git stashbisection confirms the marker file really is created pre-fix and never is post-fix. The three existing tests that had pre-shQuote()-ed their script-path args are updated to pass raw paths now that quoting happens once, correctly, inside the function itself. -
Requested directly, after a similar pass on a separate interpretation report: “I’d like to add a small description in each section about what kind of information each test result may provide… I’d also like the report to look more professional, more polished.” Applied to the actual
population_genomics_report.html/.pdftemplate (inst/rmarkdown/templates/popgenvcf_report/skeleton/skeleton.Rmd), not a one-off document, so every future run’s report carries it. Everyreport_table_section()call (and the hand-written AMOVA/sex-biased-dispersal blocks) now takes an optionalnote, a single-paragraph, data-independent explanation of what that section’s test measures and what it does and doesn’t support concluding – reusing the same voice and citations aswiki/Results-and-Interpretation.md(Weir-Cockerham FST/Nm, Nei’s distance, Luu et al. 2016 pcadapt, Waples & Do 2008 LD-based Ne, Manichaikul et al. 2010 KING kinship, Excoffier et al. 1992 AMOVA, Kamvar/Tabima/Grunwald 2014 MLG, Favre et al. 1997/Goudet-Perrin-Waser 2002 sex-biased dispersal, and more) so a reader gets the same methodological framing whether they open the wiki or a generated report. Notes render as a markdown blockquote, portable to both formats without a format-specific branch. Design pass:report.cssgives every HTML table a full border/header/zebra-stripe treatment (previously bare<table>tags with no Bootstrap class at all applied to them, inherited unfixed from thereport_kable()HTML branch), restyles the note blockquotes as a distinct methods-callout box, and colors headings/TOC to match;report_pdf_preamble()(R/report.R) addsxcolor/titlesecsection-heading color and aframed-based shaded box for the PDF’s own quote environment – added as new, additive packages kept strictly separate from the already-hardened\sbox-measured table/figure logic in the same preamble, so as not to risk the real production defects that logic exists to prevent (see the 1.0.9-1.0.11 entries below). Verified with a synthetic fixture exercising every note-bearing section across both formats (test-report-figures.R’s existing suite still passes unchanged) and visually confirmed via rendered PDF page images and a headless-Chrome HTML screenshot.Follow-up, caught directly by re-reading the just-updated report: “no new explanations were added prior the figures in the population_genomics_report.” Correct – the note treatment above only covered
report_table_section()’s tables; the figure gallery (skeleton.Rmd’sfigure-gallerychunk) is a single flat, numerically-ordered loop over every figure file with no per-module grouping or explanation at all. Addedreport_figure_family_catalog(), an ordered list of (stem regex, heading, note) entries built directly from the realsave_plot()/sprintf()stem patterns acrossR/*.R(not guessed), covering every figure family the pipeline can produce – QC, diversity, PCA, IBS/individual relationships, the NJ and ML trees, FST, DAPC, Mantel/IBD, all three ancestry backends separately, private alleles, kinship, ROH, genome scans, LD decay, LD-based Ne, population assignment (previously undocumented anywhere in the report – it has no table section of its own), the site-frequency-spectrum/bottleneck screen, spatial autocorrelation, pcadapt, sex check, MLG/clonal diversity, and sex-biased dispersal.report_figure_family()matches each figure’s stem against the catalog (first match wins, so the four per-backend ancestry families are listed ahead of their broader shared14_..._Q_K<k>shape); both the HTML and PDF figure-gallery loops now call a sharedreport_figure_family_heading()that emits a##subheading plus the matching note only when the family actually changes from the previous figure, so a family that recurs later in the numeric ordering (a real case:17_PCA_loadings_manhattansorts well after the ancestry-backend figures) correctly gets its heading again rather than being silently merged with an unrelated family in between. An unmatched stem (a hypothetical future figure not yet cataloged) degrades to the pre-existing bare behavior – no heading, no note, not an error – verified directly with a synthetic99_unknown_future_stemfixture. Verified end-to-end with a synthetic multi-family figure set across both formats (correct heading/note placement, correct graceful degradation,test-report-figures.Rstill passing) before re-rendering the real production report.Requested directly: “Run a Fable 5 pass on the code,” reviewing the two report-polish commits above. Caught two real issues, both now fixed. (1)
report.css‘s table-alignment rule (thead, tbody, tr { display: table; ... }, added alongsidetable { display: block; overflow-x: auto }to let a wide table scroll within itself) silently broke every HTML table in the report: giving each<tr>its own independentdisplay: tablemakes each row compute its own column widths from its own content alone, so header cells stop sitting above their data cells and no two body rows share a column grid – confirmed directly via a headless-Chrome measurement (five real column widths, no two rows matching).table { display: block }alone already gives the intended per-table horizontal scroll without disturbing the shared column layout, so thethead, tbody, trrule is removed outright; re-verified visually (headless-Chrome screenshot, header cells now aligned with their data rows). (2)report_figure_family_catalog()’s ADMIXTURE/sNMF/fastStructure/DAPC entries each carried an extra alternation branch (27b_ADMIXTURE_K_selection,30b_sNMF_K_selection,29b_fastStructure_K_selection,22e_DAPC_K_selection) that can never match: those four stems are written only as.tsvtables bywrite_structure_k_selection()(R/structure_k_consensus.R), never as a figure file, while the actual figure for the same K-selection data is saved under a different stem eachplot_structure_k_selection()call already uses (13b_ADMIXTURE_cluster_number_selection,13c_sNMF_cluster_number_selection,13d_fastStructure_cluster_number_selection,12_DAPC_cluster_number_selection– all still correctly cataloged). Harmless (first-match catalog order was never at risk), but contradicted the catalog’s own header comment that every pattern traces to a real figure-writing call site; removed the four dead branches. The report_note()/report_table_section() note text itself, the ~25 regex patterns’ real branches, and the new LaTeX preamble additions (xcolor/titlesec/framed) all came back clean under the same pass (systematic backtick/newline/emphasis-balance check across every new string, a 58-stem regex-coverage test against every real figure-writing call site, and a standalone xelatex compile of the exact preamble output).
popgenVCF 1.0.14
popgenVCF 1.0.14 is released. DOI 10.5281/zenodo.22821420 (concept DOI 10.5281/zenodo.21747067), 2026-09-18. Verified the same way as prior releases (the Zenodo record API reports version 1.0.14, the MIT license, the same creator, and a related identifier pointing at the v1.0.14 GitHub tag; the DOI resolves via a real doi.org redirect). The release was deposited twice again (the second GITHUB_TOKEN release-creation race, since fixed in 67f24a2), leaving one orphaned, non-citable DOI (10.5281/zenodo.22819582, 2026-09-17); as for v1.0.10-v1.0.12, the later deposit is the citable one. The repository’s identity files were not flipped to released for 1.0.14 before development continued, so this section is the in-repo record of that DOI.
-
Reported directly from a real production run (
PCN2026_run123456, 64 samples, 5 populations): the pipeline completed successfully, but thene_ldmodule (LD-based effective population size, Waples & Do 2008) silently reportedNe = Infinityfor every single population, and its figure was entirely missing from the report. Root-caused against the real cached GDS rather than guessed:SNPRelate::snpgdsLDMat()returnsNAfor any SNP pair where one locus is fixed (monomorphic) within a specific population subset – routine for real, structured data, since a locus can clear the pooled-cohort MAF filter yet still be fixed in one particular subgroup.mean(r2)inne_ld_one_population()(R/ne_ld.R) had nona.rm = TRUE, so a singleNApair silently collapsed the entire population’smean_r2toNaN, which then read as non-finite and fell through to the “no detectable drift signal” branch –Ne = Inf, status = "ok", a fabricated result masquerading as a valid one. Measured directly on the real data: 18-84% of candidate pairs wereNAacross every population in the run, including a 32-sample one. Fixed by filtering to valid (non-NA) pairs before computing bothmean_r2andharmonic_mean_n– the two must be computed from the identical pair set, since they’re combined in the same bias-correction formula – and by gating themin_pairsthreshold on the valid pair count, not the raw candidate count. Re-run against the real production data:Ro1now reports a genuine finiteNe = 5.69(status: ok) where it previously showedInf;Ro2andTBDnow correctly reportstatus: below_formula_domain(implying an even smaller effective size than the formula’s valid domain covers) instead of a wrongInf/ok;Ro4andRo5(n=2 and n=4) still correctly reportInf, now for the right reason (a genuinely negative bias-corrected r2, not aNaN-poisoned mean). This bug has existed in every release since the module was added (685bfe2, 2026-08-10) – every release fromv1.0.0throughv1.0.13. New regression test (test-ne-ld.R) constructs a fixture with one locus fixed for every sample in the population being tested (confirmed via a rawSNPRelate::snpgdsLDMat()probe that this specific degeneracy – a fixed/homozygous locus, not merely uniformly heterozygous – is what actually triggersNA, not just any zero-variance case) and fails 4 ways against the pre-fix code, passing clean against the fix. The affected production run’sanalysis_results.rds,45_Ne_LD.tsv, figure, and report were regenerated from the cached GDS without re-running the full ~25-hour pipeline.
popgenVCF 1.0.13
popgenVCF 1.0.13 is released. DOI 10.5281/zenodo.22754452 (concept DOI 10.5281/zenodo.21747067), 2026-09-14. Published under the maintainer’s own GitHub account directly, the first release since the RELEASE_TOKEN fix (see the 1.0.12 entry below): container.yml fired automatically on the real release: published event without the manual delete-and-recreate dance every prior release since v1.0.0 needed, and Zenodo’s GitHub integration deposited this release exactly once (no orphaned duplicate DOI, unlike v1.0.10-v1.0.12). Verified independently the same way as every prior release (raw Zenodo record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). Reconciled the real DOI and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, and README.md; release_status transitions from development to released.
-
Asked directly: “how hard would it be to make popgenVCF conda/mamba-installable from bioconda? Maybe a lighter version with no benchmark datasets included?” Investigated properly rather than assuming: yes, and arguably a better fit than CRAN, since the toolkit already has a hard runtime dependency on BCFtools/HTSlib as external system binaries (something CRAN discourages), and
inst/conda/environment.yml– the real environment already used to build the Apptainer container image – proves the full dependency graph resolves through bioconda/conda-forge’sr-*/bioconductor-*channels today.Checking surfaced a real, independent bug worth fixing either way: the built source tarball was 64.7 MB, and the single largest contributor was never benchmark data (
benchmarks//validation/are tiny and already excluded via.Rbuildignore) – it wasvignettes/interpreting-results.Rmd’s 52 embedded figures, each exported at 600 DPI print resolution for the PDF report/wiki (many 4800-13800px on a side, up to 4.7 MB apiece). At that resolution they were wasted inside an HTML vignette viewed at typical browser width, and rmarkdown’s default self-contained HTML then base64-embedded every one of them a second time, inflatinginst/doc/interpreting-results.htmlalone to 47.7 MB. Resized every PNG invignettes/figures/to a max dimension of 1600px (LANCZOS resampling, preserving aspect ratio), visually verified still legible – 34.4 MB to 9.3 MB (73% reduction) for the source figures, cutting the actualR CMD build --compact-vignettestarball from 64.7 MB to 22.0 MB (66% smaller).wiki/figures/(full resolution, not part of the R package) is untouched.Added
packaging/bioconda/popgenvcf/{meta.yaml,build.sh}as a maintained draft recipe (excluded from the R package build via a new^packaging$.Rbuildignoreentry – actual submission is a separate PR to the externalbioconda/bioconda-recipesrepository, reviewed on bioconda maintainers’ own timeline). Its dependency list mirrorsinst/conda/environment.yml’s required set exactly, but is deliberately narrower on therun:side per the “lighter version” request: ADMIXTURE and fastStructure are left out (genuinely optional ancestry backends, each a heavyweight compiled tool in its own right – a user installs them separately the same way the container image documents), while BCFtools/HTSlib (DESCRIPTION’s own requiredSystemRequirements) and Pandoc (exercised by every report-rendering code path) stay as required runtime dependencies.build.shalso installs a thinpopgenVCFCLI wrapper into$PREFIX/bin, since the R package source tarball intentionally excludes the repository’s ownpopgenVCF.R(acommandArgs()-basedcli_main()wrapper for local/Docker use). The recipe is structurally checked – Jinja-rendered YAML parses correctly, every listed dependency confirmed to resolve today viamamba search, nor-popgenvcfname collision, and thetest:block’spopgenVCF --helpinvocation is a real, workingcli_main()code path – but has not yet been linted or built with realbioconda-utilstooling (not installed in this environment), spelled out honestly inpackaging/bioconda/README.mdalongside the note thatversion/sha256need re-pinning to whichever release is actually submitted.Documented the future
mamba install r-popgenvcfcommand inwiki/Deployment-and-Troubleshooting.md(a new “bioconda package (coming soon)” subsection, explicitly marked not yet available since the recipe has not been submitted, reviewed, or merged) andwiki/Getting-Started.md’s pointer to it, alongside the existing, genuinely-workingenvironment.yml-based development-environment install path – deliberately not touched:README.md(already only points to the wiki for Conda/Mamba, and sits at its own enforced 120-line cap), the containers-and-hpc vignette (scoped to Docker/Podman/Apptainer specifically, not conda), andwiki/Developer-Guide.md/docs/DEVELOPMENT_GUIDE.md(contributor dev-environment setup, not end-user install docs).
popgenVCF 1.0.12
popgenVCF 1.0.12 is released. DOI 10.5281/zenodo.22708378 (concept DOI 10.5281/zenodo.21747067), 2026-09-11. Deposited automatically by Zenodo’s GitHub integration once the recreated GitHub Release was published (the same GITHUB_TOKEN anti-recursion issue already found for every prior release since v1.0.0 recurred and was fixed the same way – see docs/ROADMAP.md); verified independently the same way as every prior release (raw Zenodo record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). The version-history lookup surfaced the same duplicate-deposit side effect seen for v1.0.10/v1.0.11: this release was also deposited twice (once from the token-created release, once from the recreated one), leaving one orphaned, non-citable DOI (10.5281/zenodo.22701072) alongside the correct one – see docs/reproducibility.md. That recurring bug is now fixed permanently: tagged-source-release.yml’s “Publish GitHub Release” step uses a new RELEASE_TOKEN repository secret (a fine-grained PAT scoped to this repo, Contents: Read and write only) instead of the default GITHUB_TOKEN, so future releases will no longer need a manual delete-and-recreate to get the container image published. Reconciled the real DOI and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, and README.md; release_status transitions from development to released.
-
Asked directly: “for the ML tree module, is the model hardcoded or can the user change it in the config file?” It was hardcoded:
model = "GTR"was a literal string in three separate places inrun_ml_tree()(R/ml_tree.R) – the mainphangorn::optim.pml()fit, thephangorn::bootstrap.pml()replicates, and the returned result’s ownmodellabel – with no config knob at all. Addedanalyses.ml_tree.model(default"GTR", keeping the previous hardcoded behavior as the default), validated invalidate_config()against the complete list of nucleotide substitution modelsphangorn::optim.pml()supports (JC,F81,K80,HKY,TrNe,TrN,TPM1,K81,TPM1u,TPM2,TPM2u,TPM3,TPM3u,TIM1e,TIM1,TIM2e,TIM2,TIM3e,TIM3,TVMe,TVM,SYM,GTR, mirrored in a newml_tree_allowed_models()since phangorn does not export this list). Gamma-rate heterogeneity (4 categories) and the Lewis (2001) ascertainment-bias correction stay fixed regardless of model choice, since those are architectural requirements of a SNP-only alignment with no invariant sites, not alternatives a user would trade off the way they would a substitution model.54_ML_tree_summary.tsv’smodelcolumn and the tree figure’s title now reflect the actual model used per run; the previously GTR-specific output filename (ML_GTR_gamma_ASC.nwk) is renamed to the model-agnosticML_tree_gamma_ASC.nwkso it stays accurate regardless of which model is configured.Implementing this surfaced a real, independent bug, caught by the test suite rather than by chance: passing the new
modelparameter as a bare variable (model = model) instead of a literal string broke bootstrapping outright, turning a previously-passing determinism test into a reproducible “bootstrap failed” result. Root cause:phangorn::optim.pml()internally callsupdate.pml(), which – like R’s ownlm()/glm()and much other S3 modeling code – stores its unevaluated call (viamatch.call()) on the returned object for later re-fitting. A bare-variable argument captures the symbolmodel, not its value;bootstrap.pml()then re-evaluates that stored call once per resampled replicate, several stack frames removed from the original function’s own localmodelvariable, and fails withobject 'model' not foundon every replicate – silently degrading every bootstrap run to “failed” instead of erroring loudly, exactly the graceful-degradation path this module already had for a genuinely uninformative resampled locus set, which is what made this so easy to miss without the test suite catching it. Root-caused by bisecting against the pre-change code (git stash) to confirm the regression, then reproducing the internal phangorn error directly outside the package’s own error handling. Fixed by switching both theoptim.pml()andbootstrap.pml()calls todo.call(), which constructs the call from already-evaluated argument values instead of bare symbols – confirmed the fix restores the exact pre-change test outcome (0 failures, the same “NaNs produced” warnings as before, not a new failure mode masked as a pass).
popgenVCF 1.0.11
popgenVCF 1.0.11 is released. DOI 10.5281/zenodo.22692556 (concept DOI 10.5281/zenodo.21747067), 2026-09-10. Deposited automatically by Zenodo’s GitHub integration once the recreated GitHub Release was published (the same GITHUB_TOKEN anti-recursion issue already found for every prior release since v1.0.0 recurred and was fixed the same way – see docs/ROADMAP.md); verified independently the same way as every prior release (raw Zenodo record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). Reconciliation was delayed by an extended Zenodo API outage (well over an hour of sustained 504 Gateway Timeout responses across the reconciliation attempts for both this release and 1.0.10 below); once Zenodo recovered, the version-history lookup also surfaced that both this release and 1.0.10 were each deposited twice (once from the token-created release, once from the recreated one), leaving one orphaned, non-citable DOI per release alongside the correct one – see docs/reproducibility.md for the full account. Reconciled the real DOI and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, and README.md; release_status transitions from development to released.
Requested directly, following up on the already-documented
poppr::poppr()32-bit Ia/rbarD overflow crash (confirmed live in a real production report’s own pipeline notices): “is there an easy workaround… maybe someone has already forked it and fixed it?” Researched properly rather than guessing: no existing patch or fork fixespair_diffs()(the specific compiled C routine that overflows), but poppr ships a separate, actively-maintained implementation,poppr::bitwise.ia(), that computes the same family of statistic via bitwise SNP comparison on agenlightobject instead of poppr’s genind-basedpair_diffs()path – a completely different code path that never builds the same flattened(pairs x loci)32-bit index. Verified empirically, not just by reading docs: ran it on a synthetic dataset at 600 samples x 20,000 loci (~3.6 billion pairsloci, well past the ~2.15 billion overflow point) and it completed cleanly in 34 seconds. Read poppr’s own C source (bitwise_distance.c) to confirm exactly what it returns: the documented formula (Vo,Ve, final value(Ve - Vo) / (2 * sum of sqrt(var_i * var_j))) is the standardized index of association,rbarD(Agapow & Burt 2001) – not rawIa, and not accompanied by poppr’s own permutation-based p-values, which would require reimplementing poppr’s internal locus-shuffling scheme independently.run_clonality()(R/clonality.R) now falls back to this when the isolatedpoppr::poppr()call crashes: newclonality_genlight_from_matrix()builds agenlightdirectly from the same 0/1/2 dosage matrix already used elsewhere in this file (no genind round-trip needed),clonality_run_bitwise_ia_isolated()runsbitwise.ia()isolated in a forked child (mirroringclonality_run_poppr_isolated()’s own crash-containment pattern, even though this path has not been observed to crash), andclonality_bitwise_fallback_summary()computes rbarD for every recorded population plus the pooled “Total” row (anNAfor any group with fewer than two members, since rbarD is genuinely undefined there, not silently coerced to a number). Also found, verifying against the real production report that originally surfaced the crash: the overflow is not exclusively a large-sample*-count problem – the flattened index ispairs x loci, so a comparatively modest 50-sample cohort with ~52,000 LD-pruned loci was enough to trigger it too, which the original NEWS.md entry’s framing (“large sample x locus counts”) already anticipated but is worth calling out plainly. The “poppr::poppr() Ia/rbarD… skipped” pipeline-notice warning is reworded to describe what was actually recovered.56_MLG_diversity_summary.tsv’s column schema is unchanged (ia/ia_p_value/rbard_p_value/mlgetc. simply readNAon the fallback path, exactly like the pre-existing empty-table fallback did for every column). New tests:clonality_genlight_from_matrix()’s basic construction,clonality_bitwise_fallback_summary()’s per-population/Total/too-small-group behavior,clonality_run_bitwise_ia_isolated()’s NA-not-error contract on a wrapped-call failure, and the fullrun_clonality()integration path (mockingclonality_run_poppr_isolated()to fail, same as the pre-existing empty-fallback test it replaces).Reported directly against a real v1.0.9 production HTML report: the DAPC red warning subtitle was still clipped, the isolation-by-distance plot’s bottom “Curve: …” caption was clipped, the DAPC membership probabilities plot’s title was clipped, the sNMF ancestry “data-driven cluster order” plot’s title was clipped, the Hardy-Weinberg plot’s subtitle was clipped, and every HTML table was missing the top/bottom rule its PDF sibling already has. Root cause:
wrap_plot_subtitle()(R/utils.R) only ever wrappedplot.subtitletext, and only on 4 call sites (DAPC’s main scatter plot, its membership-probability plot, the ROH length-class figure, and the PCA Tracy-Widom figure). Titles and captions were never wrapped anywhere in this package, and several other subtitle sites were simply missed. A full audit of all 44ggplot2::labs()calls acrossR/found ~20 more dynamically-built (sprintf()/paste0()/if-else) title, subtitle, and caption sites that could overflow the plot width and get silently clipped the same way – including every one of the six reported here. Renamedwrap_plot_subtitle()towrap_plot_text()(the wrapping logic never depended on which aesthetic the text was destined for) and applied it to every genuinely dynamic site the audit found, acrossR/dapc.R,R/admixture.R(plot_q_matrix(), shared by ADMIXTURE/fastStructure/sNMF/STRUCTURE/DAPC-membership – now also defensively wraps its subtitle pass-through parameter, not just its own title),R/mantel_ibd.R,R/diversity.R,R/clonality.R,R/ld_decay.R,R/ordination.R,R/pcadapt_scan.R,R/population_specific_fst.R,R/population_assignment.R,R/qc.R,R/sexbias.R, andR/spatial_autocorrelation.R, andR/structure_k_consensus.R. For the table borders:report_kable()’s HTML branch (skeleton.Rmd) renders viaknitr::kable(format = "pipe"), pandoc’s default markdown-table conversion, which emits a bare<table>with no class attribute – confirmed directly against the real production HTML (30<table>tags, none classed) – so none of the bootstrap theme’s own table styling ever applied, unlike the PDF report’s booktabs top/bottom rules. Addedinst/rmarkdown/templates/popgenvcf_report/skeleton/report.css(atable { border-top; border-bottom }rule) wired intoR/report.R’shtml_document()call for the HTML format only, restoring the same visual convention without adding akableExtradependency. Added 3 targeted regression tests (test-admixture.R,test-mantel.R,test-publication-plot-quality.R) and verified end-to-end by re-rendering the real productionanalysis_results.rdsto HTML with these fixes applied.
popgenVCF 1.0.10
popgenVCF 1.0.10 is released. DOI 10.5281/zenodo.22668745 (concept DOI 10.5281/zenodo.21747067), 2026-09-09. Deposited automatically by Zenodo’s GitHub integration the same way as every prior release, but not reconciled into this repository’s own metadata until now (1.0.11’s own DOI reconciliation caught the gap) – development moved directly from cutting 1.0.10 into the clonality bitwise.ia() fallback work and the 1.0.11 cut above without a pause for Zenodo reconciliation in between, and by the time it was attempted, Zenodo’s API was down for over an hour. Reconciled the real DOI and publication date the same way as every other release.
-
Reported directly from a real v1.0.9 production run: the manuscript PDF report failed outright with
LaTeX failed to compile population_genomics_report.tex, XeTeX crashing while trying to load a font it called\XeTeXLink@font=pzdr(“not loadable”), cascading throughkpathsea’smktexnam/mktexmfMETAFONT-synthesis fallback to an emergency stop.pzdris the Adobe/URW “Zapf Dingbats” PostScript font thatpsnfss(and, transitively, XeLaTeX’s font-loading chain) can probe for regardless of whether the document body ever visibly uses a dingbat glyph. Extensive searching for a content-level cause – the package’s own R/Rmd source, the production run’s VCF sample names, its metadata TSV and config YAML, and even the successfully-rendered HTML sibling of the same report – found no literal dingbat/checkmark/emoji character anywhere, and reproducing the exact same cachedanalysis_results.rdslocally rendered the PDF cleanly, with the resulting.texcontaining zero references topzd,Pisymbol, ordingbat. The real difference was the environment, not the content: this machine’s TeX Live hastexlive-fonts-recommendedinstalled (confirmed viadpkg -S, the exact package that shipspzdr.tfm); the container’sDockerfileandApptainer.definstall LaTeX viaapt-get install --no-install-recommends texlive-xetex lmodern [ghostscript], and--no-install-recommendssilently dropstexlive-fonts-recommended(a Debian Recommends, not a hard Depends, oftexlive-xetex) – leaving the container’s LaTeX installation without the classic PostScript “base 35” font files. With no network access at container runtime, TeX Live’s usual on-demand package fetch can’t recover, so any code path that probes for that font family hits exactly this crash instead. Verified directly: a throwaway probe image built from the current (unfixed)Dockerfile’s exactapt-getline has nopzdr.tfm(kpsewhichfails) and cannot cleanly load the sibling PS35 “Symbol” font family either (a minimalpifonttest document degrades to garbled “Missing character” output); the same probe rebuilt withtexlive-fonts-recommendedadded resolves both cleanly. Fixed by addingtexlive-fonts-recommendedto bothDockerfileandApptainer.def’s LaTeX install step. While fixingApptainer.def, also brought its LaTeX toolchain to parity withDockerfileby addingghostscript(already present inDockerfile, absent fromApptainer.def), whichcompress_report_pdf()(R/report.R) requires to compress the report PDF – its absence degrades gracefully to an uncompressed PDF with a logged warning rather than a hard failure, but was a real, unintended parity gap between the two container build definitions all the same.
popgenVCF 1.0.9
popgenVCF 1.0.9 is released. DOI 10.5281/zenodo.22305304 (concept DOI 10.5281/zenodo.21747067), 2026-09-04. Deposited automatically by Zenodo’s GitHub integration once the recreated GitHub Release was published (the same GITHUB_TOKEN anti-recursion issue already found for v1.0.0, v1.0.6, v1.0.7, and v1.0.8 recurred and was fixed the same way – see docs/ROADMAP.md); verified independently the same way as every prior release (raw Zenodo record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). Reconciled the real DOI and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, and README.md; release_status transitions from development to released.
Reported directly while preparing the container image for this release: the PCA component significance (Tracy-Widom) figure’s subtitle ran off the plot uncorrected.
plot_pca_tracy_widom()(R/ordination.R) built a subtitle up to ~120 characters long (“N of M computed component(s) significant at alpha = 0.05 (Patterson, Price & Reich 2006); N retained (dotted line)”) but never passed it throughwrap_plot_subtitle(), the same fix already applied to the DAPC reproducibility annotation and the ROH length-class subtitle earlier – this one was simply missed since it was written after those fixes landed. New test constructs a realistic long subtitle and asserts it wraps to multiple lines within the established 90-character width.**Requested directly, following up on “check on the container image”: confirmed the published
ghcr.io/duceppemo/popgenvcfimage (built from the rootDockerfile) does include everything the newn_pcs = "auto"feature needs –LEAinstalls by default viainst/scripts/install-bioconductor.R– but the investigation surfaced a second, unrelatedcontainers/directory (Dockerfile,popgenVCF.def) that neithercontainer.ymlnorapptainer.ymlhas ever built from (the real, actually-used definitions are the rootDockerfileandApptainer.def), unreferenced by any doc, and stale on every axis that matters – noLEA, no ADMIXTURE/fastStructure/vcftools, no scientific-validation smoke test. Removed both files and the now-empty directory.Requested directly, following the
--write-config/inst/example_config.ymlfixes below: “is there a step that checks every line of this file is valid?” There wasn’t – existing coverage only spot-checked individual fields (test-autosome-filtering.R’s YAML-boolean-corruption check on one field; a newtest-cli.Rcheck on the two specific fields the entry below already found broken), never the whole file through the real validator,validate_config(). Both of those bugs were only caught by hand-diffing the file againstdefault_config(). Added a test that loadsinst/example_config.yml(the literal file--write-confignow ships, per the entry below) viaread_config(), substitutes real stand-in files for the three fields that are legitimately user-specific placeholders (input.vcf,input.metadata,output.directory), and runs everything else throughvalidate_config()exactly as shipped – a general regression guard against a future edit introducing a bad value or type anywhere in the file, not just the two already-known bugs. Separately confirmed (on the same request) that a user’s own--config/--resume config=file is already validated this same way, asrun_pipeline()’s/run_pipeline_resume()’s literal first step, before any output directory is created or the VCF is touched – a bad field fails fast with a clear error rather than partway into a real run.Requested directly, following up on the just-released v1.0.8: “where does the docs say
n_pcscan be\"auto\"?”, which surfaced that--write-configand the bundledinst/example_config.ymlhad already drifted apart.--write-configserializedtemplate_config()(an R list with no comment metadata, viayaml::write_yaml()) from scratch – a completely separate artifact frominst/example_config.yml, the package’s real, hand-documented reference config. Structurally diffing the two (not just visually) found they had genuinely drifted on a real, non-cosmetic field:analyses.bootstrap.enabledwastruein the actual default (R/config.R) butfalsein the shipped example file. The same investigation caught a second, live bug in the example file itself:sex_check_y_chromosome_names: [Y, chrY]’s bare, unquotedYis a legacy YAML 1.1 boolean literal, so it parsed toTRUEinstead of the string"Y"– the sibling fieldnon_autosomal_chromosome_namesalready quoted"Y"correctly, just not this one – silently breaking Y-chromosome detection for any dataset using bare"Y"as its chromosome name, a very common convention. Fixed both values ininst/example_config.yml, then redesignedwrite_default_config()(R/cli.R) to copyinst/example_config.ymldirectly instead of serializingtemplate_config();template_config()itself is removed (it had become a trivialdefault_config()wrapper, kept only for the now-obsolete distinction it used to encode). Collapsing two artifacts into one makes this class of drift structurally impossible going forward, and gives every user inline documentation for every option instead of a bare value dump.Separately confirmed on request:
compute.threads/compute.memory_mbauto-detection had no safety margin at all.detect_available_threads()/detect_available_memory_mb()(R/system_resources.R, used bydefault_config()whenever a config file omits either field) returned the raw detected/cgroup-limited value with no headroom reserved – an analysis could claim every last core and byte on the machine it ran on. Fixed to reserve one thread for the OS/orchestrating process (floored at 1, so a single-core machine still gets a usable thread count) and cap memory at 85% of what’s detected/allowed (floored at 1 MB) – the same headroom reasoning that motivated the--resumefeature in the first place (an OOM kill is exactly the failure mode--resumeexists to recover from, better avoided than recovered from when possible). The two doc snippets (vignettes/quickstart.Rmd,docs/examples/README.md) that manually reimplemented the same “-1 thread” logic as a workaround (max(1L, parallel::detectCores() - 1L)) are now redundant and were simplified to rely on the built-in margin instead.Also updated
inst/example_config.yml’s input/output path placeholders from generic/path/to/...to the documented/data/...container bind-mount convention (see “Understanding container paths” indocs/user/container-images.md), so the file a user actually gets from--write-config– almost always run inside the Docker/Apptainer image – matches how those images really work rather than needing translation.wiki/Configuration-Reference.md’s own description of--write-configvs.inst/example_config.ymlupdated to match the new copy-based behaviour (it previously, correctly, described them as two artifacts with the same values but different comments – now stale given the redesign above). New/updated tests (test-cli.R,test-config.R,test-sexbias.R) cover the copy-basedwrite_default_config()(byte-identical to the reference file, real comments present, the two fixed fields parse correctly) and drop the now-removedtemplate_config()references. Full local test suite re-run clean.
popgenVCF 1.0.8
popgenVCF 1.0.8 is released. DOI 10.5281/zenodo.22289055 (concept DOI 10.5281/zenodo.21747067), 2026-09-04. Deposited automatically by Zenodo’s GitHub integration once the recreated GitHub Release was published (the same GITHUB_TOKEN anti-recursion issue already found for v1.0.0, v1.0.6, and v1.0.7 recurred and was fixed the same way – see docs/ROADMAP.md); verified independently the same way as every prior release (raw Zenodo record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). Reconciled the real DOI and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, and README.md; release_status transitions from development to released.
Reported directly against the same real production report: table 24.2 (the fastStructure reproducibility table) rendered wrong, because its executable path is too long. Root cause was broader than that one column: unlike the DAPC and sNMF reproducibility tables right beside it in the same “Reproducibility” section, both of which already select only meaningful columns, the fastStructure one (
inst/rmarkdown/templates/popgenvcf_report/skeleton/skeleton.Rmd) dumped its full rawrunstable verbatim – includingexecutable,log_file, andq_file, all long, space-free absolute paths. This codebase’s table-wrapping fix (report_kable_wrap_long_columns(), an earlier fix this same session) wraps long text at word boundaries, which a path with no spaces at all cannot do – confirmed directly: the rendered table showed each path cut off mid-word (fastStruc,outpu), not wrapped.executableis also identical on every K row, adding nothing per-row. Fixed by keeping onlyK,exit_status, andmarginal_likelihood– full paths remain available inanalysis_results.rdsfor anyone who needs them. Updated the existing reproducibility-section test to assert the path columns are gone and the useful columns survive, instead of asserting the now-deliberately-removed executable name is present.Found directly while skimming the same real, freshly re-rendered production report page by page: a population’s own facet strip label was silently clipped in the DAPC membership / ADMIXTURE / fastStructure / sNMF Q-matrix bar charts whenever that population had few samples relative to the rest of the cohort – a real “Ro2-3” population rendered as just “o2”. All four figure families share one plotting function,
plot_q_matrix()(R/admixture.R), whose population-organized view usesfacet_grid(~population, scales = "free_x", space = "free_x")– giving each population’s strip exactly its own panel’s width, proportional to its sample count. A population with only a couple of samples gets a narrow strip with no room for its own label, and ggplot2 clips overflowing strip text rather than wrapping or shrinking it; the same underlying mechanism already scales the x-axis sample-name font down for exactly this reason (axis_label_size), but the strip labels were never given the same treatment. Fixed by computing astrip_text_sizethe same way – from the tightest constraint across every population’s own (sample count, label length) pair – floored to stay legible and capped at the theme’s base size so evenly-sized, generously-wide populations are unaffected; and, since that estimate is only an approximation and a genuinely single-sample population still clipped even at its floored size, also settingstrip.clip = "off"(ggplot2 >= 3.5, now a pinned minimum version) so any residual overflow spills visibly into neighbouring space instead of silently losing characters. New tests cover a real narrow-population reproduction, a genuine single-sample population (the case dynamic sizing alone didn’t fully fix), an evenly-sized control, and the data-driven view (no population facet, unaffected).Found directly while reconciling the same real production report against its own stored analysis state:
analysis_results.rds’s “Pipeline notices” table still showed a stalepca: requested 10 bounded component(s)message from before this session switched the run ton_pcs = "auto", because the earlier ad hoc, targeted PCA re-run (done directly against the production RDS, not through the normal pipeline) updated the actual PCA figures/tables but never touched the recorded pipeline message, nor severalanalysis_results.rds$results$pcafields (requested_components,retained_components) that a realrun_module_pca()call always sets. Not a code bug –run_module_pca()itself was already correct – but a real, user-visible inconsistency in this specific production run’s own report, caught only by actually reading the rendered PDF end to end rather than trusting the individual figure/table fixes in isolation. Corrected directly against the productionanalysis_results.rds: re-ranrun_pca()(deterministic, reproduced the already-recorded 14/49 result exactly), backfilled the missing result fields, and replaced the stale notice text.Reported directly against the same real production report: a redundant PCA scatter plot appeared at the very end of the report, sorted after everything else, effectively identical to the report’s own
07_PCA_PC1_PC2figure. Root cause:write_pca_publication_artifacts()(R/pca_publication.R) writes its own standalone, publication-readyPCA_PC1_PC2.pdf/.svg/.png– meant for a user to pull directly into a manuscript – into the exact samefigures/directory thatreport_figure_inventory()(R/report.R) scans indiscriminately for every report format; its un-numbered stem sorted to the end since every one of this package’s ownsave_plot()-written report figures uses a numbered stem (natural_sort_key()-ordered) and this one has none. The same shared-directory pattern exists in the (currently unused/unwired) IBS and ancestry publication-artifact writers too, so this was a systemic risk, not just a one-off. Fixed by havingreport_figure_inventory()only consider files whose stem matches this package’s own^[0-9]{2}[[:alpha:]]?_numbered-figure convention – the publication-ready copies stay exactly where they are on disk (still directly reachable, or via the written artifact manifest), they simply aren’t swept into the report a second time. New test (test-report-figures.R) reproduces the exact bug directly: a numbered figure and its un-numbered publication-ready duplicate side by side, asserting only the numbered one is inventoried.Reported directly against the same real production report: the ROH
FROH-by-run-length-class figure’s subtitle was truncated, cut off mid-word at the right edge.plot_roh_length_class()’s subtitle (“Short: background LD; intermediate: distant common ancestors; long: recent close inbreeding”, 93 characters) is the longest of the three ROH figure subtitles and the only one that overflowed the plot’s rendered width – the same known issue (ggplot2 subtitles don’t auto-wrap) already fixed for a DAPC figure’s subtitle earlier viawrap_plot_subtitle()(R/utils.R). Applied the same fix here; the other two ROH subtitles were checked directly against the real production figures and confirmed to already fit without truncation, so left unchanged.Requested directly, following the
n_pcs = "auto"feature below: “can we always run the Tracy-Widom analysis and output the scree plot even if the user selects a fixed number of PCs? Then it will give the user the ability to check if the number they provided is somewhat close to the ‘optimal’ one.”run_pca()gained analways_tracy_widomargument (defaultFALSE, preserving every existing caller’s behaviour unchanged, e.g. the per-chromosome PCA inrun_chromosome_analyses()): when set, a fixedn_pcsstill retains exactly what was requested, but the eigendecomposition is widened to the same generous 100-component cap"auto"uses (recover_pca_eigensystem()’s rare covariance-eigendecomposition fallback widened to match), and the Tracy-Widom test runs as a best-effort comparison – a missingLEAinstallation or any other failure degrades to simply not drawing the comparison figure (WARNINGlogged), never to failing the PCA module itself, since this is purely informational for a fixed choice.run_module_pca()(R/pca_registry_integration.R) now always passesalways_tracy_widom = TRUE, so every real report gets this comparison regardless of whethern_pcsis"auto"or a fixed integer.plot_pca_tracy_widom()now takes the actually-retained component count as a fourth argument: when it equals the Tracy-Widom-significant count (always true for"auto"), the figure is unchanged from before; when they differ (a fixedn_pcsthe user chose independently), a second, distinctly-styled (dotted, orange) reference line marks the user’s own choice, with the subtitle stating both numbers plainly. New tests cover the widened-spectrum retained-vs-significant comparison for a fixedn_pcs, graceful degradation whenLEAis unavailable (mocked), and both single- and dual-reference-line rendering inplot_pca_tracy_widom(). One pre-existing source-pinning test (test-pca-runtime-eigenvalues.R) was updated to match:eigen.cntis now driven by a dedicatedeigen_cntvariable (which itself derives fromrequested_components, just no longer identical to it whenever a Tracy-Widom comparison is requested) rather than being the same variable directly. Full local test suite re-run clean.Requested directly, following the
n_pcs = "auto"feature below: “add a figure if the Tracy-Widom test is being used – % vs. index, with the selected value highlighted” (referencing LEA’s owntracy.widom()example plot). Addedplot_pca_tracy_widom()(R/ordination.R), a scree-style plot of percent-of-total-variance vs. component index, only produced whenanalyses.n_pcs = "auto"actually triggered the test – every retained (significant) component is highlighted against the discarded noise tail, with a dashed reference line at the cutoff and a subtitle stating the exact significant/computed counts and alpha.run_pca()now returns the full Tracy-Widom statistics table (pca_tracy_widom_table(), factored out of the significance-count logic so both share one LEA call site) alongside the previously-returned significant count, andplot_pca()draws the new figure automatically as its first step whenever that table is present (NULL/absent for a fixedn_pcs, so no figure – and no LEA dependency – when auto-selection wasn’t used). The Tracy-Widom table is also now persisted in the stored PCA analysis result (run_module_pca(),R/pca_registry_integration.R), matching how DAPC already retains its full fitted model rather than only its summary tables. Separately, fixed a real latent bug this feature’s own review surfaced:run_chromosome_analyses()(R/chromosome.R) computed its per-chromosome PCA’s component cap asmin(3L, cfg$analyses$n_pcs)– withn_pcs = "auto"(a string), this silently coerces3Lto"3"and relies on"3"sorting lexicographically before"auto"to still land on the intended cap of 3; correct by coincidence (any digit-string sorts before a letter-string in this comparison) but fragile and not the actual intent. Replaced with an explicitif (identical(cfg$analyses$n_pcs, "auto")) 3L else min(3L, cfg$analyses$n_pcs). New tests (test-pca-auto-components.R) cover the Tracy-Widom table’s structure and its agreement with the existing significant-count function,run_pca()returning/omitting the table correctly for auto vs. fixedn_pcs, the plot’s highlighting and no-op behaviour, andplot_pca()writing (or correctly not writing) the new figure end to end. Full local test suite re-run clean.Requested directly, following the DA-eigenvalues discussion below:
analyses.n_pcs(inst/example_config.yml) has always been a fixed integer (10 by default) for the PCA module, with no data-driven way to pick it – raised as “should the number of PCA axes retained be an auto scree-plot analysis instead of a fixed default?”. Addedn_pcs: "auto"as an opt-in alternative to a fixed integer, using the same sequential Tracy-Widom eigenvalue significance test EIGENSOFT’ssmartpcauses (Patterson, Price & Reich 2006) to stop retaining components once the next eigenvalue is no longer statistically distinguishable from the noise tail, instead of retaining a fixed count regardless of the data. Implemented viapca_significant_component_count()(R/ordination.R), which calls LEA’s compiledR_tracyWidomroutine directly rather than throughLEA::tracy.widom()(an S4 method hard-tied to LEA’s own file-basedpcaProjectPCA workflow) – confirmed by direct comparison that calling the same underlying.C()routine with a hand-written eigenvalues file reproduces LEA’s own tutorial-dataset output exactly.pca_component_count()now accepts"auto"alongside an integer >= 2 (still capped atmin(n_samples - 1, n_snps, 100)), andrun_pca()retains only the significant leading components when"auto"is requested, logging the Tracy-Widom result.validate_config()(R/config.R) accepts"auto"as the sole valid non-numeric value foranalyses.n_pcsand rejects any other string (including case variants like"AUTO") with a clear error. Kept opt-in rather than the new default, since a fixed, predictable PC count remains the better default for reproducing a specific prior run. Requires theLEApackage (already an existing optionalSuggestsdependency for the sNMF ancestry backend, not part of the base Docker image); errors clearly if"auto"is requested without it installed. New tests (test-pca-auto-components.R) cover the cap/fallback behaviour, rejection of invalid strings, a real end-to-endrun_pca(n_pcs = "auto")call against a real Tracy-Widom result, the missing-LEA error path, and config validation. Full local test suite re-run clean.Follow-up to the same discussion: once “auto” PCA was addressed, the DAPC PC-count cross-validation and eigenvalues figures were reconsidered directly – “it would be better to get rid of [the eigenvalues figure] as a standalone figure and move it inside the DAPC figure as a smaller insert (position may vary so it doesn’t cover any dots), no axis titles, no need to highlight.” The standalone
plot_dapc_eigenvalues()(which rendered one12c_DAPC_eigenvalues_K<k>figure per K, with a highlighted retained-axis bar and %-contribution labels – see the two entries below) is removed entirely. Replaced withdapc_eigenvalues_inset()(a minimaltheme_void()bar chart, no axis titles, no legend, just each bar’s%.0f%%contribution label) composited directly onto each K’s own DAPC scatter plot viaggplot2::annotation_custom()/ggplot2::ggplotGrob().add_dapc_eigenvalues_inset()picks whichever of the plot’s four quadrants (split at the data’s own x/y midpoints) contains the fewest sample points and places the inset there, so it never sits on top of real data regardless of cluster shape or count. Verified visually against the real corrected production DAPC results (post cross-validation fix, below): correct empty-quadrant placement on both a simple 3-cluster and a complex 10-cluster (9-bar) scatter, no overlap, no axis clutter.12c_DAPC_eigenvalues_K<k>figure files are no longer written at all – the % contributions are only ever shown as the inset now. New tests (test-dapc-xval.R) replace the old per-K eigenvalues tests: inset construction (no axis titles/legend, correct % labels), the no-eigenvalues-returns-NULLedge case, quadrant selection (including a fixed test-data tie that previously masked the wrong corner), the no-inset/no-finite-coordinates passthrough, and an end-to-endplot_dapc()check that every saved K’s plot carries the inset layer and no standalone eigenvalues file is written. Full local test suite re-run clean.Follow-up, requested directly: keep one DA eigenvalues figure per K after all (reverting the highest-K-only consolidation below), and a real bug found while considering how to show the PC-count cross-validation curve declining past its peak.
xvalDapc()’s search ceiling (n.pca.max) was set to the same conservative fallback value meant only for when cross-validation doesn’t run at all – confirmed directly on real production data, this limited a 50-sample K=3 model’s search to n.pca 1-5, and “5” (the ceiling itself) came back as the winning value: a better value above 5 was never even tested, and the resulting curve had no room to show a decline past its own peak. Fixed by using the real, generousmax_pcaas the search ceiling, keeping the small fallback strictly for the no-cross-validation case it was designed for. Also revertedplot_dapc_eigenvalues()to render once per K again (each with its own highlighted-bar/%-label treatment from the fix below), since per-K panels are still preferred over the single-highest-K summary. Separately checked and confirmed directly: discriminant axes beyond K-1 don’t just contribute little, they don’t exist at all –adegenet::dapc()silently clips anyn.darequest above the between-group covariance matrix’s rank (K-1) rather than returning extra near-zero eigenvalues, so there is no real data to extend the eigenvalues x-axis with. New test (test-dapc-parallel.R) locks in the corrected search ceiling; the per-K eigenvalues test (test-dapc-xval.R) reverted to match.Follow-up to the DAPC cross-validation fix below: fixing it made cross-validation actually run, and a real ~20-minutes-per-K cost (n.rep = 30 on the full QC-passing marker set) surfaced a second, independent problem –
run_module_dapc()(R/module_registry.R) was the only analysis module using the full 561,767-SNP QC-passing set instead of the 54,052-SNP LD-pruned set every other module (kinship, IBS, population assignment, clonality, …) already uses. Not a judgment call: the wiki’s own Results-and-Interpretation page already documented DAPC as one of the LD-pruned-set consumers in three separate places (“PCA, IBS, kinship, DAPC, the ancestry backends…” etc.) – the code just didn’t match its own documented design. Fixed by switchingrun_module_dapc()tocontext$final_snps, subsetting the already-loaded diversity genotype matrix by column position exactly the wayrun_module_clonality()already does for its own LD-pruned copy (final_snpsis always a subset ofqc_snps, so thematch()is a safe positional lookup). Cuts marker count ~10x, which scales directly to bothxvalDapc()’s cost and every replicate’s own model-fitting cost. New assertion added to the existing real end-to-endtest-pipeline.Rintegration test: every DAPC loading’ssnp_idis now checked against the written LD-pruned marker list.Reported directly against the same real production report: the DAPC PC-count cross-validation figure (
12b_DAPC_xval_K<k>) was missing entirely, every K showed exactly the same “40 PCA axes retained” (suspiciously round for a supposedly cross-validated choice), and the K=3 scatter showed nearly every sample collapsed onto one point – the classic DAPC over-fitting signature. All three were the same root cause.adegenet::xvalDapc()– unlikeadegenet::glPca(), used everywhere else inrun_dapc_k_task()(R/dapc.R) – callsade4::dudi.pca()directly on whatever data it’s given, which hard-errors on any missing genotype ("na entries in table"); a real QC-passing marker set legitimately contains missing calls (QC bounds missingness, it doesn’t eliminate it), so cross-validation was failing on every K, always caught by a baretryCatch(..., error = function(e) NULL)with no log line at all, silently falling back to a crudefloor(0.8 * n_samples)PC count instead – confirmed directly by reproducing the exact error against the real cached GDS. Fixed by passing a mean-imputed genotype table (adegenet::tab(gl, NA.method = "mean"), computed once and shared across every K, the same reasoning as the existing shared PCA) asxvalDapc()’s data argument instead of the raw genlight. The failure is now also logged with the real error message if it ever recurs for a different reason, instead of vanishing silently. The fallback formula itself was also tightened from0.8 * n_samplesto0.1 * n_samples– not a safety margin, but a real, defensible default now that it’s a true last resort rather than the value nearly every model was silently using. Separately, cross-validation was being re-run once per reproducibility replicate (structure.replicates, default 3+) instead of once per K –structure.replicates-fold morexvalDapc()cost than necessary, and it let different replicates of the same K silently fit at different n.pca values, mixing cross-validation’s own sampling variability into the replicate-membership reproducibility metrics. Now computed once, from the first replicate’s clustering, and reused. New tests (test-dapc-parallel.R): the fallback formula, the imputed-matrix pass-through (a real missing-genotype fixture, mockedxvalDapc()capturing its data argument), and the now-logged failure path. Existingtest-dapc-xval.R,test-dapc-loadings.R,test-dapc-plots.R,test-module-dapc-spec.R,test-publication-dapc-outputs.Rsuites all pass unchanged.Requested against the real production PDF report: long pipeline-notice messages forced the whole table into an illegibly tiny font, tall tables (LD decay, chromosome-specific results) were truncated partway down the page with two pages left entirely blank, and figure-level issues (Figure 3’s sample labels overlapping, a DAPC figure’s subtitle cut off mid-word, nine near-identical DA eigenvalues figures) needed cleanup. Two independent LaTeX defects were behind the table symptoms:
report_kable()(skeleton.Rmd) wrapped every table in a plain (non-paginating)tabular, so a table taller than one page’s\ht+\dpsimply lost every row past the physical page bottom – not an error, just silently gone, and the almost-certain cause of the two blank pages (a huge unbroken box straining LaTeX’s page-breaking); and a long free-text cell (e.g. a kinship warning message) forced the table’s naturally-measured width so wide that\resizeboxhad to shrink every column, including short numeric ones, to fit. Fixed withreport_kable_render(): tables now render viakable(..., longtable = TRUE)with\endheadinserted by hand so headers repeat on every page (baseknitrdoesn’t do this on its own), but only when a table’s own measured height exceeds ~82% of\textheight– everything else (the large majority of real result tables) keeps the original, exact-fit\resizeboxbehaviour, since\resizeboxcannot wrap alongtableat all and an always-longtable earlier version of this fix regressed the common case (a real 10-32-column table still overflowed\textwidtheven at\scriptsize, discrete sizing’s smallest step). Long free-text columns (character values over 40 characters) are now wrapped in\parboxinstead of shrinking the whole row. Two secondary LaTeX bugs surfaced only by rendering full reports at these sizes: the height check needed\ht+\dp, not\htalone (confirmed directly –\htalone came in just under the pagination threshold for a real 80-row table, sending it down the row-losing branch, while the combined height was unambiguously over it), and a raw output line starting with a bare{or}(not attached to a backslash command) isn’t recognized by pandoc’s raw-LaTeX passthrough inside aresults='asis'chunk, rendering as a literal visible brace instead of functioning as grouping – fixed by scoping with\begingroup/\endgroupthroughout instead. Figure fixes:03_sample_missingness‘s y-axis sample-name font now scales down (with a floor) once there are enough samples to overlap at the fixed theme size, confirmed against a real 50-sample report; the DAPC reproducibility/stability annotation used as several figures’ subtitle (dapc_reproducibility_annotation(),R/dapc.R) is now wrapped via a newwrap_plot_subtitle()(R/utils.R) instead of overflowing the plot and getting silently clipped – this was the same text in both the “Fig 16 subtitle truncated” and “RMSE warning truncated” reports, one underlying cause;plot_dapc_eigenvalues()now renders once, for the highest K only (every K’seigvector already holds exactlyn.daretained values, adegenet’sdapc()never returns a larger discarded pool to compare against), highlights the right-most bar as the total-retained-count marker, and labels each bar with its % contribution; and the number of PCA axes retained (n_pca) is now shown on every DAPC figure that has one, not justplot_dapc_xval()’s own cross-validation curve. New/updated tests:test-report-figures.R(a real tall-table-plus-long-message end-to-end PDF render, checked withpdftotext/pdfinfofor row completeness and page count),test-plot-palettes.R(label-size scaling),test-dapc-xval.R(eigenvalues highlighting/labelling/highest-K-only across several K values),test-dapc-loadings.R. Existingtest-pipeline-resume.R,test-dapc-plots.R,test-dapc-parallel.R,test-qc-reports.R,test-qc-contract.Rsuites all pass unchanged.Requested directly against the same real production run: even after the PNG-fallback fix below, the report PDF (still ~150-250MB of embedded raster figures at their source 600 DPI) was too large to email; asked for compression that doesn’t compromise figure quality too much. Added
compress_report_pdf()(R/report.R), a Ghostscriptpdfwritepost-processing pass applied to the finished PDF report – it recompresses/downsamples embedded raster images in place and leaves genuine vector content (text, the smaller figures that stayed vector PDF) untouched, so it’s a single pass over the assembled document rather than reworking every figure-generating function. Preset chosen by direct comparison on the two largest real production figures (dense Manhattan-style DAPC/PCA loadings plots):/screen(72 DPI) and/ebook(150 DPI) both left dense multi-panel axis labels illegible;/printer(300 DPI) kept them legible while still compressing a 44MB 2-figure worst-case prototype to 1.1MB. Gracefully skips (with a loggedWARNING, not a hard failure) whengsisn’t onPATH, or when Ghostscript’s own output isn’t actually smaller than the input – so a report with no oversized rasters, or an environment without Ghostscript, is left exactly asrmarkdown::render()produced it.ghostscriptadded to bothDockerfiles alongside the existingtexlive-xetex/pandocPDF toolchain. New tests cover both the missing-gsfallback and a real compression pass (mockedSys.which()for the former;skip_if_not()on realgsavailability for the latter). Existingtest-pipeline-resume.Randtest-report-figures.Rsuites both pass unchanged.Reported directly against the same real production run, once the OOM fix below let it finish: the generated report PDF was 954MB, over 3x its HTML sibling (291MB), when it should be far smaller than the HTML.
report_figure_inventory()prefers embedding each figure’s on-disk vector.pdffor a PDF-format report – fine in general, but a dense per-locus/per-K scatter (Manhattan-style DAPC/PCA loadings plots, genome-wide allelic richness) draws one vector path per plotted point, and pdflatex/xelatex concatenates each\includegraphics’d PDF’s content stream verbatim with no recompression. Traced directly: nine such figures alone (11-126MB each) accounted for ~915MB of the 954MB report, while their PNG counterparts (identical visual content, rasterized) were 0.5-30MB. Fixed inreport_figure_inventory()by deprioritizing any on-disk PDF figure above 2MB (comfortably above every legitimate vector figure seen, comfortably below the smallest pathological one) in favor of its PNG sibling for PDF-report embedding – confirmed against the real dataset’s figures directory: selected-figure payload drops from ~915MB to 148MB (129 of 151 figures keep their vector PDF; the 22 oversized ones fall back to PNG). New test (test-report-figures.R) covers the fallback directly. Existingtest-pipeline-resume.Randtest-report-figures.Rsuites both pass unchanged.Reported directly against a real production run:
render_report()’s PDF pass was killed again with no error logged, the same symptom the 2026-08-28 fix was supposed to have closed. That earlier fix (report.max_concurrent_figures) forces HTML and PDF to render sequentially instead of concurrently once a report has enough figures, avoiding the doubled peak memory of two simultaneous renders – but it doesn’t touch a second, separate memory source:finalize_pipeline_analysis()(R/pipeline.R) still holds the fullanalysisobject – every module’s retained results (DAPC replicate fits across K=2:10, admixture/faststructure/snmf output, trees, clonality, kinship, …) – live in the same R process for the entire report-rendering call, on top of whateverxelatex/pandoc needs to compile the PDF. On this run (151 figures, 303 embedded files) HTML rendered and was written successfully; the process vanished during the PDF pass with no log line at all, consistent with an externalSIGKILL(OOM-killer or similar), which bypassesrun_stage()’stryCatchentirely – it can’t log what it never gets a chance to catch. Fixed by droppinganalysis(rm();gc()) right before the report-rendering call and reloading it from theanalysis_results.rdsjust written a few lines earlier (which already reflects everything up to that point), so PDF rendering no longer stacks its own memory use on top of the full pipeline state. Verified with the existingtest-pipeline-resume.R(29 tests, including report rendering across multiple checkpoint-resume scenarios) andtest-report-figures.R(67 tests), both passing unchanged.
popgenVCF 1.0.7
popgenVCF 1.0.7 is released. DOI 10.5281/zenodo.22210850 (concept DOI 10.5281/zenodo.21747067), 2026-08-31. Deposited automatically by Zenodo’s GitHub integration once the recreated GitHub Release was published (the same GITHUB_TOKEN anti-recursion issue already found for v1.0.0 and v1.0.6 recurred and was fixed the same way – see docs/ROADMAP.md); verified independently the same way as every prior release (raw Zenodo record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). Reconciled the real DOI and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, and README.md; release_status transitions from development to released.
-
Requested: a seventh gap-audit pass, on three classic base-R pitfall patterns not yet specifically targeted –
1:n-style off-by-one bugs on zero-length input,sapply()/vectorized-iftype instability, and algorithmic scalability at real production scale. The1:naudit came back genuinely clean (this codebase consistently usesseq_len()/seq_along()throughout – only two colon-range constructs exist anywhere inR/, both already safely guarded) andsapply()is never used at all (this codebase exclusively usesvapply()). The other two found real, live issues.validate_config()(R/config.R) – the very first code every real pipeline run executes – either crashed with a cryptic, generic R error or, for at least two fields, silently accepted a malformed config value with no error at all. Every scalar-valued config leaf’s own range/finiteness check (if (!is.finite(x) || x > bound)-style, dozens of them) assumesxalready has length 1. A malformed config – a realistic mistake, e.g. an accidental YAML sequence likemaf: [0.1, 0.6]from a copy-paste or formatting slip – reaches these checks as a multi-element vector instead:if()on a length > 1 condition is a hard error on every R version this package supports (DESCRIPTIONrequires R >= 4.3), so most fields crashed with"the condition has length > 1"instead of this codebase’s otherwise-consistent clear diagnostics. Worse, two fields (max_sample_missing,ld_r2) are checked only via a vectorizedany()over a combined value vector – both individually in-range elements passed that check silently, and neither field was ever coerced back to scalar afterward, sovalidate_config()returned a genuinely malformed length-2 config with no error raised at all. Fixed with a single, comprehensive scalar-length guard added early invalidate_config(), checking every one of the ~48 scalar-expected config leaves up front and reporting every offending one by name in one clear, uniform error – before any individual comparison can crash or silently mismeasure a multi-element value. New tests cover both the crash case (maf) and both silent-pass cases (max_sample_missing,ld_r2), plus a sex-check threshold,sexbias_test, and the genuinely-optionalpcadapt_k.run_genome_scan_fst()/run_genome_scan_diversity()(R/genome_scan.R) re-scanned the entire retained marker set (or, for diversity, the entire population-wide locus set) for every single genome-scan window, instead of restricting the scan to that window’s own chromosome first. At the config defaults (50kb window/step) against this session’s own real production example (561,767 loci), that’s roughly 20,000 windows each doing an O(n_loci) linear scan – true O(n_windows × n_loci) instead of the O(n_windows × n_loci_per_chromosome) the already-per-chromosome window construction (genome_scan_windows()) makes straightforward to achieve, with the diversity side further repeating the same redundant work once per population. Fixed by pre-splitting the marker/locus positions by chromosome once, so each window’s scan is restricted to its own chromosome’s subset – a pure search-space restriction with no semantic change (the same final SNP/locus selection either way), verified by the existing exact-match serial-vs-parallel tests continuing to pass unchanged at real scale (98,922 variants, 160 samples).
Full local test suite re-run clean: 4573 passed, 0 failed.
-
Requested: a sixth gap-audit pass, on three fresh angles – locale/sort-order reproducibility, numerical stability under real floating-point edge cases (a different lens than the formula-correctness checks already done), and cross-platform Windows path/fork-guard consistency. All three found real, live bugs.
Locale-dependent
sort()could silently change figure colours/shapes and table row order depending on which machine ran the analysis.population_palette()/population_shapes()(R/utils.R) sorted population names with base R’s default, locale-sensitive collation – empirically confirmed on this platform: the same mixed-case population set sorts differently under a"C"locale than under"en_US.UTF-8", silently assigning different colours/shapes to the same populations across figures (PCA, DAPC, trees, …) depending on the runtime locale, with no indication anything differed.compute_jost_d()(R/jost_d.R) andrun_bottleneck_analysis()(R/bottleneck.R) had the same issue affecting written table row order.natural_sort_levels()(R/utils.R, used for Manhattan-plot chromosome ordering) had a related gap: its digit-padding only protects purely-numeric comparisons, not the underlying character ordering for non-digit runs ("X"/"Y"/"MT"-style names). All four fixed by sorting withmethod = "radix", which R documents as platform-independent for character vectors (verified directly: identical output under both locales tested) – giving this package’s figures and tables the same cross-machine reproducibility guarantee its checkpoint/config fingerprints already have. New test assertspopulation_palette()/population_shapes()output matches the fixed radix order directly, without needing to actually switch locales in CI.spatial_autocorrelation_r()(R/spatial_autocorrelation.R) crashed with “missing value where TRUE/FALSE needed” on real, non-adversarial input: a sample pair sharing zero non-missing genotyped loci after QC.stats::dist()returnsNAfor such a pair;rs/sgdalready usedna.rm = TRUE(showing this was anticipated), but the per-bincx/cxii/cxjjsums downstream did not, so a singleNApair propagated intodenom, andif (denom == 0)on anNAdenominator is a hard R error, not the gracefulNA_real_this function clearly intends for the analogous zero-denominator case. Fixed withna.rm = TRUEon all three sums and an explicitis.na(denom)guard.haversine_matrix()(R/mantel_ibd.R, shared byrun_mantel_ibd()andrun_spatial_autocorrelation()) could returnNaNfor real geographic coordinates. Its intermediateaterm is mathematically bounded in[0, 1]but is a sum of two independently-rounded trig terms; for near-antipodal coordinates, floating-point rounding pushes it fractionally above 1, and the unclampedsqrt(1 - a)silently returnsNaN(a well-known haversine-formula pitfall). Confirmed directly with an exact antipodal coordinate pair (lat -89.26/89.26, lon -180/0) that triggersa > 1on this platform. Fixed by clampingato[0, 1]before bothsqrt()calls; new test verifies the same pair now returns the correct antipodal distance (~20015 km) instead ofNaN.run_tree_bootstrap_replicates()(R/tree_bootstrap.R) – reached viabuild_nj_tree()/build_population_tree()– was the only remainingmclapply()path in the codebase that didn’t route its worker count throughfork_worker_count(), the shared Windows/task-count/thread-budget guard every other fork site uses. Both real callers passedcfg$compute$threadsstraight through asworkers. Not an active crash –mclapply()itself already forcesmc.cores = 1on Windows – but a real, live inconsistency that also meant this path skippedfork_worker_count()’s other protections (NA/zero/negative-threads coercion). Fixed by re-derivingworkersviafork_worker_count()inside the shared helper itself, closing the gap for every current and future caller at its root; new test proves anNAworkersvalue (which would have hitif (NA)and crashed under the old code, the same error class fixed in finding 2 above) is now handled cleanly.
Everything else checked – the numerical-stability audit’s broader sweep of Fst/diversity/AMOVA/kinship/DAPC-diagnostics/Ne-LD/genome-scan formulas for NaN/Inf propagation and degenerate-input handling, and the Windows audit’s check of hardcoded path separators,
/tmpliterals, and executable resolution – came back clean (a minor, non-livefile.access()platform-semantics note inR/vcf_input.Rwas judged not worth prioritizing: it only gates a pure optimization, not a correctness path). Full local test suite re-run clean: 4566 passed, 0 failed. -
Requested: a fifth gap-audit pass, taking a different angle than the first four – a security review of external-tool invocation and file handling, the CI glue scripts and workflow YAML orchestration themselves (not just the R library code they call), and a sweep for tests that don’t actually verify what they claim (a pattern that had already caught three real bugs this session). Four more parallel audits: the scripts/*.R glue code and the test suite both came back genuinely clean; the security and GitHub Actions workflow audits each found a real issue.
Security:
--resume’s checkpoint-loading path (read_execution_checkpoint(),R/execution-checkpoint.R) documented its SHA-256 sidecar in a way that overstated the protection it actually provides. The sidecar is written next to the checkpoint by the same ordinary write, so it detects accidental corruption (a truncated copy, a partial write) but provides no defense against a deliberate attacker, who can just as easily recompute and replace a matching sidecar for a crafted payload – andreadRDS(), which this function ultimately calls, can execute arbitrary R code embedded in a maliciously crafted file the moment it is deserialized, a documented, general property of R’s own serialization format. This is inherent to using RDS for checkpoints at all, not a code defect fixable without a larger architecture change (cryptographic signing, or a sandboxed loader) that wasn’t undertaken unilaterally here – instead,write_execution_checkpoint()’s/read_execution_checkpoint()’s own docstrings,man/execution_checkpoint.Rd, andwiki/Deployment-and-Troubleshooting.md’s--resumesection were all corrected to state the real trust boundary plainly: only ever resume from anoutput.directoryyour own runs produced, never one downloaded, received from a collaborator, or restored from storage you don’t fully control.Security:
portable_gds_to_bed()(R/nonhuman_plink_export.R), reached whenever ADMIXTURE/fastStructure runs on non-human chromosome data, built its PLINK subprocess arguments withoutshQuote()-ing the dynamic file paths.command_runner’s default,system2(), was confirmed to shell-interpret itsargsvector on Unix (base R joins command and args into one string run through a shell) – anoutput.directorypath containing a shell metacharacter would otherwise either break or be silently shell-interpreted, unlike every other external-tool call site in this codebase, which alreadyshQuote()s dynamic path arguments (and unlike this same module’s own PLINK/ADMIXTURE binary invocations, which correctly route through the shell-freeprocessx::run()instead). Fixed toshQuote()both dynamic paths; new test proves the arguments handed to the command runner are properly quoted.CI: the
public-api-contract.ymlandrelease-metadata.ymlworkflows’ own gate-record-writing steps had noif: always()guard, so a genuine contract violation or metadata inconsistency – the exact failure these gates exist to catch – skipped the step entirely, meaning no gate-record JSON was ever written for a real failure. Confirmed live and consequential:release-candidate-collector.ymldispatches these workflows and inspects their gate-record artifacts to assemble a release’s evidence index; a workflow that fails before writing its gate record leaves that gate silently absent from the index rather than explicitly recorded as failed. Both steps now run unconditionally and write an accurate"failed"record (with a clear explanatory summary) even when an earlier step crashed before producing the evidence file this step normally reads – verified locally against three real scenarios (all-pass, a genuine validation failure, and a missing-evidence crash) for each workflow before pushing, since these steps can’t be exercised by the localtestthatsuite.
Everything else checked in this pass – bcftools/PLINK/ADMIXTURE invocation elsewhere, path handling for VCF/metadata-derived content, executable resolution, the
scripts/*.RCI entry points’ control flow and exit-code correctness, and the wholetests/testthat/suite for more instances of the “test doesn’t verify its own claim” pattern – came back clean. Full local test suite re-run clean after all fixes: 4557 passed, 0 failed. -
Requested: a fourth gap-audit pass, covering the release-governance/canonical-validation framework the first three passes had deliberately left aside as out of scope for the real analysis pipeline. Five more parallel audits (canonical-baseline/release-governance; the Phase 9/Phase 10 module-contract layer; benchmark/dashboard/regression tooling; manuscript regeneration/revision/submission tooling; runtime-audit/project/artifact-lineage tooling) confirmed the Phase 9/Phase 10 layer, most manuscript regeneration/revision/submission tooling, and the runtime-audit/project/artifact-lineage family are entirely dead code with no live caller anywhere (
R/dashboard.Rtoo) – but the canonical-baseline and benchmark/regression layers turned out to be genuinely live, called from realscripts/*.Rtooling behind real CI release gates (.github/workflows/canonical-real-data.yml,scientific-release-integration.yml, a release-benchmark-archive workflow), and turned up two serious bugs of exactly the kind those gates exist to catch – a real regression silently passing as approved:reconcile_canonical_changes()(R/canonical_change_approval.R) could let an OLDER, more permissive approval silently govern over a NEWER, stricter one covering the same metric. When two requests both havestatus = "approved"for the same metric – a real supersession scenario, a stricter approval later replacing an earlier, more permissive one – the code picked which one governs viacandidates[[length(candidates)]], wherecandidatesis built from the registry’s own storage order: alphabetical by request id, not decision date or supersession."req-10"(approved later, stricter) sorts before"req-9"(approved earlier, permissive) because'1' < '9', so the stale, more permissive approval would silently govern – the exact reverse of what the schema’s ownsupersedesfield exists for (defined since this field was added, but never actually read anywhere until now). A real “major” drift the newer policy should block could instead be judged"approved_change"under the old"major"-permitting approval, reachingrelease_ready = TRUE. Fixed: a new.resolve_governing_approval()excludes any approval a same-metric approval’ssupersedesfield names, falling back to the most recently decided approval when no supersession chain resolves a tie. New tests reproduce the exact alphabetical-sort trap and the correct supersession/recency resolution.compare_release_benchmarks()(R/regression_reporting.R) could report an overall"passed"release comparison even when a validation component’s pass/fail verdict genuinely regressed between releases. Its digest-comparison branch (used for every archived component that isn’t a raw performance result – includingscripts/build_release_benchmark_archive.R’s own archivedrun_scientific_validation()/run_population_structure_validation()$checkstables) only ever emitted"unchanged"/"changed", and"changed"was never in the set (c("failed", "error")) that flips the overall comparison to"failed"– so a real regression (a$checkstable’spassedcolumn going from all-TRUEto not-all-TRUE) was silently reported as"changed"/overall"passed", defeating that script’s own release-gatestop()(which only fires oncomparison$status == "failed"). The existing test literally asserted this exact behavior as correct. Fixed with a new.component_validation_regressed()check: a component’s ownpassed/validfield (scalar or column) going from all-TRUEto not-all-TRUEnow flags that component"failed", while an unrelated content change (e.g. a note or a metric’s value legitimately evolving) still correctly stays informational"changed", not an over-eager blanket failure. New tests cover both the regression case and the intentionally-non-blocking case; the pre-existing test’s scenario re-verified to still correctly report"passed"(it has no pass/fail field at all).
Two lower-severity siblings of the same “silently omits a check” pattern, also fixed:
compare_performance_baseline()(R/performance_benchmarks.R) used an inner join onthreads, silently skipping any thread count present in only one of observed/baseline instead of reporting it as incomparable – now errors clearly.run_benchmark()(R/benchmark_registry.R) reported a vacuous"passed"for a spec whose runner returned empty observed/reference values despite a real reference being supplied (0 comparison rows was previously always treated as trivially passing) – now only vacuously passes when the spec genuinely has no reference at all (a real, legitimate, documented case), not when a reference existed but produced zero comparable rows.Lower-priority findings in the now-confirmed-dead code were noted but not fixed, per this session’s established policy of prioritizing live, user/CI-facing correctness over scaffolding with zero current effect: a JATS tag-balance validator structurally unable to detect its own stated defect, a journal-submission-profile crash on an
NA-disabled highlight limit, a GDS-context digest false-equivalence landmine in an unwired runtime-replay framework, a check-then-act race in an attempt-ledger writer, and an incompletely-enforced digest-validation invariant in project tamper-evidence checking. Full local test suite re-run clean after all four fixes: 4554 passed, 0 failed. -
Requested: a third gap-audit pass, covering the external-process supervision layer and everything else still unaudited. Two more parallel audits (subprocess dispatch for external tools like ADMIXTURE/fastStructure; a reachability-first sweep of the remaining ancestry/diagnostic/utility files) came back essentially clean – confirming, in the process, that a substantial further slice of this codebase (an unfinished async/resumable external-process layer in
R/external_process_async.R/R/external-process-result-persistence.R/R/external-process-workspace*.R; most of theancestry_*.Rfamily beyond the real per-run admixture/faststructure/snmf modules;R/fair_export.R,R/external_reference_validation.R,R/reference_adapters.R,R/golden_outputs.R,R/scientific_object_cache.R,R/schema-validation.R,R/execution_plan.R) has no live caller from the real pipeline, matching the pattern already found for the publication/manuscript layer in the second pass. One small, real fix came out of it:davies_bouldin_index()(R/dapc_k_diagnostics.R, used bystructure_k_consensus.R’s cluster-number diagnostics) filtered candidate ratios with a blanketis.finite()across each group’s full distance row – correctly excluding the meaninglessi == iself-term, but also silently discarding the case where two genuinely different DAPC groups happen to share the same discriminant-space centroid (a real, if rare, degenerate clustering), understating the index instead of reporting the undefined result asNA. Fixed to exclude only the self-term explicitly, letting a real zero-distance-between-distinct-groups case correctly propagate toNAinstead. New regression test. Full local test suite re-run clean: 4541 passed, 0 failed. -
Requested: a second gap-audit pass, covering the parts of the codebase the first pass hadn’t reached yet. Three more parallel audits (the remaining core statistics modules; the execution-engine/module-registry orchestration layer; the report/manuscript/publication figure-and-table generation code) found one serious live bug, one silent-data-loss bug, one real configurability gap, and one dead-but-real landmine in unreleased-capability code:
write_pca_publication_artifacts()(R/pca_publication.R) computed an inflated “percent of variance explained” for every real PCA-enabled run (the default), writing the wrong number directly into manuscript-ready caption/methods text.run_module_pca()(R/pca_registry_integration.R) passed only the retained-only, truncated eigenvalue subset (SNPRelate::snpgdsPCA(eigen.cnt = N)’s output), and the function’s own100 * eigenvalues / sum(eigenvalues)is only the true percent of total variance wheneigenvaluesis the complete spectrum –R/ordination.Ralready has its own verified comment about exactly this pitfall and correctly avoids it for13_PCA_variance.tsv(using SNPRelate’s own total-trace-normalizedvarprop), but the publication-output sibling was never given the same fix. With the defaultn_pcs = 10and a real ~20-sample cohort, the inflation is roughly 53% relative (e.g. a true 15% becomes ~23% in text a user could paste directly into a paper). Fixed:write_pca_publication_artifacts()gained an optionalvariance_percentparameter for the correct, pre-computed figure;run_module_pca()now passespca$variance$percent(the already-correct number) instead of re-deriving it from truncated eigenvalues. The function’s own internal “sums to 100” validation check was also wrong for a real truncated PCA (a correctly-computed retained subset legitimately sums to less than 100) and is now a bound (<= 100) instead. New tests reproduce the real inflation scenario and its fix directly.read_metadata()(R/io.R) silently collided two differently-punctuated metadata column headers that normalize to the same name (e.g."Sample.ID"and"Sample_ID"), discarding one column’s data with nothing but an easy-to-miss R warning.normalize_metadata_name()collapses punctuation to_, so both headers become"sample_id";data.table::setnames()doesn’t reject duplicate names, and the auto-detect rename path then silently renamed only the first match. Every other column-name ambiguity in this file is a loudstop(); this was the one silent exception, and a real risk for a real messy spreadsheet merged from two sources. Fixed to detect and error on any post-normalization collision, naming the colliding original headers.AMOVA and Mantel/IBD permutation counts were hardcoded
999Lwith no config knob, unlike the structurally identicalspatial_autocorrelation_permutations,sexbias_permutations, andclonality_ia_permutations(all genuinely configurable). Newanalyses.amova_permutations/analyses.mantel_permutations(default999, validated>= 1since – unlike those three siblings – AMOVA’s/Mantel’s significance tests aren’t optional once their analysis runs, and 0 permutations cannot produce a p-value).A dead-but-real landmine: no registered analysis module currently sets
parallel_safe = TRUE(verified: zero of them do), but if one ever does whilecontext$gds(a live GDS handle) is still populated, the scheduler’s multicore/multisession parallel-batch dispatch would share or attempt to serialize that handle across a process boundary – unsafe either way, andmerge_parallel_module()’s own context-identity check does not reliably catch it.run_execution_batch()(R/execution-scheduler.R) now refuses to run a parallel-safe batch at all whencontext$gdsis populated, erroring clearly with the fix (give the module its own independent GDS connection per task, the waydiversity.R/fst.Ralready do) rather than leaving this as a silent trap for whoever adds the first such module.
Two lower-priority observations from the execution-engine audit were noted but not acted on, since they’re deliberate-seeming architectural states rather than bugs: the retry/timeout/cancellation execution layer (
R/execution-retry.R/R/execution-timeout.R/R/execution-cancellation.R) is exported public API but never called fromrun_pipeline()(checkpoint/resume was chosen as this project’s actual mitigation for exactly the incidents that layer would otherwise guard against); andexecution-timeout.R’sbase::setTimeLimit()-based guard cannot reliably preempt a single long-running native/compiled call (a well-known limitation), which matters only if that layer is ever wired up. The report/manuscript audit additionally found that most of theR/publication_*_outputs.R/R/manuscript_*.R/R/report_engine.Rfiles it was asked to check are not actually reachable from the real pipeline, any documented workflow, orvignettes/publication-gallery.Rmdat all (confirmed by grepping every exported function name against the whole tree) – a self-consistent API with no live caller, so bugs there have no current user-facing effect; not audited further on that basis. Full local test suite re-run clean after all four real fixes: 4540 passed, 0 failed. -
Requested: a gap audit for bugs before the next release. Four parallel audits (this session’s own new/modified code; the core analysis modules; config/wiki/vignette consistency; package metadata and roxygen/.Rd sync) found the config/wiki/vignette and package-metadata areas clean, and turned up four real, independent correctness bugs:
write_manifest()(R/report.R) recorded hardcodedvariant_missing/ld_r2values of0.2inrun_manifest.tsv, regardless of the actual configured values. A real regression from making both parameters genuinely user-configurable earlier this cycle – this one call site was missed. Any run with a non-defaultqc.max_variant_missing/qc.ld_r2produced a provenance record that silently lied about what was actually used. Fixed to readcfg$qc$max_variant_missing/cfg$qc$ld_r2directly; new regression test.A silent Fst pair-misattribution bug in
run_fst(), and the same underlying gap at three moremclapply()call sites. Like the report-rendering crash fixed earlier this cycle, a forked worker killed by a signal (OOM, segfault) returnsNULL, not a classed"try-error"– butrun_fst()’s parallel path only checked for"try-error", sounlist(results)would silently shrink the Fst-value vector, and the followingMap()would then zip that shorter vector against the full, unshrunken population-pairs vector: Fst values silently attributed to the wrong pairs, not just a crash. The identical gap existed inexecute_dapc_k_tasks()(R/dapc.R),compute_diversity()(R/diversity.R), and bothrun_genome_scan_fst()/run_genome_scan_diversity()(R/genome_scan.R), each dropping a population’s or K-value’s results silently instead of erroring. Consolidated into one shared, directly unit-testedcheck_mclapply_results()helper (R/utils.R) used at all five sites (plusrender_report_formats()’s pre-existing check, refactored onto the same helper) so this class of gap cannot recur silently at a sixth. New tests reproduce the real failure end to end for the FST and DAPC sites (mocking the per-task worker function to returnNULL, exactly what a killed worker’s mclapply slot actually contains) plus a thorough direct suite for the shared helper itself.Phylogenetic bootstrap replicate counts were decoupled from how many replicates actually succeeded, in both NJ trees this package builds.
run_tree_bootstrap_replicates()(R/tree_bootstrap.R) filtered only!inherits(x, "try-error"), but every real caller’sbuild_onealready wraps its own body intryCatch(..., error = function(e) e), so a genuine numerical failure (e.g.ape::nj()erroring on a degenerate resampled distance matrix) comes back as a plain error condition, not"try-error"– never removed by that filter, and a killed worker’sNULLwasn’t either. Bothbootstrap_population_nj_tree()andbootstrap_nj_ibs_tree()then reportedreplicates = length(trees)using this still-inflated list, always equal to the requested count regardless of real failures – overstating confidence in the bootstrap support percentages with no indication anything was dropped. The sequential (workers <= 1) path had no filtering at all, an even more direct version of the same gap. Fixed to filter for actual"phylo"successes (matchingbootstrap_tree_support()‘s own internal filter), so the reported count and the support percentages’ real denominator now agree. New tests cover both the shared filtering function directly and a fullbootstrap_population_nj_tree()integration case with real, deterministic ape::nj() failures.Minor UX gap:
--resumecombined with any override flag other than--configsilently did nothing, with no feedback. Documented, correct behavior (the checkpointed configuration is always used), but a user who mistakenly believed--vcf/--maf/etc. took effect alongside--resumegot no indication otherwise.cli_main()now warns, naming which flag(s) were ignored;wiki/Deployment-and-Troubleshooting.mdandcli_usage()’s own text updated to mention it.
The two clean audits (config/wiki/vignette; package metadata/roxygen) found nothing further needing correction, reflecting the dedicated passes already made over those areas earlier this cycle. Full local test suite re-run after all four fixes: 4521 passed, 0 failed.
-
Closed GitHub issue #333 (“Consolidate the multi-file release-version-bump choreography”). Every one of the
v1.0.0-v1.0.6cuts hand-edited ~10 independent files that each declare “the current version” for a different consumer, and the public-api-baseline/release-candidate-policy re-pins were each missed at least once, only caught by a subsequent CI failure.Two real, independent bugs turned up building the fix. First,
tools/update-public-api-baseline.R(an already-existing tool meant to maintain the baseline) was completely broken: it wrote a plain, uncompressed.tsvto a path nothing tracks, and a.dcfmissing theSnapshot-Encoding/Snapshot-SHA256fieldspublic-api-contract.yml’s own verification requires – it could never have actually produced a working baseline. Rewritten to write the real trackedinst/api-contract/public-api-baseline.tsv.gz.b64/.dcfformat, shelling out to the realgzip/base64CLI tools (R’s ownmemCompress(type = "gzip")was confirmed to emit a zlib stream, not true gzip, on this platform –gzip --decompressrejects it), and strips a.9NNNdev-version suffix before writingVersion, matchingpublic-api-contract.yml’s own reconciliation logic. Second, a newscripts/bump_release_version.Rconsolidates the remaining ~9-file choreography (DESCRIPTION,inst/metadata/software-identity.json,CITATION.cff,codemeta.json,.zenodo.json,inst/metadata/release-candidate-policy.json,NEWS.md’s top heading,README.md’s version blockquote, and the public API baseline when the installed package already matches) – its rules for each field, across both a version “cut” and a development-cycle bump, were verified against three real historical precedent commits (ca51820,35d71cb,56312c6), and it self-checks its own output againstscripts/validate_release_metadata.R/scripts/validate_zenodo_metadata.Rbefore exiting successfully, which caught a real bug during development: resettingsoftware-identity.json’srelease_status/date_released/doito development/null/null without also removing the equivalent DOI/date fields fromCITATION.cff(date-released, theidentifiers:block),codemeta.json(datePublished,doi,sameAs), and.zenodo.json(publication_date,doi,conceptdoi) – fixed, and re-verified against fresh throwaway copies of the repo for both a cut and a dev-bump scenario. Deliberately does not touchdocs/ROADMAP.md/inst/doc/ROADMAP.md’s own narrative, which needs real judgment, not template substitution. -
Closed the remaining real gap in GitHub issue #332 (“Wire the existing checkpoint/resume engine into
run_pipeline()and the CLI”):--resumewas already wired up in an earlier cycle, but there was no way to detect – or even attempt – resuming with an edited configuration. The issue’s own analysis called this out directly: “editing an upstream parameter (e.g.qc$maf) would not invalidate the checkpoint today, silently reusing stale results.” Investigating found the actual current behavior was different, and in one sense safer, but still a real gap:run_pipeline_resume()never accepted a configuration at all –--resume OUTPUT_DIRis documented to read “no--configor other override flags,” and always replays the exact configuration frozen into the checkpoint. So today’s risk isn’t silent staleness; it’s that there was no supported way to combine “resume the modules that already finished” with “apply a config fix” at all, and no detection if a config file the checkpoint was built from was edited.run_pipeline_resume()gained an optionalconfigargument (CLI:--resume OUTPUT_DIR --config analysis.yml, still optional – omitting it keeps every prior release’s exact behavior). When supplied, its fingerprint (config_fingerprint(), a whole-config SHA-256, newconfig_fingerprintfield onPopgenVCFExecutionCheckpoint) is compared against the one recorded when the checkpoint was written; a mismatch refuses to resume, loudly, naming every top-level section (qc,analyses, …) that actually differs. Deliberately whole-config, not per-module:qc/input/compute.seedvalues are baked intocontext(the retained sample/marker set) before any registry module runs, and resume skips re-deriving them entirely by design, so a change anywhere could in principle invalidate any already-completed module’s result – this registry has no per-module config-key ownership metadata precise enough to safely reuse only the modules a specific edit couldn’t have affected, so over-invalidating (refuse on any difference) is the correctness-safe choice, not under-invalidating. A checkpoint written before this change (no recorded fingerprint) also refuses a config comparison, with a clear message, rather than silently skipping the check; plain--resumewith no--configis completely unaffected either way. New tests cover matching/differing/legacy-checkpoint cases directly.wiki/Deployment-and-Troubleshooting.mdgained the--resumedocumentation this feature never had at all until now. -
Suppressed the clonality module’s per-locus console spam when
poppr::genotype_curve()drops monomorphic loci before the genotype accumulation curve. Reported directly: on a real marker panel this can be a lot of loci, andpoppr::genotype_curve()names every single one via an unconditionalmessage()call that isn’t gated by its ownquietargument (confirmed against the installed poppr source) – polluting the pipeline’s console output.clonality_curve_summary()(R/clonality.R) now intercepts that specific message withwithCallingHandlers(), parses out the dropped locus names, and muffles it before it reaches the console.run_module_clonality()(R/module_registry.R) logs a single count instead and, when at least one locus was dropped, writes the full list – one locus name per line – to a new58c_monomorphic_loci_dropped.csv. No public API changes; both functions are internal. New unit tests intest-clonality.Rcover both the message-suppression and the no-loci-dropped case. -
Requested: a dedicated wiki page for the configuration YAML, with a complete example and a breakdown of what each entry unlocks. Auditing
inst/example_config.yml(the wiki’s own “canonical example”) againstdefault_config()for the requested completeness found it was a real, non-trivial gap: it silently omitted the entireml_tree,pcadapt,clonality, andsexbiasanalyses.*blocks (all real, current keys), andadmixture.q_sample_file/faststructure.q_sample_fileturned out to be dead config – accepted by the schema but never read anywhere (unlike the real, consumedsnmf.q_sample_file), since PLINK’s own.famfile already carries authoritative sample order for those two backends.inst/example_config.ymlnow documents everyanalyses.*keydefault_config()defines, verified by an exhaustive key-diff against it (not just spot-checked), including honest comments on the two deadq_sample_filekeys rather than pretending they do something.wiki/Configuration-Reference.md’s “Analyses” section, previously two short paragraphs, is now a single table mapping every key to the analysis it unlocks, its key output file(s), and its metadata gating (population / 2+ populations / lat-long / opt-in), cross-checked againstR/metadata_capabilities.R’s real gating lists rather than assumed. No code changes. -
Requested: a broad scan for latent bugs and stale documentation across the whole repository. Four parallel audits (wiki +
docs/user, vignettes, core pipeline code, package meta-consistency) turned up two more real dead-config keys beyond theadmixture/faststructureq_sample_fileones found earlier:qc.ld_slide_max_bp/ld_slide_max_n/ld_start_poswere silently reset byvalidate_config()’s “fixed QC contract” with no warning (unlikeld_r2, which shared that reset and did warn), andanalyses.structure.minimum_cluster_correlationwas validated but never compared against anything – its siblingreproducibility_rmsegates a real DAPC-instability warning, this one didn’t gate anything. Also found: a real bug invignettes/getting-started.Rmd(popgenVCF::list_analyses()shown called with no argument, which errors – the function requires one), a stalewiki/Home.mdlanding-page blockquote still describing the0.10.0release, an undocumented dual role for the metadataindividualcolumn (it doubles as asample-identity synonym when nosample/sample_id/idcolumn exists), and an unusedpatchworkdependency inDESCRIPTION.Decided, after discussion, to make the QC/LD-pruning parameters genuinely configurable rather than just documenting them as fixed – broadening the toolkit’s audience to users who need a different QC/LD-pruning definition than this package’s own historical default.
ld_prune_exact()(R/qc.R) now takesld_r2/slide_max_bp/slide_max_n/start_pos/max_missingarguments and threads them intoSNPRelate::snpgdsLDpruning(), instead of hardcoding all five;validate_config()no longer silently resets any of them, and validates instead (a bad value now fails loudly). The historical fixed values (max_variant_missing/ld_r2 = 0.2, unbounded window, 50-SNP slide,start.pos = "first") remain the defaults for continuity. Makingmax_variant_missingconfigurable surfaced a second, independent real bug found while wiring it through:run_pipeline()’s main variant-QC stage (R/pipeline.R) was callingvariant_qc()with a hardcoded0.2literal, notcfg$qc$max_variant_missing– so even before today, that config key never actually affected the retained marker panel PCA/kinship/DAPC/etc. all use, only the separate ROH module’s own missingness gate (R/module_registry.R) that happened to read it correctly. Both call sites now consistently readcfg$qc$max_variant_missing.minimum_cluster_correlationis now wired up indapc_reproducibility_annotation()(R/dapc.R) andvalidate_dapc_result()(R/validators.R), alongsidereproducibility_rmse, both gating DAPC’s own replicate-stability warning. Fixed the four documentation issues above;inst/example_config.ymlandwiki/Configuration-Reference.mddocument the newly-configurable QC/LD-pruning keys. No breaking changes: every default is unchanged, verified end to end against the real quickstart dataset (357 LD-pruned SNPs, identical to before). New unit tests cover the LD-pruning/missingness argument wiring directly (mockingSNPRelate::snpgdsLDpruning(), and a synthetic-missingness fixture provingmax_missingchanges which SNPs pass QC) and the correlation-threshold warning path. -
Reported directly against a real 50-sample, 7-population production cohort: the manuscript report crashed during PDF/LaTeX rendering (no completion or error logged – the process was killed abnormally, likely OOM, compiling a 303-figure report), and the clonality module still took 8.3 hours even after the earlier LD-pruning fix.
Investigating the report crash found a real, distinct code gap:
render_report_formats()’smclapply()-based concurrent HTML+PDF rendering only detected an R-leveltry-error; a forked worker killed by a signal (OOM, segfault) returnsNULLinstead, whichunlist()would silently drop, misreporting a hard crash as a clean (if quietly incomplete) success. Fixed to raise a clear error instead. Added a size-aware guard:render_report()now counts the report’s embedded figures (report_figure_inventory()) and, above a newreport.max_concurrent_figuresconfig key (default 100, matching every prior release’s unconditional-concurrent behavior below that size), renders HTML and PDF sequentially instead of concurrently, roughly halving peak memory at the cost of wall-clock time –render_report_formats()gained aparallelargument for this.Investigating the clonality runtime found the real cause:
poppr::poppr()’s Ia/rbarD computation does not drop monomorphic loci the waypoppr::genotype_curve()already does internally for the accumulation curve – a monomorphic locus (identical genotype at every retained sample) carries zero information for either the exact-identity comparison MLG detection needs or Ia/rbarD’s pairwise distances, so computing over it is pure waste, not a tradeoff.run_clonality()(R/clonality.R) now drops monomorphic loci once, up front, from both marker sets this module uses – the newclonality_monomorphic_loci()(vectorized, ~0.7s even at 560,000 loci) – before any of the expensivepopprcalls, rather than relying ongenotype_curve()’s own later, curve-only detection. New57b_monomorphic_loci_dropped.csv(full unpruned set, before57_MLG_groups.tsv) joins the existing58c_monomorphic_loci_dropped.csv(LD-pruned set); both are also now surfaced asINFO-level messages in the report, not just the pipeline log. Verified correctness-neutral for duplicate detection with a new test (dropping a monomorphic locus never changes which samples are flagged as sharing a genotype). No effect on the quickstart example: none of its loci are monomorphic, verified directly.Separately, asked whether the toolkit should add SNP/biallelic pre-filtering for raw VCF input (indels, multiallelic sites, etc.): verified directly (not assumed) that it already does, and has since VCF ingestion was written –
SNPRelate::snpgdsVCF2GDS(..., method = "biallelic.only")(R/io.R) automatically retains only biallelic SNPs, confirmed empirically against a synthetic VCF mixing a true biallelic SNP, an insertion, a deletion, a multiallelic SNP, an MNP, and a structural variant: only the two genuine biallelic SNPs survived. That same test also disproved this project’s own prior assumption (corrected here) that this conversion step additionally excludes monomorphic records, assnpgdsVCF2GDS()’s own documentation claims – a dedicated monomorphic-biallelic-SNP fixture showed the installed SNPRelate retains them; this package’s own MAF filter removes them instead, one stage later.wiki/Getting-Started.md,wiki/User-Guide.md, andvignettes/getting-started.Rmdnow say so explicitly, since the question itself was evidence the existing docs didn’t make this clear enough.Asked for a transparency table/count of this filtering, on top of the documentation. New
vcf_variant_type_summary()(R/vcf_input.R) runsbcftools statsonce (a single fast streaming pass, ~0.1s even at ~100,000 records) and derives exactly how many raw VCF records are retained biallelic SNPs versus dropped indels/multiallelic sites/MNPs/other types –records - (snps - multiallelic_snp_sites), verified to matchSNPRelate’s own real retained count exactly on both a mixed-type fixture and the real quickstart VCF.run_pipeline()now runs this as its own named stage right after VCF preparation, writes it to a new00_vcf_variant_types.tsv(first table in every run’s output, before any QC), logs a one-line summary, and records anINFO-level pipeline message. That last part exposed a real, separate, pre-existing gap:analysis$messages(everyWARNING/INFOmessage every module already records, including the new monomorphic-loci ones above) was never actually read by the report template – silently discarded, not just under-surfaced. The report’s setup chunk now carriesmessagesthrough, and a new “Pipeline notices” section (skeleton.Rmd) shows everyWARNING/INFOmessage in one place, omitted entirely when there are none.
popgenVCF 1.0.6
popgenVCF 1.0.6 is released. DOI 10.5281/zenodo.22135350 (concept DOI 10.5281/zenodo.21747067), 2026-08-28. This heading covers both this release and popgenVCF 1.0.5 (released 2026-08-26, DOI 10.5281/zenodo.22106600, same concept DOI) together, since 1.0.5 was cut without its own separate heading here; see each bullet’s own content for which release it shipped in. Deposited automatically by Zenodo’s GitHub integration when each tagged release was published, verified independently the same way as v1.0.0-v1.0.2 (raw record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects). Reconciled the real DOIs, concept DOI, and publication dates across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, README.md, and docs/reproducibility.md; release_status transitions from development to released. The same reconciliation also backfilled v1.0.3 and v1.0.4 below, whose real DOIs had similarly never been recorded locally.
-
Fixed a real regression in the just-shipped Manhattan chromosome-label fix: rotated contig-name labels could overlap the plotted panel itself (running back up through the axis and real data points) instead of clearing it. Found regenerating the reporting user’s own real PCA-loadings figure a second time as a verification step: the label’s vertical anchor position (
pad) was a fixed 14% of the panel’s own y-range – fine for one line of horizontal text, but for a panel with a genuinely narrow range (a PC with little variance across only its top-20 exported loadings, contribution values spanning barely 0.0035) that 14% is physically tiny, so a tall rotated label’s anchor sat barely below the lowest real data point rather than below the whole panel.manhattan_chromosome_row()gained an optionalpanel_height_inparameter (the actual physical height, in inches, of the one facet panel or single-panel figure the label row belongs to); when in vertical mode,padis now the larger of its original value and whatever this panel’s own real height/y-range conversion says is needed to clear the tallest label – reusing the exact same conservative usable-fraction assumption as the horizontal fit check, so it always errs toward more clearance, never less. All five call sites (ordination.R,dapc.R,genome_scan.Rx3,pcadapt_scan.R) now pass their own real per-panel height. Verified with a new regression test pinning the exact real ticks/y-range/panel-height that exposed the gap (confirmed to fail without the fix), and by regenerating and re-checking all ten of the reporting user’s affected figures for both horizontal label spacing and this new vertical clearance. No public API changes;panel_height_inis optional and additive. Fixed a second real collision found regenerating the reporting user’s figures again:
pcadapt’s “Genomic inflation factor” caption rendered on top of the rotated contig-name row instead of below it.ggplot2::labs(caption = ...)places its caption in a layout row close to the panel, with no awareness of how farmanhattan_chromosome_row()’s own data-space annotation extends into the margin below it – the only affected figure, since none of the other four Manhattan-family plots set a caption. Fixed by pushingplot.caption’s top margin down by the same real clearance the labels themselves needed, only when the plot actually has a caption. Verified visually against the regenerated real figure and with a new unit test covering both the with-caption and without-caption cases.-
Fixed a third real regression in the same Manhattan chromosome-label saga, reported directly by the user after regenerating their real figures against the just-shipped
panel_height_infix above: rotated contig-name labels were too large to read comfortably and, more seriously, the fix squeezed the plotted panel itself smaller to make room for them – the exact class of problempanel_height_inwas supposed to prevent, just reintroduced through a different mechanism.panel_height_in’s clearance math grewpad, a value in the plotted data’s own y-units, to guarantee a rotated label’s full physical length fit below the panel – butpadparticipates in ggplot2’s automatic per-panel y-scale training the same as any other layer’s data, so inflating it pulled that much of the panel’s own y-range down with it, visibly crushing the real plotted data.manhattan_chromosome_row()’spanel_height_inparameter and pad-inflation block are removed entirely.padis back to the same small, fixed 14%-of-range value used in horizontal mode, unconditionally; all real clearance instead comes fromplot.margin(points, a physical-device quantity entirely outside the data coordinate system) combined withcoord_cartesian(clip = "off"), plus a smaller fixed 7pt vertical-mode font (independent ofbase_size, down from abase_size-derived ~10pt) that needs less of it – matching the user’s own stated tradeoff: exact legibility of a rotated contig name is secondary, since real identities are always available in the accompanying TSVs. Verified against the exact real PC10 facet that first exposed the squeeze: the panel now renders at its own full, correct y-range with no crushing, labels legible at the smaller fixed size. New/updated unit tests intest-manhattan-chromosome-row.Rpin the fixed pad value and the fixed font size directly. -
Fixed a second, distinct real squeeze found regenerating the
pcadaptfigure specifically while verifying the fix above:plot.marginis a fixed point quantity that does not scale with the caller’s chosen plot height, and onpcadapt’s compact single-panel 10x4.5in save the vertical-mode margin computed to ~164pt – over half of the entire 324pt canvas – squeezing the panel down to a sliver and visually crushing all 560,000+ real -log10(p) points into an unreadable one-pixel-tall smear. The tall, multi-facet PCA/DAPC-loadings figures this margin factor was originally tuned against never exposed this: their much taller canvas made the same fixed-point margin a small fraction of the total, not a majority of it.manhattan_chromosome_row()now reports how much extra canvas height its vertical-mode margin actually needs via amanhattan_extra_height_inattribute on the plot it returns;save_plot()reads it and grows the requested height by that amount before saving, so the panel keeps the caller’s originally-intended size and only the canvas grows to fit the labels – directly matching the user’s own preference (extend the canvas rather than squeeze the plot). No call site changes needed; all five affected figure types (PCA/DAPC loadings, genome-scan FST/diversity/Tajima’s D,pcadapt) pick this up automatically through the shared helper. Verified against the realpcadaptfigure (canvas grew from 4.5in to 6.4in; the panel now shows its full real data range, labels and caption both fully legible) and the other four figure types (no unwanted canvas growth where the original fixed height was already sufficient). New unit tests cover the attribute directly andsave_plot()’s use of it. No public API changes; both functions are internal. -
Fixed a separate, real y-range mismatch found bisecting the real 10-facet PCA-loadings figure, which still truncated a label after both fixes above:
geom_hline(yintercept = 0)silently expands a panel’s own rendered y-scale to include 0, even when the real underlying data never comes close to it – ggplot2 trains each panel’s scale on every layer’s data, not just the primary geom’s. For a low-variance PC whose loadings cluster tightly far from zero (the same real PC10 scenario above), this produced a much larger true rendered range thanrange(contribution)alone would suggest, silently invalidating the externally pre-computedy_rangethatplot_pca_loading_manhattan()/plot_genome_scan()pass intomanhattan_chromosome_row()– so the label, anchored just below what looked like the panel’s bottom, actually landed inside the now-taller real panel instead of below it, clipped by the panel’s own boundary rather than bleeding into the margin as intended.Fixed at the three affected call sites –
ordination.R’s PCA-loadings panel andgenome_scan.R’s FST and Tajima’s D panels all draw ageom_hline(yintercept = 0); the diversity panel and DAPC-loadings have no such line and needed no change – by computingy_rangeasrange(c(data, 0), na.rm = TRUE)instead ofrange(data, na.rm = TRUE), matching what the hline will actually do to the rendered scale. Verified against the real regenerated PCA-loadings, genome-scan FST, and genome-scan Tajima’s D figures (all now fully legible, no truncation) and a new unit test intest-pca-loadings.Rpinning the exact mechanism: a synthetic low-variance PC facet with a narrow range far from zero, asserting the label’s computed y-position matches therange(c(data, 0))-derived pad, not the narrower raw-data range. Added a DAPC PC-count cross-validation diagnostic figure,
12b_DAPC_xval_K{k}, one per K value tried.run_dapc_k_task()already callsadegenet::xvalDapc()to pick the number of retained PCs by cross-validated assignment success wheneveranalyses.dapc_cross_validationis enabled (the default) – this was already the standard adegenet cross-validation approach, already on by default, not a new statistical method. The full per-n.pca success/error curve behind that choice was computed every run but discarded (xval.plot = FALSE, only the final selected count kept). The newplot_dapc_xval()draws that curve straight from the already-retainedcvobject with no new computation: mean assignment success against number of PCs retained, a dashed reference line for the median random-chance rate, and the selected PC count marked directly on the curve – the standard DAPC cross-validation diagnostic (e.g. https://grunwaldlab.github.io/Population_Genetics_in_R/DAPC.html#cross-validation). Verified end to end against the real quickstart dataset (K = 2:4) and with new unit tests covering the plotting logic directly and its wiring intoplot_dapc(). No public API changes;plot_dapc_xval()is internal.-
Requested: a way to visually validate DAPC’s automatically-selected PC count (reported concern:
adegenet::xvalDapc()can pick a count that collapses all samples onto one point/group), a parallel cross-validation backend, and a DA-eigenvalues diagnostic. Investigated the “switch to poppr’sxvalDapc()” request directly against the installed packages first: there is no separate poppr implementation to switch to –xvalDapcand every one of its S3 dispatch methods (.genlight,.matrix,.data.frame,.default) live in adegenet’s own namespace even with poppr loaded; poppr just re-exports the generic for internal use. The parallel execution the user had in mind traces to the widely-used “Population Genetics in R” tutorial’sxvalDapc(..., parallel = "multicore", ncpus = 4L)example – real, but it’s adegenet’s own...being forwarded three calls deep intoboot::boot()’s native parallel bootstrap (xvalDapc()-> its internal.get.prop.pred()->boot::boot()), not a poppr feature. Traced the requested “PCA axes retained vs. proportion of successful outcome prediction” figure the same way: those are adegenet’s ownxvalDapc(..., xval.plot = TRUE)default plot’s exact axis labels, currently suppressed (xval.plot = FALSE) in favor of the mean-curve-onlyplot_dapc_xval()above.Tried wiring up that native parallel path (
parallel = "multicore", ncpus = ..., budgeted against the outer per-K fork so nested forking couldn’t oversubscribe) and caught a real problem verifying it against this package’s own real quickstart dataset before shipping it: thisxvalDapc()call resamples withsim = "parametric", which perboot::boot()’s own documentation resamples inside the worker processes, each choosing its own separate, non-reproducible seed – confirmed directly, not just theoretically: identical data/seed gaven.pca = 10(replicate RMSE ~0, fully reproducible) serial vs.n.pca = 40(RMSE 0.076, exceeding the stability threshold) withparallel = "multicore"enabled, a materially different, less reproducible result, not just a faster one. Every other parallel path in this codebase is verified byte-identical regardless of thread count; matching that guarantee here would require reimplementingxvalDapc()’s own resampling loop, well beyond this request’s scope, so the call stays serial – a deliberate reproducibility-over-speed tradeoff, not an oversight.Shipped the two real, verified improvements instead.
plot_dapc_xval()now overlays every individual bootstrap replicate’s outcome (fromcv’s already-retained, previously-unusedCross-Validation Resultsraw table) as jittered points, plus the full 2.5/50/97.5% random-chance reference band instead of just the median – a mean-only curve can look like a clean, confident peak even when the underlying replicates are highly variable, exactly the case a user needs to see to judge whether an auto-selected PC count is a robust signal or an artifact of too few replicates/too small a training set; axis labels now match adegenet’s own wording verbatim. Newplot_dapc_eigenvalues()(12c_DAPC_eigenvalues_K{k}) draws the standardbarplot(dapc$eig, ...)DA-eigenvalues diagnostic as a ggplot2 bar chart – a second, independent way to sanity-check the retained axis count (this one validatesn.dacoming out ofdapc(),plot_dapc_xval()validatesn.pcagoing in). Verified end to end on both a real synthetic 3-population dataset and this package’s real quickstart dataset, and with new unit tests: the raw-scatter/CI-band rendering,plot_dapc_eigenvalues()directly plus its wiring intoplot_dapc(), and thatrun_dapc_k_task()’sxvalDapc()call never requestsboot::boot()’s parallel bootstrap. No public API changes; both new/changed functions are internal.Found one more real gap regenerating this release’s own example report from scratch:
plot_dapc_xval()’s raw-replicate scatter usedggplot2::geom_jitter()’s default, unseededposition_jitter(), so the exact same retainedcvobject rendered a visibly different point cloud on every report regeneration, purely from jitter randomness, not any real change in the underlying cross-validation data – confirmed by comparing two real renders of this figure byte-for-byte. Fixed with a fixed jitter seed; new regression test renders the same data twice and asserts the built plot’s jittered x-positions are identical. -
Requested: minimum spanning networks (MSN), following the poppr tutorial’s own MSN page. The tutorial’s own
bruvo.msn()assumes a stepwise mutation model appropriate for microsatellite repeat-length data, not this package’s SNP genotypes, soclonality’s already-existing LD-prunedgencloneobject (the same onepoppr()’s Ia/rbarD summary and the genotype accumulation curve already use, for the same reason: approximately independent loci) instead feeds the generalpoppr::poppr.msn(), withpoppr::diss.dist(percent = TRUE)as the distance – a discrete/Hamming-style metric poppr documents as usable “for any marker system” and, per its own docs, numerically identical toprovesti.dist()but built to “perform better for large numbers of individuals (n > 125)” (this package’s own real production datasets reach into the thousands of samples).include.ties = TRUE(matching the tutorial’s own choice) keeps every edge tied for shortest distance rather than arbitrarily dropping ties, since a discrete SNP-based distance routinely produces exact ties.New
58b_MSN_networkfigure (base-graphics, like the NJ trees, viapoppr::plot_poppr_msn()– no ggplot2-native equivalent exists) and58b_MSN_edges.tsv(every edge’s endpoints and genetic distance), computed and plotted wheneverclonality’s LD-pruned set is usable, degrading gracefully (a newmsn_failedflag/WARNING, independent of the existingpoppr_failedpath) rather than failing the whole module otherwise. One real layout bug found rendering the actual figure:plot_poppr_msn()builds its own multi-panelgraphics::layout()(legend | network | distance scale bar) and leaves that layout’s last panel active on return, so a title/caption added the normal way landed inside that small panel, on top of poppr’s own “DISTANCE” scale-bar label rather than below the whole figure – fixed by adding them via outer-marginmtext(..., outer = TRUE)instead, unaffected by which inner panel is active. New required dependencyigraph(a real, already-present transitive dependency viapoppritself, not a new capability) added toDESCRIPTIONandenvironment.yml, confirmed by the existingtest-container-dependency-completeness.R.Verified against this package’s real quickstart dataset: all 160 samples remain distinct nodes even on the 357-SNP LD-pruned set (matching the accumulation curve’s own near-immediate saturation), and the resulting network reproduces real, specific, independently-verifiable structure – CHB (East Asian) forms a largely self-contained subcluster (18 of 21 incident edges connect two CHB samples) with only single bridging edges out to six other populations, and LWK/YRI (African) similarly mostly connect to each other (28 of 36 edges) – computed from
58b_MSN_edges.tsvdirectly rather than assumed from the figure by eye, which also caught that the overall same-population/cross-population edge-distance split is only modest (mean 0.154 vs. 0.162), consistent with this dataset’s own ~9% FST/AMOVA-among-population signal rather than a sharper one; an earlier draft of this same paragraph overstated a cleaner separation before that check was actually run. Documented in bothvignettes/interpreting-results.Rmdand the wiki’s mirroredResults-and-Interpretation.md, regenerated from the real quickstart run. New unit tests cover MSN construction (node/edge counts, clone-correction), the edge-table extraction, graceful degradation (too-small LD-pruned set, a single-sample genclone), and the figure-writing path. No public API changes; all new functions are internal. -
Fixed a real, reproducible race condition breaking the optional manuscript report: requesting both HTML and PDF (the default) failed intermittently with
xdvipdfmx: Image inclusion failed: Could not find fileduring PDF compilation, on an otherwise perfectly valid report. Found regenerating this release’s own example report. Root cause:render_report_formats()renders both formats concurrently (parallel::mclapply()) from the same source template basename into the same shared destination directory;rmarkdown::render()creates a companion<basename>_files/directory for supporting images at that identical shared path for both forked renders, and HTML’sself_contained = TRUEfinalization step embeds every image and then deletes that directory once done – which can race ahead of the PDF format’sxelatex/xdvipdfmxcompiler still reading images from the same path. Confirmed directly, not just theorized: PDF alone and HTML alone each rendered cleanly on every repeated attempt against a real full pipeline report; only the concurrent combination failed, nondeterministically, depending on fork scheduling.The existing
intermediates_dirisolation (added for a different, already-solved race) only covered knitr’s own internal chunk-evaluation temp files, not this rmarkdown-level supporting-files directory. Fixed by also isolating each format’s actual render output directory into its own scratch temp directory, copying only the final document out to the real destination afterward – no shared state between the two forked renders at all. New regression test pins the fix’s actual invariant (rmarkdown::render()is never called with the shared destination, and the two formats never share a render output directory with each other) via a mocked renderer, rather than trying to reproduce the race’s own timing, which would just make the test flaky in the other direction. Verified against a real full pipeline report: 4/4 concurrent HTML+PDF renders succeeded after the fix (0/1 initial reproduction succeeded before it), each producing a valid, real 18-page PDF. No public API changes; the fix is entirely internal torender_standard_report_format().
popgenVCF 1.0.4
popgenVCF 1.0.4 is released. DOI 10.5281/zenodo.22105573 (concept DOI 10.5281/zenodo.21747067), 2026-08-26. Deposited automatically by Zenodo’s GitHub integration when the v1.0.4 release was published, verified independently the same way as v1.0.0-v1.0.2 (raw record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects), but not reconciled into this repository’s own metadata until popgenVCF 1.0.6.
-
Fixed a real production report: every Manhattan-style figure (PCA/DAPC SNP-loading plots, genome-scan FST/diversity/Tajima’s D scans, the pcadapt outlier scan) rendered completely unreadable on a non-model reference genome assembled into dozens of short contigs with long accession-style names. Reported with real figures from a 160-sample nematode cohort assembled against an 88-contig WGS reference (contig names like
JAEVLN010000001.1): the shared chromosome-name row all five figure types draw beneath their Mb-tick x-axis (manhattan_chromosome_row(),R/utils.R) centers each contig’s name horizontally on its own narrow segment – fine for a handful of large model-genome chromosomes, but with dozens of short contigs sharing a fixed page width, adjacent long names inevitably collided into an illegible smear regardless of font size, confirmed directly against the reporting user’s own figures and the exact real contig names/lengths from their VCF header.Fixed geometrically, not with a chromosome-count threshold:
manhattan_layout()now also reports each chromosome’s own laid-out physical span, andmanhattan_chromosome_row()compares each contig’s real label width (measured viagraphics::strwidth()against the actual font, not a guessed characters-per-inch constant) against the physical page width its own span actually occupies. When any label would not fit horizontally, every label in that row switches to vertical (angle = 90) text instead, whose footprint is one line’s height rather than the full label string length; the now-redundant “Chromosome position”/“Chromosome” axis title (which would otherwise collide with the taller rotated column) is dropped, since the contig names are self-explanatory as an axis identity on their own. If even rotated text cannot fit every contig (real fragmented assemblies can have hundreds of tiny scaffolds), labels are thinned by minimum physical spacing, always keeping at least one – the same “simplify once the per-item budget collapses” idiommanhattan_bp_breaks()already used for the Mb ticks above this row, rather than an unreadable jumble. A model genome with a handful of large chromosomes is unaffected: labels stay exactly as before (horizontal, every chromosome shown, title intact).A first pass compared each label’s width against
ticks$width / total_width * plot_width_indirectly, treating the figure’s full saved width as if it all maps to plotted data. Regenerating the reporting user’s own already-completed PCA/DAPC-loading figures from their saved tables (31_PCA_loadings.tsv,22f_DAPC_loadings_K*.tsv) as a real-world check caught that this was wrong: two real contigs (JAEVLN010000016.1/017.1) measured as fitting apart by a ~20% margin under that estimate but still rendered fully overlapping, since the y-axis number/title column and ggplot2’s default 5% scale expansion on each side both eat into the panel that actually renders the data, leaving real available space meaningfully narrower than the naive estimate. Fixed with a deliberately conservative safety factor (only 70% of the saved width is trusted as usable) rather than modeling ggplot2’s exact internal geometry, so the check always errs toward more rotation/thinning, never less; verified with a new regression test pinning the exact realmanhattan_layout()ticks that exposed the false positive, and by regenerating and re-measuring all ten of the reporting user’s affected figures (the PCA-loadings plot and DAPC-loadings K2-K10) to confirm every kept label pair now clears the required physical spacing with margin. Verified against a full synthetic sweep too (a normal 8-chromosome case confirming no regression, the reporting user’s real 88 contigs by both a realistic synthetic distribution and their exact real names/lengths, and an extreme 500-tiny-scaffold stress case) plus unit tests (test-manhattan-chromosome-row.R) covering the fit/rotation/thinning logic directly, in addition to the existingtest-pca-loadings.R/test-dapc-loadings.Rcoverage confirmed to still pass unchanged. No public API changes;manhattan_chromosome_row()’s newplot_width_inparameter is optional and additive, defaulting to the prior always-horizontal behavior when omitted. -
Fixed a real robustness gap found investigating the above: a broken log-file destination (a deleted output directory, a full disk, or – as found here – a stale path left behind by an earlier, unrelated run in the same R session) could abort real analysis work, since logging is diagnostic, not load-bearing. Found while adding new tests that, for the first time, called the analysis-execution scheduler directly in a test session where an earlier test (
test-pcadapt.R’s no-metadata pipeline run) had pointed the sharedlog_msg()destination at awithr::local_tempdir()that was later cleaned up, without resetting it back toNULLafterward – every subsequentlog_msg()call in that session then crashed with"cannot open the connection"instead of just… not writing to a file that no longer exists. This was already visible as a long-standing, unexplained flaky failure intest-phase10-runtime-binding.R, confirmed to be the exact same root cause once traced.log_msg()(R/utils.R) now wraps its optional file write intryCatch(), so an unwritable log destination is silently skipped rather than fatal – console logging (the primary, always-on path) is unaffected either way. Also fixed the actual state leak at its source:test-pcadapt.R’s real-pipeline test now resets.pg_env$log_fileon exit, matching the convention already followed bytest-pipeline.R,test-quickstart-dataset.R, andtest-autosome-filtering.R. Verified: the full suite’s previously-flakytest-phase10-runtime-binding.Rfailures and the new--resumeintegration tests (which hit the identical hazard once added) both now pass reliably; a direct repro (pointinglog_msg()at a deleted directory) confirms it degrades gracefully instead of throwing. -
Wired up
--resume, letting a killed run pick back up instead of restarting from scratch (ROADMAP.md issue #332). Motivated by the same real production incident as theclonalityfix below: a real 44-hour run on a 561,767-locus cohort had to bedocker stop-ped and restarted from zero once theclonalityfix was ready, because nothing survived to resume from – the checkpoint/resume engine itself (R/execution-checkpoint.R,R/execution-checkpoint-fixes.R) had existed since Phase 8.3 but was never actually wired intorun_pipeline()or the CLI. Two designs were considered: checkpoint only at cooperative-cancellation points (cheap, but does nothing against a realdocker stop/OOM kill/SIGKILL, since those never let cooperative-cancellation code run at all), versus writing a checkpoint after every completed analysis-registry batch unconditionally (survives any kill signal, no OS signal-handling plumbing required, at the cost of one small addition to the core scheduler loop). Chose the latter.execute_analysis_plan()(R/execution-scheduler.R) andexecute_analysis_registry()(R/registry.R) both gained an optional, backward-compatiblecheckpoint_pathparameter; when set, a checkpoint is written after every batch completes, regardless of what runs next.run_pipeline()passes<output_directory>/execution_checkpoint.rdsin automatically. A livegdsfmt/SNPRelateGDS connection is an external pointer that cannot survive serialization at all – the same fork-safety fact behind this file’s earlierdiversity/fst/genome_scanparallelization entries turns out to also apply tosaveRDS()/readRDS(), not just forking – so the newexecution_checkpoint_safe_context()stripscontext$gdsbefore every checkpoint is built (including the pre-existing cooperative-cancellation checkpoint path inR/execution-cancellation.R, which had the identical latent bug); a resumed run re-opens its own fresh, read-only connection from the plaincontext$gds_pathstring instead. Newrun_pipeline_resume(output_directory, registry)reads the checkpoint, reopens the GDS handle, and calls the existingresume_analysis_execution()to run only the modules the checkpoint doesn’t already have – rebuilding the checkpoint object from scratch vianew_execution_checkpoint()rather than mutating the validated one in place, sincecheckpoint_digestcovers the entire payload and a post-hoc field mutation (reattaching the live GDS handle) would otherwise silently invalidate it. Shares the same finalization path (finalize_pipeline_analysis()) as a normalrun_pipeline()run, so a resumed run’s output directory is indistinguishable from one that ran straight through.New CLI flag:
Rscript popgenVCF.R --resume OUTPUT_DIR, reading everything it needs from the checkpoint itself – no--configor other override flags. Because heavy-resource-class modules (population_tree,fst,genome_scan,clonality,dapc) already run one at a time regardless of thread count, per-batch checkpoint granularity costs at most one already-in-progress module’s work on a real multi-hour run, not more. Verified with a new integration test suite (test-pipeline-resume.R: a real interrupted-mid-registry run correctly resumes without re-running completed modules, an already-fully-completed checkpoint re-runs nothing, a missing checkpoint errors clearly) plus newtest-cli.Rcoverage for the flag itself, and a real, full CLI-level rehearsal of the actual incident: ran the quickstart dataset throughdiversity/fst/clonality,kill -9-ed the real R process at the exact momentclonalityfinished but before that batch’s checkpoint had hardened (the genuine worst case), then confirmed--resumecorrectly picked up from “2/3 modules complete,” re-ran onlyclonality, and produced a normal, completeanalysis_results.rds– with no duplicated or corrupted output. No public API changes beyond the new exportedrun_pipeline_resume()and the two additivecheckpoint_pathparameters. Fixed the “Sequential SNP retention” bar plot (and its underlying tables) sorting alphabetically instead of showing the real filtering order. Reported by the same real user.
qc_reports()builtsequential$step("Input biallelic","After MAF","After missingness","After LD pruning") andindependent$criterionas plain character columns; a discrete ggplot2 axis (and any other order-sensitive consumer) defaults to alphabetical sorting for a character column, which for these labels is a genuinely different, misleading order (e.g."After LD pruning"sorts before"Input biallelic"). Fixed at the root by making both columns ordered factors, with levels fixed to the real filtering sequence, at construction insideqc_reports()itself – so every consumer (the plot, the TSV files, the report’skable()table, any future dashboard) inherits correct order automatically, rather than patching just the one plot call site.plot_qc_reports()also keeps a defensive re-assertion of the same ordering, since it can be called directly with a hand-built table that bypassesqc_reports(). Verified with new unit tests (test-qc-reports.R: ordering and hand-verified counts;test-plot-palettes.R: the rendered plot’s factor levels match filtering order, not alphabetical) and confirmed against the same real production dataset’s actual07_QC_sequential_counts.tsv.Fixed a real production incident:
clonalitytook 29+ hours (still running when investigated) on a real 50-sample, 561,767-locus unpruned cohort, entirely insidepoppr::poppr()’s Ia/rbarD computation. Reported by the same real user who runs similar datasets frequently and needed this under an hour. Root-caused via a real, currently-running production container (confirmed the forked child was genuinely still computing, not deadlocked) and a direct scaling measurement (500 to 20,000 synthetic loci, everything else held fixed):poppr()’s runtime grows superlinearly in locus count, with the local growth exponent climbing from ~1.0 to ~1.6 across that range – not a flat per-locus cost. Extrapolating that measured curve to 561,767 loci predicts ~28.5 hours, matching the real run almost exactly;genotype_curve()’s resampling has the same problem (timed out past 5 minutes at just 20,000 loci in the same investigation).encode/as.genclone/mlg.id()stayed negligible at every scale tested. Fixed by routingpoppr()’s Ia/rbarD summary and the genotype accumulation curve to the LD-pruned marker set instead of the full unpruned one (the same set already used for kinship/PCA/DAPC), while exact multilocus-genotype duplicate detection (mlg.id()/57_MLG_groups.tsv) stays on the full unpruned set, since thinning that specifically would raise the real risk of false-positive “identical genotype” calls between genuinely different individuals. This is not only a performance fix: Ia/rbarD’s own null-model interpretation assumes approximately independent input loci, so the module’s previous full-unpruned-set choice (“for consistency with AMOVA”) was already flagged in the wiki’s own quickstart walkthrough as inflating apparent non-random association through ordinary linkage, not real clonal signal – confirmed concretely on the quickstart example, where Total Ia dropped from 35.40 to 2.28 and rbarD from 0.0185 to 0.0066 once computed on the LD-pruned set. Degrades gracefully (matching the module’s existing poppr-crash handling) on the rare dataset where LD pruning leaves fewer than two usable loci. Verified with new unit tests provingmlg.id()uses the full set (a genotype pair that only matches on the LD-pruned subset is correctly not flagged as a duplicate) andpoppr()/the curve use the LD-pruned set (the reported total MLG count reflects the coarser panel, not the full one), plus a real end-to-end quickstart pipeline run confirming the wiring and updating the wiki/vignette walkthrough’s numbers and figure to match. SeeR/clonality.R’s top-of-file comment for the full measurement and reasoning.
popgenVCF 1.0.3
popgenVCF 1.0.3 is released. DOI 10.5281/zenodo.22097105 (concept DOI 10.5281/zenodo.21747067), 2026-08-25. Deposited automatically by Zenodo’s GitHub integration when the v1.0.3 release was published, verified independently the same way as v1.0.0-v1.0.2 (raw record API JSON matches .zenodo.json; both the version and concept DOIs resolve via real doi.org redirects), but not reconciled into this repository’s own metadata until popgenVCF 1.0.6.
Parallelized
fstandgenome_scan’s remaining serial loops, using three different task shapes rather than one copy-pasted pattern. The last of the modules surveyed alongside thediversityparallelization andpopulation_treevectorization above, on the same real production cohort. (1)run_fst()’s pairwise loop: each population pair’ssnpgdsFst()call scans the full QC-passing SNP set (500K+ on a real cohort, not the LD-pruned subset), a genuinely heavy, modest-count task – forked per pair exactly likediversity’s per-population design, each worker opening its own independent GDS connection rather than sharing the parent’s (gdsfmt’s documented fork-unsafety, same citation as thediversityentry below). (2)run_genome_scan_fst()’s per-window loop: a genome-wide scan has thousands of windows, each individually cheap (onesnpgdsFst()call over a handful of SNPs) – forking one process per window the wayfst/diversityfork per task would pay fork overhead thousands of times over for little work each, so windows are instead split into exactlyworkerscontiguous chunks viaparallel::splitIndices()(the same balanced-partitioning helpermclapply()uses internally), and each worker opens one GDS connection and processes its whole chunk. (3)run_genome_scan_diversity()’s per-population loop: pure R/data.tablework computed from the already-materialized locus table, no GDS handle involved at all, so it forks directly per population with no per-worker-connection dance – the same profile asdiversity’s own parallelization, just without the extra safety machinery that turns out not to be needed here.bootstrap_fst()was deliberately left sequential, out of scope: it is optional, gated behindcfg$analyses$bootstrap$enabled. Verified with new unit tests (test-fst-parallel.R,test-genome-scan-parallel.R: parallel output matches serial to1e-12, sequential fallback confirmed when nogds_pathis supplied, and a forced per-worker/per-chunk failure surfaces a clear error rather than a corrupted result) and a real end-to-end pipeline run on the 160-sample, 8-population, ~99K-SNP quickstart dataset confirming identicalfst/genome_scanoutput betweenthreads = 1andthreads = 3.Vectorized
population_tree’s per-SNP allele-count matrix assembly, replacing an R-levelforloop with two whole-matrix column assignments. Found alongside thediversityparallelization above while surveying which modules could benefit from performance work on the same real production cohort:population_allele_count_matrix()builds the interleaved reference/alternate allele-count matrix thatadegenet::genpop()/dist.genpop()consume for Nei’s distance, from the fulldiversity_full$locustable (the same 500K+ QC-passing-locus scale as thediversitymodule itself, not the LD-pruned subset) – but did so with aforloop assigning two columns per SNP one iteration at a time, an R-level cost that grows with locus count on every real run sincepopulation_treeis enabled by default. Replaced with two vectorized assignments (tab[, ref_cols] <- as.matrix(ref_wide[, snp_ids, with = FALSE]), same foralt_cols) that do the identical interleave via whole-matrix column indexing; both wide tables are still indexed by the samesnp_idsname vector rather than assumed column order, so this stays correct even ifdcast()ever ordered the two tables differently. Verified: byte-identical output against the original loop at 50,000 loci (1.16s vectorized vs. 18.7s looped, ~16x), and the existingtest-population-tree.R(26 assertions, including the Nei’s-D hand-calculation and monotonic-divergence checks) andtest-tree-bootstrap.Rsuites both pass unchanged.Parallelized the
diversitymodule’s per-population locus statistics (lapply->mclapply) after a real production run took 57 minutes on a 561,767-locus unpruned cohort. Investigated after the same real user asked whether the long-runningclonalitymodule (separately confirmed genuinely slow rather than hung, via liveps/statinspection of their production log) could be sped up with parallelism, and whether other modules could benefit similarly;diversitywas the clearest win, since its per-population HWE/allele-frequency/private-allele computation is already an embarrassingly-parallellapplyover populations. Forking while sharing the parent’s already-open GDS handle is genuinely unsafe –gdsfmt’s own documentation (tools::Rd_db("gdsfmt")[["openfn.gds.Rd"]]) states forked children inherit the parent’s shared file descriptor/offset, causing “wrong reading, even program crashes” under concurrent access – so each forked worker now opens its own independent, read-only GDS connection (SNPRelate::snpgdsOpen(gds_path, allow.duplicate = TRUE, allow.fork = TRUE)) rather than reusing the shared handle;compute_diversity()falls back to the original sequential path whenever nogds_pathis available (e.g. the small CI fixture inscientific_validation.R), so behavior there is unchanged. Generalized DAPC’s existingdapc_worker_count()fork-sizing helper into a sharedfork_worker_count()inR/utils.R(bounds worker count by task count, thread budget, and platform fork support) rather than duplicating the same bounding logic a second time. Failed workers surface as a clearstop()naming the failed population(s) instead of a silently corrupted result. Verified with new unit tests (test-fork-worker-count.R,test-diversity-parallel.R: parallel output identical to serial to1e-12, sequential fallback confirmed when nogds_pathis supplied, and a forced per-worker failure surfaces the expected error) and a real end-to-end pipeline run confirming byte-identicaldiversityoutput betweenthreads = 1andthreads = 3.population_assignment’s separate, already-fast (~10s)compute_diversity()call is deliberately left sequential-only, out of scope for this change.
popgenVCF 1.0.2
popgenVCF 1.0.2 is released. DOI 10.5281/zenodo.22072524 (concept DOI 10.5281/zenodo.21747067), 2026-08-23. Same-day patch release for the admixture/fastStructure/sNMF fix and the sample_column/population_column config feature below; deposited automatically by Zenodo’s GitHub integration when the v1.0.2 release was published, verified independently the same way as v1.0.0/v1.0.1. Reconciled the real DOI, concept DOI, and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, README.md, and docs/reproducibility.md; release_status transitions from development to released.
Added
input.sample_column/population_columnand enabled metadata-gated analyses by default in the generated--write-configtemplate. Both prompted by the same real user’s feedback. The new config fields let a differently named metadata column (e.g.pathotype) be treated assample/populationwithout renaming it in the file; an explicit value always wins over the built-in synonym auto-detection, even over an existing literalsample/populationcolumn (erroring on that specific ambiguity rather than silently discarding one). Writing this surfaced a real, previously-undiscovered bug: the existing header normalizer appliedgsub()’s deliberately lowercase-only character class beforetolower(), so any uppercase letter silently failed to match and was replaced with its own"_"("Population"normalized to"_opulation", not"population"), breaking the built-in synonym detection for any ordinarily capitalized header – fixed by consolidating into one correctly-orderednormalize_metadata_name()helper. Separately,template_config()no longer force-disables population/geography-gated analyses: every one of those 16 fields already skips automatically (with aWARNING) whenever its required metadata is genuinely absent, so the override was pure friction for users who do have complete metadata.ml_treeand the ancestry backends stay off by default, unrelated to metadata. SeeNEWS.md’s next entry anddocs/ROADMAP.mdfor full detail.Fixed a real production incident:
admixture/fastStructure/sNMFcrashed with no population metadata, discarding an already-completed real ancestry result. Reported by the same real user immediately after thepcadaptfix below:admixturecrashed with"ADMIXTURE metadata requires sample and population columns"after ADMIXTURE itself had already computed successfully (CV errors and K-selection both written). Same root cause aspcadapt: these are unsupervised ancestry backends that need no population metadata to run at all – the capability-gating system already correctly classifies them as available from VCF sample IDs alone – but a purely cosmetic output-labeling step (attaching apopulationcolumn to the Q-matrix table for readability) wrongly hard-required the column to exist. Found the identical copy-pasted bug in all three backends;plot_q_matrix()already handled a missingpopulationcolumn gracefully, confirming the requirement was simply wrong. Consolidated into one sharedattach_q_population()helper that omits the column instead ofstop()ing when metadata doesn’t provide one.
popgenVCF 1.0.1
popgenVCF 1.0.1 is released. DOI 10.5281/zenodo.22071466 (concept DOI 10.5281/zenodo.21747067), 2026-08-23. Same-day patch release for the container-image regression below; deposited automatically by Zenodo’s GitHub integration when the v1.0.1 release was published, verified independently the same way as v1.0.0. Reconciled the real DOI, concept DOI, and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, README.md, and docs/reproducibility.md; release_status transitions from development to released.
-
Fixed a real production incident: the published
v1.0.0container image was missing the optionalpcadaptpackage, and thepcadaptmodule’s ownstop()on a missing package took an entire real run down with it. Reported by a user running the real GHCR image against their own 50-sample, no-metadata cohort: ~45 minutes of completed GDS/PCA/IBS/kinship/ROH/ml_treework was lost toError: pcadapt failed: The pcadapt outlier scan requires the optional 'pcadapt' packageat the very last module. Root cause:pcadaptis declared inDESCRIPTION’sSuggestsand the module defaults on for every user (it needs no population metadata), butinst/conda/environment.yml– the file that actually builds the Docker/Apptainer images – never listed it, so every real image genuinely lacked it. Fixed at both layers: addedr-pcadapttoenvironment.yml, and changedrun_pcadapt_scan()’s missing-package check from a hardstop()to the same gracefulfailed = TRUEdegradation already used for its other failure mode (numerical instability), matching this codebase’s established convention for other optional-dependency default-on modules (hierfstat/sexbias,clue). A newreasonfield ("package_missing"vs."numerical_instability") letsrun_module_pcadapt()report an accurateWARNINGfor each case instead of one generic message.Auditing further (a user asked “make sure nothing else is missing”), found and fixed three more required
Imports(jsonlite,openssl,processx) silently absent fromenvironment.yml‘s explicit dependency list – the container build never actually failed on these because conda’s solver was pulling them in transitively as other listed packages’ own dependencies, but that’s fragile (an unlisted transitive dependency could silently disappear if some other package ever drops it). Every otherSuggestspackage with arequireNamespace()guard was checked individually and already degrades gracefully or is a legitimate dev/build-only tool correctly absent from the shipped image (DT/plotly: dashboard-only, already guarded;pkgload/withr: dev-testing tools, not used at runtime). Newtest-container-dependency-completeness.Rcross-checks every requiredImportspackage againstenvironment.yml(excluding base R and the Bioconductor packagesinstall-bioconductor.Rhandles separately) so this class of gap cannot silently recur – verified to actually catch a regression by temporarily removing a real entry and confirming the test fails. Added
oldrelto theR-CMD-checkmatrix, closing a real coverage gap. The matrix had only ever testedrelease/develR in this repository’s history, despite issue #4’s Definition of Done (and the general R package convention) calling forrelease/oldrel/devel. Found auditing the repository immediately after closing #4.DESCRIPTIONrequires R (>= 4.3), well under any currentoldrel, so no compatibility work was expected – verified via a real CI run rather than assumed: all three matrix jobs (release,oldrel,devel) passed.
popgenVCF 1.0.0
popgenVCF 1.0.0 is released. DOI 10.5281/zenodo.22069452 (concept DOI 10.5281/zenodo.21747067), 2026-08-23. Deposited automatically by Zenodo’s GitHub integration when the v1.0.0 release was published; verified independently via the raw record API JSON and a real doi.org resolution (302 redirect to Zenodo) for both the version DOI and the concept DOI before reconciling. Reconciled the real DOI, concept DOI, and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, README.md, and docs/reproducibility.md; release_status transitions from development to released.
-
Fixed a real budget-design gap that hard-failed the
Scientific release archiveCI workflow (stop(), no artifacts uploaded) after theenvironment_compatiblefix below stopped masking it: the flat 10% runtime/memory ratio budget is appropriate for the multi-secondcanonical/largebenchmark tiers but not the sub-secondsynthetictier, where ~10ms of ordinary CI-runner scheduling jitter is the entire allowed margin. Found live: rerunning the continuous benchmark to bind fresh evidence to a commit produced a real, hard workflow failure – the synthetic tier’s comparison, previously masked asinsufficient-evidenceby the now-fixed environment check, genuinely tripped the 1.1x runtime cutoff (a prior successful comparison had already measured 1.0965x, a hair under the line). Reproduced the underlying noise locally: five consecutive synthetic-tier runs on the same unchanged commit measured 0.456-0.641s wall-clock, a >40% spread from pure OS/process scheduling variance at this timescale, nothing to do with popgenVCF’s own code.New
release_performance_budget_for_tier()(R/continuous_release_benchmarking.R) returns a widened budget (runtime <=1.5x, memory <=1.25x, throughput >=0.65x) specifically for thesynthetictier; every other tier, includingcanonical, keeps the strict 10% default, since their absolute time scale makes that margin meaningful.scripts/build_release_benchmark_archive.Rnow selects the budget per tier inside its comparison loop instead of sharing one budget object across every tier. New tests confirm the widened budget both passes the exact real noisy-rerun numbers that hard-failed CI and still fails on the real magnitude of the allelic-richness regression above (a ~12.6x runtime blowup), so this is a noise-tolerance widening, not a regression-detection weakening. New public export:release_performance_budget_for_tier(). -
Fixed a real false-positive in
benchmark_history’senvironment_compatiblecheck: a routine GitHub-hosted runner kernel package update was enough to permanently strand every future comparison atinsufficient-evidence, even after two real performance regressions above were fixed and independently re-verified as fully resolved. Rerunning the continuous benchmark with both fixes in place showed every actual budget check pass (runtime 1.10x, memory 1.02x, throughput 0.91x, scaling efficiency 1.0 – essentially back to thev0.10.0baseline), but the comparison still reportedinsufficient-evidencebecauseenvironment_compatiblecompared the full captured environment fingerprint for exact equality, including the OS kernelreleasestring – and thev0.10.0baseline was captured on6.17.0-1020-azurewhile the rerun landed on6.17.0-1022-azure, a routine ABI build-number bump from ordinary runner-image patching, not a real environment change. Left uncorrected, this check would have kept expiring on essentially every rerun as GitHub’s runner images get patched over time, permanently masking whether a real regression exists.normalize_kernel_release()(R/continuous_release_benchmarking.R) strips only the middle ABI build-number segment from Ubuntu-style<version>-<build>-<flavor>kernel release strings (6.17.0-1020-azureand6.17.0-1022-azureboth normalize to6.17.0-azure) before theenvironment_compatibleequality check; every other environment field (OS, machine, R version, platform, core counts, BLAS) and every other segment of the kernel string (major.minor.patch version, flavor) are still compared exactly. A release string not matching that specific pattern (a different OS’s format, something unexpected) is left untouched and still compared exactly – a fail-safe, not a blanket relaxation. New tests confirm the routine-bump case now passes while a genuinely different kernel major version, a different kernel flavor, a different R version, and an unrecognized release-string format are all still correctly caught as incompatible. No public API changes (the exportedperformance_environment_fingerprint()and stored evidence are unaffected; normalization is applied only at comparison time). -
Fixed a second, separate real performance regression found rerunning
benchmark_historyevidence after the allelic-richness fix below:library(popgenVCF)itself eagerly loadedadegenet’s entire namespace, and adegenet’s own heavy transitive dependencies, on every session – not only whenpopulation_treeactually runs. Rerunning the continuous benchmark after the allelic-richness fix showed runtime and throughput fully recovered, but memory stayed at the same ~4.2x regression (~180MB vs thev0.10.0baseline’s 42.8MB). Traced with a second, targetedgit bisect(measuringlibrary(popgenVCF)’s owngc()-reported memory in a fresh session, no benchmark computation involved) to the commit that added thepopulation_treemodule: fixing a real correctness bug at the time (methods::new("genpop", ...)failing to resolve the class under a real installed-package load) required adding@importClassesFrom adegenet genpopto the package’s static NAMESPACE declarations – which R resolves atlibrary()time, forcingadegenet(and its own transitive dependency tree:ade4,shiny,httpuv,mgcv,igraph, and more) to load eagerly for every popgenVCF session regardless of whetherpopulation_treeis ever used. Confirmed directly, not just inferred:library(popgenVCF)’s own measured footprint was 23.8MB with the import declaration removed versus 165MB with it present, on the identical commit.Fixed properly, not by reverting the original correctness fix:
population_genpop_distance()now callsadegenet::genpop(tab = tab, ploidy = 2L)– adegenet’s own exported constructor, itself justnew("genpop", ...)called from inside adegenet’s own namespace, where the class is always resolvable – instead ofmethods::new("genpop", ...)directly. Verified this gives identical, correct output under the exact same real-installed-package-load scenario the original bug was about, withadegenetnow loading lazily on first actual use instead.library(popgenVCF)’s memory footprint is back to 23.8MB, matchingv0.10.0. New regression test spawns a fresh subprocess (this test session almost certainly already hasadegenetloaded from an earlier test) and asserts"adegenet" %in% loadedNamespaces()isFALSEimmediately afterlibrary(popgenVCF). No public API changes (the removedimportClassesFromdeclaration was never part of any exported function’s documented contract). -
Fixed a real performance regression found bisecting a
benchmark_historyrelease-gate finding toward v1.0:compute_diversity()unconditionally paid the cost ofhierfstat::allelic.richness()even for callers that never use the result. Thepipeline-core-analyses/pca_ibs_diversity_fstcontinuous benchmark’s synthetic-tier comparison against thev0.10.0baseline showed a 12.6x runtime and 4.2x memory regression, tight enough across replicates (runtime MAD 0.005s) to rule out CI noise. Root-caused bygit bisectacross the 97 commits sincev0.10.0, using a fixed, harness-independent benchmark driver (since the benchmark harness script itself changed partway through that range): the first bad commit was the one that added rarefaction-corrected allelic richness (hierfstat::allelic.richness()) directly intocompute_diversity()’s hot path, unconditionally wheneverhierfstatis installed. Isolated and confirmed the mechanism directly (not just inferred from the diff): that one call alone takes ~3 seconds on the tiny 60-sample/2000-SNP synthetic benchmark fixture – essentially the entire regression.compute_diversity()gained acompute_allelic_richnessparameter (defaultTRUE, preserving current behavior for the only caller that writes the column to output tables/figures,run_module_diversity) and a matchinganalyses.diversity_allelic_richnessconfig key (defaultTRUE) so users can also turn this real, measured cost off entirely. The three call sites that never use the allelic richness column now explicitly skip it:run_module_population_assignment(pure wasted work, no behavior change to its actual output),run_scientific_validation’s internal reference check (same), and the continuous benchmark harness (inst/scripts/continuous_benchmark_tiers.R), restoring its ability to track the same core PCA/IBS/diversity/FST cost it always has rather than being swamped by an unrelated optional feature’s cost. Verified locally:compute_diversity(compute_allelic_richness = FALSE)takes 0.099s versus 1.633s with the default on the same fixture, matching the pre-regression baseline. No change to any real pipeline run’s default output. New test covers the explicit opt-out path (in addition to the existing hierfstat-unavailable skip path); config default covered by a new test. Fixed a real false-positive integrity failure in the scientific-review packet builder, found assembling the actual v1.0 release-candidate evidence for the first time end-to-end.
release-candidate-collector.ymlflattens every CI-provable gate’s artifacts into<gate_id>--<original-relative-path>filenames, since GitHub Release assets cannot preserve directory structure (scripts/build_release_candidate_evidence_index.R). When one of those flattened artifacts is itself a*SHA256SUMS.txtmanifest –source_distribution’srelease-SHA256SUMS.txtandapptainer_distribution’sapptainer-metadata-SHA256SUMS.txtboth qualify – its contents still list files by their pre-flatten relative path, and that original directory routinely spans more than one gate:release-SHA256SUMS.txt’s 17 entries are actually split acrosssource_distribution,source_package_check, andscientific_validation, since all three come from onetagged-source-release.ymlrun.build_scientific_review_packet.R’s generic manifest scan treated every listed filename as an expected sibling in the flattened directory, which those filenames can never be, and reported 18 falseINTEGRITY FAILEDrows against a real, fully correct evidence bundle. The scan now recognizes when a manifest is itself one of the collector’s own flattened artifacts (cross-referenced againstrelease-candidate-evidence-index.json) and defers to the separate, already-correctindexed_artifact:check for that file’s own integrity instead of a doomed-by-design per-entry sibling check. New test constructs the exact real scenario (a flattened manifest whose entries were flattened under a different gate). No public API changes.Fixed a real crash found by a real production run:
clonalitycould segfault and take down the entire pipeline run, mid-way through the very last module, on a large real dataset. Found generating the sameexternal_concordancerelease-candidate evidence toward 1.0 as therohfix below, immediately after it:poppr::poppr()’s Ia/rbarD computation (pair_diffs()-> a compiled Cpairdiffsroutine) has a genuine 32-bit integer overflow in poppr’s own code, triggered when the flattened pairs-by-locus index space for poppr’s automatic pooled “Total” group exceeds2^31 - 1. On the real chr22 dataset (2504 samples, 26 populations, 2028 QC-passing loci) that product is2504*2503/2 pairs * 2028 loci =~ 6.35 billion– root-caused and confirmed by population-bisection: splitting the 26 populations into two independent ~1250-sample halves (each comfortably under the overflow threshold) ran cleanly with no crash, while the full set reproduced the segfault deterministically every time. A real segfault cannot be caught by R’s owntryCatch()– it aborts the whole process, discarding every already-completed result from a run that, on this dataset, took over 20 minutes to reach the final module.poppr::poppr()is now run in a forked child process (parallel::mcparallel()/mccollect(), Unix-only, matching this codebase’s existing use of fork-based parallelism elsewhere) specifically so a crash there costs only this module’s Ia/rbarD summary table, not the entire pipeline run; verified directly against the real crashing dataset (not just a synthetic stand-in) that the forked child segfaults exactly as before while the parent process survives intact. On a detected crash, the summary table degrades to a well-typed, zero-row table (same columns downstream code and the written TSV expect) with a WARNING logged explaining the real cause; MLG duplicate-group detection and the genotype accumulation curve, neither of which depends on this specific call, are unaffected either way. No public API changes.Fixed a real crash found by a real production run:
rohcould crash with a cryptic “subscript out of bounds” on a malformed line frombcftools roh’s captured output. Found while generating freshexternal_concordancerelease-candidate evidence against the real, approved chr22 1000 Genomes dataset (2504 samples) toward 1.0 –roh_parse_regions()androh_analyzed_footprint_bp()both blindly assumed every captured line frombcftools’s stdout had the exact expected tab-field count via a barevapply(fields, \[[`, …, N), with no defense against a single truncated or malformed line. Not reliably reproducible in isolation (manual re-runs of the identicalbcftools roh/bcftools queryinvocations, including at the real, machine-auto-detected–threads 64, never produced a malformed line) -- the leading hypothesis is a torn or interleaved stdout write under heavy concurrent system load (this ran alongside a separate multi-hour, many-threaded production job), a known risk when capturing a highly parallel external tool's output viasystem2(). Rather than chase full reproduction, fixed the real underlying gap regardless of exact cause: a shelled-out tool's captured output should never be assumed perfectly well-formed. Malformed lines are now dropped with a WARNING (matching this codebase's established graceful-degradation convention used everywhere else a too-small or malformed input is encountered) instead of crashing the whole module. Verified with new tests covering a truncatedRG` line, an all-malformed input, and a malformed site line in the footprint calculation. No public API changes.Root cause found and fixed: the
apptainer.ymlbuild failure was GCC 15 (2025) breaking the old, bundledxz-5.2.9build insidegdsfmt. Pinninggcc/g++/gfortranto the 13.x conda-forge series resolved it – confirmed by a clean, fully passingapptainer.ymlrun (SIF build, smoke tests, and the real scientific/population-structure validation all passed). Six hypotheses were eliminated with direct evidence along the way (disk exhaustion, a-std=gnu23mismatch,make -jparallelism,install.packages()’sNcpusparallelism, an OOM kill, and unusualulimits), each ruled out via a live debug session on the actual failing runner, not a local proxy for it. Also fixed a real, separate finding surfaced by this investigation’s own tooling: the temporarymxschmitt/action-tmate@v3debug step used a mutable tag rather than a pinned commit SHA, violating this project’s owntest-workflow-action-pins.Rsupply-chain-security convention (every other action reference in this repo is pinned to an exact commit); now pinned to35b54afac29c97fb54faba5b513f8fbd1882f113 # v3. No public API changes.Pinned
gcc/g++/gfortranto the 13.x conda-forge series ininst/conda/environment.yml(previously the unpinnedcompilersmeta-package, which now resolves to GCC 15.3.0) as the next hypothesis for theapptainer.ymlbuild failure below, after a live tmate debug session on the actual failing runner (not a Docker proxy for it, this time) directly ruled out an OOM kill and unusualulimit/resource constraints, joining the previously-ruled-out disk exhaustion,-std=gnu23mismatch,make -j, andinstall.packages()Ncpusparallelism – six hypotheses eliminated with direct evidence, all pointing at nothing left except the compiler/linker toolchain itself. GCC 15 (2025) is far newer than the specific vendoredxz-5.2.9source insidegdsfmtthat’s failing; an old, bundled build breaking under a very new toolchain while an actively-maintained package (LEA) builds fine on the identical runner is the standard signature this mitigation addresses. Removed the temporaryscripts/debug-gdsfmt-liblzma.shdiagnostic script added for the live-debug session, its purpose served. No public API changes.Raised the
apptainer.ymldebug session’s own step timeout from 25 to 80 minutes: the first live-debug attempt below was killed by that timeout itself mid-connection, not a network issue, cutting the session before the build reached the point under investigation. No public API changes.Added an opt-in
debuginput toapptainer.yml(mxschmitt/action-tmate, only active on an explicitworkflow_dispatchwithdebug: true) afteroptions(Ncpus = 1)failed to fix theapptainer.ymlbuild failure below too – a fifth identical, deterministic failure at the identical spot. Five-for-five with zero variance means every local repro this investigation has run (plaindocker exec, never real Apptainer’s actual build sandbox – overlay filesystems,sudo, a different/tmp) was testing a different execution environment than the one that’s actually failing; ruling out hypotheses there doesn’t rule them out for the real build. The debug step opens an interactive SSH session on the real failing runner immediately before the build step, to inspect the actual, uncaptured linker error directly instead of continuing to guess at more untested variables. No public API changes.Further refined the
apptainer.ymlbuild-failure investigation:MAKEFLAGS=-j1(forcing serial compilation withingdsfmt’s own build) did not fix it either – the identicalliblzmalink failure recurred a fourth time, at the identical spot, with disk still at 83GB free. That rules outmake -jrace conditions too, leaving one remaining, untested difference between the failing CI build and the earlier successful local repro:install-bioconductor.Rinstallsgdsfmt/SNPRelate/BiocVersiontogether in oneBiocManager::install()call, which can run multiple package installs concurrently (separate R subprocesses) whengetOption("Ncpus") > 1– a different axis of parallelism thanmake -j, and one the local repro (a single, standalonegdsfmtinstall) never exercised.install-bioconductor.Rnow setsoptions(Ncpus = 1)explicitly; R’s own default is already1L, but conda-forge’sr-baserecipe is known to raise it via a site profile on some builds, so this is a safe no-op if that wasn’t the actual cause. No public API changes.Refined the
apptainer.ymlbuild-failure investigation below: the disk-exhaustion hypothesis is ruled out by real evidence – thedf -hdiagnostics added moments earlier showed 83-86GB free throughout the build (GitHub-hosted runners have 145GB total, not the ~14GB the original hypothesis assumed), and the failure recurred identically on the very next run with disk never remotely close to full. A toolchain/C-standard mismatch is also ruled out: an identical local rebuild used the byte-identical-std=gnu23compiler command line and succeeded. The remaining, unruled-out difference between the failing CI run and the succeeding local rebuild is build parallelism – the local repro ran without an explicit-j, while the runner’s defaultmakeconcurrency can race againstxz-5.2.9’s own bundled (old, vendored) Makefile if it is missing an explicit dependency edge, a well-documented failure class matching the observed signature exactly (every individual compile reports success; only the final link fails, silently).Apptainer.defnow setsMAKEFLAGS=-j1around the Bioconductor install step as the standard mitigation, forcing serial compilation for just that step. Thedf -hdiagnostics stay in place either way, so a recurrence is still real evidence rather than another silentError 1. No public API changes.Added
df -hdisk-space diagnostics toApptainer.def’s%postbuild steps, and anapt-get cleanafter installingtexlive-xetex/lmodern, after a real, reproducibleapptainer.ymlbuild failure surfaced while collecting release-candidate evidence toward 1.0: linkinggdsfmt’s vendoredliblzmafailed withError 1and no captured diagnostic text, identically on two independent runs. Investigated rather than just retried blindly: rebuilt the identical toolchain (conda-forge GCC 15.3.0, binutils 2.46.1 – confirmed the exact versions the failing runner resolved) in a local Docker container and installed the identicalgdsfmt1.46.0 from source cleanly, ruling out a toolchain or dependency-version regression. The leading hypothesis is runner disk exhaustion:texlive-xetex/lmodern, a ~4.6 GB conda environment, and Apptainer’s own SquashFS image materialization all compete for one GitHub-hosted runner’s comparatively tight disk budget within a single job, andmake/libtool builds commonly report a bare, silentError 1onENOSPCrather than a readable message – consistent with the failure recurring at the same point both times. Could not be fully confirmed locally (no real Apptainer install available without interactivesudoon this machine), so this change is diagnostic-first: if it recurs, the next failure will show real free-space numbers instead of another silentError 1, turning speculation into evidence. No public API changes.Added a
sexbiasmodule: a sex-biased dispersal test (Goudet, Perrin, and Waser 2002) viahierfstat::sexbias.test(), another real, previously-unused capability ofhierfstat(already an optional Suggests dependency for allelic richness and population-specific FST). Each individual’s assignment index (AIc, Favre et al. 1997) – how strongly their genotype matches their own recorded population’s allele frequencies, corrected for sample size – is compared between the two recorded sexes with a two-sample t-test (mAIc, the default) or, with a configured number of permutations, a within-population permutation test (mAIc,vAIc,FIS, orFST;FIS/FSTrequire permutations, since hierfstat provides no closed-form test for that comparison – enforced by a new config validation rule). This is a genuinely different question from the existingsex_checkmodule:sex_checkasks whether one sample’s own genotype is consistent with its recorded sex;sexbiasasks whether the two recorded sexes differ, as groups, in how strongly their genotypes match their recorded population – so it deliberately uses the metadatasexcolumn, notsex_check’s genetically inferred sex. Reusescompute_diversity()’s already-computed genotype matrix (requires = "diversity", matchingclonality’s reuse of the same matrix – both are population-gated, so unlikepcadaptthis dependency cannot forcediversityto run when population metadata is unavailable). Skips gracefully (NULL, WARNING-logged, matchingibd/spatial_autocorrelation’s established pattern), not a hard error, when the optionalhierfstatpackage is not installed or the metadata’ssexcolumn is absent or does not resolve to at least two samples per recorded sex. New outputs:60_sexbias_AIc_by_sample.tsv(per-sample assignment index),60b_sexbias_test_summary.tsv(test statistic and p-value), and figure60_sexbias_AIc_by_sex. On the real quickstart dataset (160 samples, 8 populations), mean AIc does not differ detectably by recorded sex (mAIc t-test statistic = -0.648, p = 0.5177, n = 83 female, 77 male) – an honest, expected null result, since the quickstart dataset pools eight geographically distinct 1000 Genomes populations sampled cross-sectionally for genomic diversity, not a single species/site dispersal study with individuals genotyped at their natal versus breeding location, the setting this test was designed for. New config keysanalyses.sexbias(defaultTRUE,FALSEintemplate_config()since it needs population metadata),analyses.sexbias_test(default"mAIc"), andanalyses.sexbias_permutations(default0L, meaning the parametric t-test/F-test rather than a permutation test).Fixed two more user-reported subtitle-truncation bugs, the same underlying issue as the earlier
plot_bottleneck()fix (ggplot2 does not wrap subtitle text, so an over-length one is silently cut off at the plot’s right edge):clonality’s58_genotype_accumulation_curvefigure, androh’s24b_ROH_FROH_by_length_classfigure. Both split into a short subtitle plus a caption, matchingplot_bottleneck()’s/plot_ibd()’s existing pattern – and for the genotype accumulation curve specifically, the first clause alone (with the citation-style detail already moved out) still overflowed on its own, needing a second round of shortening before it fully fit; verified this time by actually rendering and inspecting the image, not just re-reading the code. Regenerated both figures and the committed example PDF from the immediately-prior reference run’s cached results. No public API changes.-
Redesigned the shared Manhattan-style x-axis (
R/utils.R), used by all six per-locus genome-position figures in this package (the threegenome_scanfigures,pcadapt, and the PCA/DAPC SNP-loading figures), based on direct user feedback on the Mb-tick style shipped moments earlier: angled Mb-position tick text is replaced with horizontal (unangled) ticks, and chromosome identity moves to its own row, centered on each chromosome’s span, directly below the tick row – restoring the at-a-glance “which chromosome is this” readability the original (pre-Mb-tick) chromosome-name-only axis had, while keeping real within-chromosome position resolution.A real overlap risk, found by testing it, not assumed away. The user explicitly flagged that unangled ticks might overlap with many chromosomes present; testing confirmed it, and root-caused it precisely before fixing:
manhattan_bp_breaks()‘s previous tick positions came frompretty()filtered to each chromosome’s exact (non-round) data range, which can unpredictably collapse to as few as one surviving tick or jump to several times the requested count depending on how the real, non-round span happens to align with round numbers – a cliff invisible at 30 degrees of rotation but immediately visible at 0. Replaced with an explicit, predictable formula: ticks evenly inset within the open interval of each chromosome’s own span (never touching either boundary, so adjacent chromosomes’ edge ticks cannot collide when many are packed tightly), and no Mb ticks at all for a chromosome once the shared tick budget rounds down to one – a lone tick would only duplicate the chromosome-name row already centered at the same position, and real genome-wide (many-chromosome) scans are exactly the case where that budget collapses, so the result matches this package’s original, pre-Mb-tick behavior for that case rather than a new compromise. Verified visually against real quickstart data reshaped into synthetic 1/3/8/23-chromosome layouts (23 being a realistic whole-genome autosome+X count) before shipping, not just unit-tested.A real, independently-shipped bug found and fixed along the way, not just avoided. The PCA/DAPC SNP-loading figures facet by component (
facet_wrap(~axis, scales = "free_y")), so the new chromosome-name row can only be drawn once, in the bottom facet, not repeated in every panel – built via a small labelled data frame matched to that one facet by itsaxiscolumn. The first implementation set that column to a plain character value; visually inspecting the real rendered figure (not just checkingnrow()/levels(), which is all the existing tests happened to assert) caught thatfacet_wrap()had silently reset the entire plot’s panel order to alphabetical once that mismatched-type layer was added –PC10rendered second, immediately afterPC1, instead of last afterPC9, for every K value in the DAPC figures and the PCA loadings figure. Fixed by constructing that layer’s facet column as a real factor sharing the exact same levels as the main data, not a bare string. Strengthened bothtest-pca-loadings.Randtest-dapc-loadings.Rwithggplot2::ggplot_build()-based checks of the actual rendered panel order (not just the source data’s factor levels, which were never wrong and would not have caught this), plus a dedicated unit test pinning the underlying cause directly.Regenerated all six affected figures and the committed example PDF from the immediately-prior reference run’s cached results (a rendering-only change; underlying values are unaffected) rather than re-running the whole pipeline. No public API changes.
Fixed a user-reported gap: the three sliding-window
genome_scanManhattan figures (25_genome_scan_FST_manhattan,26_genome_scan_diversity_manhattan,26b_genome_scan_tajima_d_manhattan) still used one coarse, chromosome-name-only tick per chromosome, unlike the PCA/DAPC loading Manhattan plots and the newpcadaptfigure, which all already show compact, Mb-formattedmanhattan_bp_breaks()ticks. A real, accidental scope gap from when that helper first shipped (2026-08-09, “Scoped to only these two plots” at the time) that was never revisited as more Manhattan-style figures were added later (genome_scanpredates the helper;pcadaptused it correctly from the start). All three now callmanhattan_bp_breaks()like every other Manhattan-style figure in this package; x-axis label changed from “Chromosome (window start)” to “Chromosome position” to match the PCA/DAPC loadings andpcadaptfigures’ wording exactly. Regenerated the three affected figures and the committed example PDF from the immediately-prior reference run’s cached results (a rendering-only change; the underlying FST/diversity/Tajima’s D values are unaffected) rather than re-running the whole pipeline. No public API changes.-
Added a genome-wide, PCA-based statistical outlier scan for local adaptation/selection (Luu et al. 2016; Privé et al. 2020), a new opt-in-by-default
pcadaptmodule, via thepcadaptpackage (a newSuggestsdependency). Closes out the last of the two candidates carried over from the first standard-toolkit gap audit (the other, TreeMix-style admixture graphs, remains open – higher integration cost, an external binary). Genuinely distinct from the existinggenome_scanmodule’s windowed FST-outlier table: that table (confirmed by reading its own code) is just the top-20 windows by raw FST magnitude, with no null distribution – its own figure subtitle already says so. pcadapt instead fits PCA on the retained genotype matrix, regresses each locus’s genotypes on the K retained PC scores, and tests a robust, genomic-control-corrected Mahalanobis distance of each locus’s z-score vector against a chi-squared distribution with K degrees of freedom – a real per-locus p-value. FDR control usesstats::p.adjust(method = "BH"), the base-R equivalent of Storey’s q-value every pcadapt tutorial uses via Bioconductor’sqvaluepackage, deliberately chosen to avoid a Bioconductor dependency chain this package does not otherwise need.Unlike almost every other module in this pipeline, pcadapt does not require population metadata at all – it is unsupervised.
Kdefaults to (number of populations - 1) when population metadata is available (a standard heuristic), or pcadapt’s own default of 2 otherwise; both configurable (analyses.pcadapt_k). Reuses the same full QC-passing, not LD-pruned, marker setgenome_scan/AMOVA/clonality already use – extracted independently inrun_module_pcadapt()rather than declaringrequires = "diversity", a deliberate choice explained below.A real dependency-graph bug found and fixed before shipping, not just avoided. The first implementation reused
compute_diversity()’s already-extracted genotype matrix for convenience, matching AMOVA/clonality’s own precedent. A real end-to-end no-metadata pipeline run (an existing test,run_pipeline completes without a metadata file...) caught the actual problem:diversityis population-gated (R/metadata_capabilities.R), but pcadapt is not, so without population metadata the registry’s dependency resolution force-includeddiversityanyway to satisfy pcadapt’s declaredrequires, anddiversitythen crashed on a missingpopulationcolumn it was never supposed to need in this scenario. Fixed by extracting the genotype matrix directly viaSNPRelate::snpgdsGetGeno()(matchingml_tree/spatial_autocorrelation’s own pattern) and declaring norequiresat all – the architecturally correct fix, not a workaround, since pcadapt genuinely never needed diversity’s population-summarized output.A second real numerical-stability failure mode, found and handled gracefully, not hidden. With too little data relative to K, pcadapt’s internal per-locus regression can hit an exactly singular design matrix (confirmed directly against this package’s own tiny 8-sample/7-locus CI validation fixture: “system is computationally singular”). Unlike
ml_tree(an explicitly opt-in module where a hard failure is the right, loud outcome for a different third-party numerical-stability issue), pcadapt defaults on for every user, so this codebase’s existing graceful-skip convention for other too-small-data cases (sex_check,ne_ld, Mantel/IBD) applies here too: the scan is skipped with a clear warning rather than crashing the whole pipeline over one exploratory module.New
59_pcadapt_outliers.tsv(full per-locus results: MAF, Mahalanobis statistic, chi-squared statistic, p-value, q-value, outlier flag) and, when non-empty,59b_pcadapt_significant_outliers.tsv; new59_pcadapt_manhattanfigure. Newanalyses.pcadapt(defaultTRUE),analyses.pcadapt_k(defaultNULL, auto),analyses.pcadapt_min_maf(default 0.05, matching pcadapt’s own default), andanalyses.pcadapt_fdr_alpha(default 0.05) config keys.LD.clumping(pcadapt’s own optional local-LD thinning refinement) is not enabled by default – an honest, documented v1 scope decision.Real, honest quickstart finding. K=7 (8 populations - 1); 708 of 1,969 tested loci (36%) are significant at q < 0.05 – read plainly, not as 708 independent selection targets. The genomic inflation factor is 1.60: with 8 continental-scale populations this divergent relative to a comparatively modest marker panel, real elevated differentiation from ordinary genome-wide drift alone can affect a large fraction of loci, a known general limitation of PCA-based outlier scans under strong population structure, not specific to this dataset. The significant loci cluster into a handful of genomic windows rather than spreading uniformly, consistent with local LD in this deliberately unpruned marker set producing one effective signal across several correlated SNPs. The strongest cluster (20.86-20.95 Mb) overlaps the single highest-FST window in the entire
genome_scanoutput (20.80-20.85 Mb, FST=0.172) – independent corroboration from two different statistical approaches, though still not proof of a specific selection target given the inflation already noted. Verified with newtest-pcadapt.R(deliberately-injected true-positive outlier recovery with zero false positives on synthetic structured data, theresolve_pcadapt_k()heuristic and its bounds, positional alignment of pcadapt’s own NA-filled output with the input locus order, the graceful-degradation path reproduced against the real tiny CI fixture, module-registration and capability-gating coverage, and validator coverage including the real q-value >= p-value invariant), a full local test suite run, and a real end-to-end reference run. Refreshed the public API baseline (641 entries). No public API changes beyond the newpcadapt_module_specexport. Added Nei’s (1987) Dxy, absolute nucleotide divergence between populations, to the
fstmodule. Found by a second standard-toolkit gap audit (SambaR’scalcdistance()and PopGenome both list Dxy alongside FST as a core differentiation statistic). Genuinely distinct from every FST-family measure already computed here (global/pairwise FST, Wright’s Nm, Jost’s D, Weir and Goudet’s population-specific beta): all four are normalized by within-population diversity, while Dxy is not, so a pair can show identically low FST with either low Dxy (recent divergence, ongoing gene flow) or high Dxy (an older split still sharing substantial ancestral polymorphism) – two different demographic histories FST alone cannot distinguish (Cruickshank and Hahn 2014). NewR/dxy.R: per-locusp1*(1-p2) + p2*(1-p1)(the probability a randomly drawn allele from each population differs), averaged across loci where both populations have at least one called genotype – the same “usable loci” conventioncompute_jost_d()/the population tree’s Nei’s-distance already use, and no finite-sample bias correction, matching how Dxy is defined and reported in the literature (unlike Jost’s D’s bias-corrected Hs/Ht). Unlike FST and Jost’s D, Dxy has no natural multi-population generalization (it is fundamentally a divergence between two specific gene pools), so the reportedglobal_dxyis explicitly documented as the mean of the pairwise matrix, not a separate multi-population estimator – a deliberately weaker, honestly-scoped claim than the other three “global” values in the same table. Newglobal_dxycolumn on17_global_FST.tsv,dxy/dxy_n_snpscolumns on18_pairwise_FST.tsv, and a new19c_pairwise_dxy_matrix.tsv; no new figure (table-only, matching Jost’s D’s own precedent).validate_fst_result()now also enforces Dxy’s exact[0, 1]bound as an error (not a warning like FST’s own bound, which the Weir-Cockerham estimator can occasionally exceed under small-sample noise – Dxy has no such estimator noise of its own, so a violation would indicate a real defect). Real quickstart finding, reported honestly rather than forced into a dramatic contrast: global Dxy = 0.3056; pairwise Dxy correlates with FST (r = 0.90) but less tightly than Jost’s D does (r = 0.998), as expected since Dxy alone is not scaled by within-population heterozygosity – but the two lowest-FST population pairs here (ITU-STU, LWK-YRI) are also the two lowest-Dxy pairs, so this dataset does not show the FST/Dxy decoupling the statistic is often used to detect, consistent with these 8 populations sharing a broadly similar, comparatively recent divergence history rather than the more variable species-level divergence times where that decoupling is most often reported. Verified with newtest-dxy.R(hand-verified per-locus formula, including two closed-form sanity checks – identical population frequencies reduce to2p(1-p), fixed-opposite alleles give exactly 1 – plus a full multi-locus/multi-population hand calculation, the zero-call-locus exclusion convention, and module-integration coverage), a full local test suite run, and a real end-to-end reference run. No new dependency, no public API changes (all new functions are internal).-
Added multilocus genotype (MLG) and clonal diversity analysis, a new opt-in-by-default
clonalitymodule. Found by a standard-toolkit gap audit:poppris already a requiredImportsdependency, but its actual signature capability – genotypic diversity indices and detection of samples sharing an identical multilocus genotype – was completely unused; the only existing call (poppr::poppr.amova()inR/amova.R) never touches MLGs at all. Distinct from the existingkinshipmodule: kinship scores continuous pairwise relatedness (KING) and flags samples above a threshold, while this module asks whether two samples’ genotypes are exactly identical across the analyzed marker set – the standard signal for clonal replicates, accidental resampling, or duplicate submissions in any population-genetic dataset, diploid-outbreeding or partially clonal.A real object-compatibility finding, not assumed from the docs. adegenet’s
genlight/poppr’ssnpcloneclasses (this package’s usual representation for SNP data,genlight_from_gds()) are explicitly rejected by bothpoppr()andgenotype_curve()– confirmed empirically against the installed poppr 2.9.8 (“The poppr function will not work with genlight or snpclone objects” / “must be a genind or loci object”), not something the docs make obvious up front. NewR/clonality.Rinstead builds agenindobject directly, encoding genotypes with synthetic per-locus allele labels (“1”/“2”) rather than real ref/alt bases – genotype matching only needs internally-consistent labels per locus, simpler thanml_tree’s IUPAC encoding, which genuinely needs real bases for its nucleotide substitution model.Outputs.
56_MLG_diversity_summary.tsv(per-population + Total: N, MLG, eMLG with its rarefaction SE, Shannon’s H, Stoddart-Taylor’s G, Simpson’s lambda, evenness E.5, expected heterozygosity, and the index of association Ia/rbarD –poppr::poppr()’s own columns, renamed to self-documenting snake_case);57_MLG_groups.tsv(samples sharing an identical multilocus genotype, frompoppr::mlg.id(), with across_populationflag when a group spans more than one recorded population label – a stronger, discrete corroboration of the kind of cross-population duplicate/mislabeling issue kinship’s continuous score can only suggest);58_genotype_accumulation_curvefigure and table (mean and 95% envelope of distinct MLGs resolved as loci are subsampled, summarized rather thanpoppr::genotype_curve()’s own per-count boxplots – illegible at hundreds of loci, where a continuous mean/envelope curve reads cleanly). New report section (manuscript skeleton) surfaces the summary and groups tables in the body text, matching the AMOVA section’s placement; both are also picked up automatically by the existing figure/table gallery machinery.New config, tuned against a real measured cost, not guessed.
analyses.clonality(defaultTRUE),analyses.clonality_genotype_curve_replicates(default 100), andanalyses.clonality_ia_permutations(default 0 – Ia/rbarD’s point estimate is always computed, but its permutation-based p-value is opt-in: measured directly on realistic 160-sample/357-locus synthetic data,poppr::poppr(sample = 999)(this package’s usual permutation-count convention, matching AMOVA) exceeded two minutes, an inappropriate default cost for every pipeline run). Modulerequires = "diversity"(reusescompute_diversity()’s already-extracted genotype matrix, the same dependency AMOVA declares) and is gated on population metadata likeamova/bottleneck(added toR/metadata_capabilities.R’s population-module list and disabled intemplate_config(), the metadata-less generated template, alongside them).resource_class = "heavy", set from the real measured runtime (99.26s on the quickstart data, comparable to AMOVA’s 109s) rather than the smaller synthetic-data timing that motivated the module’s initial design.Real, honest findings, investigated before writing them up. On the quickstart’s 160 samples (1,969 QC-passing loci, the same unpruned locus set AMOVA reuses – not the 357-SNP LD-pruned set
kinship/pca/treeuse), every sample has a unique multilocus genotype:57_MLG_groups.tsvis empty. Notably, this includes the pair already documented as this package’s one confirmed real duplicate/MZ-twin (NA19331/NA19334, KING kinship 0.4459, kinship’s ownduplicate/MZ twinclassification) – their genotypes are not bit-identical across this larger marker panel, a genuine, expected illustration of why the two signals are complementary rather than redundant: kinship’s continuous estimator classifies a relatedness class from partial genetic similarity, while exact multilocus-genotype matching requires literal identity and is comparatively easy to rule out with enough markers. The genotype accumulation curve saturates almost immediately, as expected for real, unrelated diploid samples: a mean of 150 of the 160 possible MLGs are already resolved with just 14 of the ~1,969 available loci, and every replicate deterministically resolves all 160 by 174 loci. Ia/rbarD are positive in every population and overall (e.g. Total Ia = 35.40, rbarD = 0.0185) – documented honestly as not evidence of clonal reproduction in an outbreeding species, but an expected artifact of testing an index designed to assume approximately independent markers against this module’s deliberately unpruned, physically-linked locus set; interpret Ia/rbarD cautiously here, unlike the MLG/diversity columns, which are unaffected by that choice. Verified with newtest-clonality.R(comprehensive unit and module-registration coverage) and this real end-to-end reference run; committed example PDF unchanged at 145 pages (the new figure fit within existing whitespace) despite one genuinely new figure. No public API changes beyond the newclonality_module_specexport. Added a population-colour legend to the individual-level tree figures (
52_IBS_tree,55_ML_tree): tip colours were shown with no key mapping colour to population name. Population-tree figures (53_population_tree) are unaffected – their tips already are population names, needing no legend. Drawn in its own dedicated panel viagraphics::layout()rather than an insetlegend()call inside the tree panel itself, so it never overlaps tree structure regardless of tip count – an inset placement that looks fine on a small tree can land on real branches for a tall, 160-tip individual tree; verified directly on both. Figure width widened slightly (8in to 9.5in) to give the legend panel room without shrinking the tree area. Regenerated the two affected figures directly from the cached results of the immediately-prior reference run (topology, branch lengths, and bootstrap support are unaffected by a rendering-only change) rather than re-running the whole pipeline; re-rendered the committed example PDF from that same cached run. No public API changes.-
Added a genuine maximum-likelihood individual-level tree (GTR+Gamma, Lewis (2001) ascertainment-bias correction, bootstrap support), a new opt-in
ml_treemodule distinct from the existing NJ-basedtree/population_treemodules added the previous round.Scope, decided by the statistics, not by preference. ML nucleotide substitution models operate on per-taxon sequence characters; there is no standard, defensible way to apply them to the population-level tree, which is built from allele-frequency distances (Nei’s D), not per-sample sequences – averaging individuals into a population consensus sequence and running ML on that is not established practice. So this module applies only to the individual-level tree; the population tree keeps its bootstrapped-NJ approach.
No new SystemRequirements.
phangorn(a newSuggestsdependency, checked viarequireNamespace()at run time – the module is off by default, so a hard failure when a user explicitly enables it without the package installed is the right failure mode, matching this codebase’s convention for other explicitly-opt-in optional backends) implements the ascertainment-bias correction natively (phangorn::pml(ASC = TRUE), its own documentation: “allows to estimate models like Lewis’ Mkv”) – the correction a SNP-only alignment with no invariant sites recorded genuinely requires for a valid likelihood, not an optional refinement. This is pure R with a compiled likelihood backend, unlike ADMIXTURE/fastStructure/sNMF, so no external binary or container change was needed; added toinst/conda/environment.ymlalongside this package’s other optional R dependencies.Encoding and model. New
R/ml_tree.R: each sample’s genotype dosage (0/1/2/NA) is encoded as an IUPAC-coded DNA character per locus (homozygous ref/alt become the plain base, heterozygous becomes the standard ambiguity code, missing becomes “N”) using the retained biallelic ref/alt alleles, feedingphangorn::phyDat(type = "DNA"). The existing NJ tree (phangorn::NJ()on adist.ml()JC69 distance) is reused as the ML search’s starting topology;optim.pml()then jointly optimizes GTR rate parameters, gamma-distributed rate heterogeneity, topology (NNI rearrangement), and branch lengths. Bootstrap support (phangorn::bootstrap.pml(), nativemulticore/mc.coresparallel support, 100 replicates by default) is tallied via the samebootstrap_tree_support()/ape::prop.part()/ape::prop.clades()machinery the NJ trees already use, and rendered the same way (plot_nj_tree(), reused as-is): new55_ML_treefigure and54_ML_tree_summary.tsv(model, log-likelihood, SNPs used/dropped, replicate count),trees/ML_GTR_gamma_ASC.nwk.A real numerical-stability failure mode found, investigated, and hardened against – not papered over. Building small test fixtures surfaced a genuine, directly-observed phangorn limitation: with too little SNP data relative to sample count,
optim.pml()’s branch-length optimizer can become numerically unstable, sometimes propagating as an R error, sometimes (confirmed directly) returning normally with a non-finite log-likelihood instead – and the exact boundary is data-pattern-dependent, not a simple SNPs-per-tip ratio (the real 160-sample/357-SNP quickstart run is stable at a lower ratio than a small 12-sample test fixture needed).run_ml_tree()now explicitly checks for both failure shapes and converts either into one clear, actionable error. A second, related failure mode – a single bootstrap replicate’s resampled locus subset occasionally being too uninformative to fit at all – is handled by design rather than treated as fatal: the main tree is already a complete, valid result on its own, so a failed bootstrap degrades gracefully (bootstrap_failed = TRUE, no node labels, a logged warning) instead of losing the whole analysis over a supplementary confidence measure.Real, honest finding, investigated before writing it up. On the quickstart example (160 samples, 357 SNPs, 100 replicates), ML bootstrap support is even sparser than the NJ tree’s own bootstrap from the previous round (median 1% vs. NJ’s 13%; 68% of nodes below 10% support vs. NJ’s 44%) – expected, not a regression: a proper likelihood-based method with an explicit substitution model is more conservative about what a modest, LD-pruned marker panel can actually resolve than a simple distance method. The one split both methods agree on with full confidence is the same one: the tree’s root, separating the African (LWK, YRI) samples from everyone else, at 100% bootstrap support in both the NJ and ML trees – the same continental signal this package’s PCA/MDS/FST already find, now independently confirmed a fourth way. Documented honestly in the wiki/vignette.
Verified with new
test-ml-tree.R(43 assertions: hand-verifiable IUPAC encoding including dropped-locus and case-insensitivity edge cases, a real quickstart-derived fixture – not purely synthetic data, since a synthetic fixture with uniform per-group allele frequencies reliably triggered the numerical-instability failure mode above – structural correctness, determinism for a fixed seed, the graceful bootstrap-degradation path exercised directly with a combination verified to trigger it, module registration/config, and validator coverage), a full local test suite run, and a real end-to-end reference run isolation-diffed against the immediately-prior run: zero table differences, zero PNG figure differences outside the one new figure. Committed example PDF now 145 pages (from 144). Refreshed the public API baseline (639 entries, the newml_tree_module_specexport). No public API changes beyond that export. Fixed a CI break in the tree-bootstrap tests shipped moments earlier:
R-CMD-checkfailed withError in .check_ncores(cores): 4 simultaneous processes spawned.parallel::mclapply()enforces CRAN’s 2-core check policy (parallel:::.check_ncores(), active whenever_R_CHECK_LIMIT_CORES_is set – asr-lib/actions/check-r-packagedoes, but a bare localR CMD checkdoes not, which is why this passed locally before shipping). Two new tests passedcfg$compute$threads–default_config()’s auto-detected full machine core count, unrelated to what the test itself needed – straight intobootstrap_nj_ibs_tree()/bootstrap_population_nj_tree()’s realmclapply()dispatch; on the CI runner’s 4 cores, that’smc.cores = 4, over the limit. Fixed by cappingcfg$compute$threads <- 2Lin both tests (the tests only need parallel dispatch to genuinely occur, not any specific worker count). Verified this time by explicitly setting_R_CHECK_LIMIT_CORES_=truefor both the local test run and a realR CMD build+R CMD checkagainst the built tarball – the exact condition that caught this, not just a bare local check. No production code changed.-
Added bootstrap support and rendered figures to both existing neighbour-joining trees (the individual-level tree from IBS distance, and the population-level tree from Nei’s (1972) genetic distance), which were previously Newick-output-only with no rendering, no support values, and no figures at all.
Bootstrap support (Felsenstein 1985). New
analyses.tree_bootstrapconfig block (enabled = TRUE,replicates = 100L–ape::boot.phylo()’s own documented default, chosen as the standard fast-but-informative choice rather than guessed; parallelized acrosscompute.threadsworkers). Both trees resample loci with replacement, rebuild the tree from each resampled set, and tally the percentage of replicates agreeing with each split in the original tree – the standard phylogenetic bootstrap, applied to population-genetic distance trees (not sequence alignments) viaape::prop.part()/ape::prop.clades(), ape’s own bipartition-matching algorithm (the same oneape::boot.phylo()calls internally), shared by both tree types in newR/tree_bootstrap.Rrather than reimplemented per tree.Getting there required solving a real technical obstacle for the individual IBS tree specifically:
SNPRelate::snpgdsIBS(), which computes the tree’s real distance matrix, rejects duplicate SNP ids outright (confirmed directly:"Some of snp.id do not exist!"), so it cannot be called per bootstrap replicate on a resampled-with-replacement locus set. Fixed by extracting the retained genotype matrix once (SNPRelate::snpgdsGetGeno()) and computing each replicate’s distance directly fromstats::dist(..., method = "manhattan"), mean-imputing missing genotypes per resampled locus column – verified to reduce to SNPRelate’s own exact IBS formula on complete data (max absolute difference ~5e-17, floating-point noise, against a real quickstart subset). The population tree’s bootstrap resamples column-pairs of the already-built per-population allele-count matrix directly (no per-replicate re-derivation from the raw locus table); verified thatadegenet::dist.genpop()treats duplicate-named resampled columns exactly like independently-renamed ones (a real risk checked directly, not assumed, sincegenpop’s internal locus grouping could plausibly have merged same-named duplicate columns instead of treating each resampled occurrence independently – it does not).Rendered figures. Neither tree had any figure at all before this – new
52_IBS_tree/53_population_treefigures (ape::plot.phylo()/ape::nodelabels(), this package’s first base-graphics figures; everything else uses ggplot2). Newsave_base_plot()(R/utils.R) mirrorssave_plot()’s format/dpi conventions for a plotting function that draws directly to a device rather than returning a ggplot object. The individual tree’s tips are coloured by population (reusing the existingpopulation_palette()); figure height scales with tip count (capped at 24in for the full 160-sample quickstart tree, still legible).A real, honest finding investigated before writing it up. On the quickstart example (357 LD-pruned SNPs), the individual-level tree’s bootstrap support is low almost everywhere (median 13%, 44% of internal nodes below 10%) – an expected consequence of resolving fine-scale, individual-level splits from a modest marker panel, not a bug. But the single most important split – separating the African (LWK, YRI) samples from everyone else, the same continental signal PCA/MDS/FST all already found – has 100% bootstrap support, the tree’s actual root split. The population-level tree, by contrast, shows uniformly high support (96-100% at all 6 internal nodes): averaging allele frequencies over many individuals per population makes that topology far more robust to which loci were sampled than any single individual-level split can be. Both patterns are documented honestly in the wiki/vignette rather than only showing the flattering population-tree result.
validate_tree_result()/validate_population_tree_result()now check that bootstrap support labels, when present, are valid percentages (0-100) matching the internal node count.tree_module_spec()/population_tree_module_spec()gained “Felsenstein 1985” alongside their existing references. Verified with newtest-tree-bootstrap.R(49 assertions: bootstrap support tallying against hand-verifiable synthetic bipartitions, the IBS distance formula match, mean-imputation on a hand-computable missing-data case, end-to-end determinism for a fixed seed, the population-tree column-resampling re-derivation check, figure rendering, and validator/config coverage) plus a real end-to-end reference-pipeline run. No public API changes (all new functions are internal). -
Fixed a flaky test,
test-execution-scheduler.R’s “portable workers preserve dispatch order and expose completion order” (failed intermittently, roughly one run in three under load, unrelated to any recent code change), and along the way found a real, more fundamental limitation worth documenting rather than papering over:pkgload::load_all()’s dev-mode namespace is not always reliably usable from a freshparallel::makePSOCKcluster()worker.The test’s original premise – two modules sleeping 0.35s and 0.05s, asserting the shorter one always completes first – raced two independent sources of noise, not just each other. First,
"multisession"dispatches through a freshly-spawned PSOCK cluster for every call (run_execution_batch()), whose own process-startup latency is real and variable under load, easily comparable to the original 0.3s margin. Widening the margin (tried up to 3s vs 0.1s, a multi-second gap) measurably helped but did not fully eliminate failures under real full-suite load, and a filesystem-marker-based synchronization alternative (making completion order a hard causal dependency instead of a timing race) was tried and abandoned: it depends on PSOCK worker processes sharing a consistent view of the temp filesystem, which did not hold reliably in this environment.Investigating why even a generous margin sometimes failed surfaced the second, unrelated cause: under
pkgload::load_all()(this repository’s routine local-development mode, not just an edge case), a PSOCK worker’s ability to resolve an unqualified in-namespace call – here,run_stage(), called from insiderun_scheduled_engine_module()– is not reliable. It intermittently fails with"could not find function 'run_stage'", which the module’s owntryCatch()correctly converts into a fast, near-instantPopgenVCFEngineFailurerather than ever reaching the module’sSys.sleep()call at all – explaining both the near-zero elapsed times observed and why no fixed timing margin, however wide, could have made the original assertion reliable. Confirmed via 10/10 clean runs against a realR CMD INSTALLof the identical commit (matching whatR CMD checkand this repository’s own CI always use) that this is specific to the dev-mode namespace, not a real defect inrun_stage()/run_scheduled_engine_module()themselves, and not something any test-side timing fix could have addressed.Restructured the test to separate what it is actually verifying, rather than continuing to chase a still-flaky combined assertion:
scheduler_sequence()’s own completion-order-ranking logic is now verified directly against synthetic, deterministicfinished_numericinputs (a new “scheduler_sequence orders distinct completion times correctly” test, alongside the existing tie-breaking one) – this is the part that actually needs unit coverage, and it needs no real dispatch or timing at all. The real-dispatch test now only verifies genuine parallel execution occurred (both modules completed with distinct real worker PIDs, dispatch order preserved), with no real-timing-based completion-order assertion at all, and tolerates (skips, does not fail) the known dev-mode-only namespace-resolution limit specifically whenpkgload::is_dev_package("popgenVCF")is true – a real install still must fully succeed; verified 5/5 clean (no skips) against a real install. No production code changed. -
Fixed the four remaining, lower-priority findings from the full-codebase audit that previously surfaced the PCA/genome_scan/registry, ADMIXTURE-timeout, and stale-install-shadowing bugs fixed earlier this cycle.
admit_execution_resources()was stateless. Each call checked one module’s requirements against a resource policy’s fixed capacity in isolation, with no shared record of what else was already admitted and still running against that same policy – so two genuinely concurrent, overlapping admissions each saw the full capacity independently, providing no real protection against joint oversubscription once more than one was actually in flight at once. Not reachable anywhere in this package’s own pipeline today (ADMIXTURE and fastStructure both run one external process at a time, never concurrently), but a real gap in the public contract of the exported async supervision API (start_supervised_external_command()and friends), which document themselves as “deterministic concurrent scheduling.” Fixed with an explicitin_useparameter onadmit_execution_resources()(default all-zero, exactly reproducing prior behavior for every existing caller) and a session-scoped ledger (admission_ledger_in_use()/_commit()/_release()) thatstart_supervised_external_command()now consults and updates, releasing onset_async_terminal()– with areg.finalizer()safety net that releases a handle’s commitment if it is garbage-collected without ever being finalized, rather than leaking it for the rest of the R session. Honestly scoped: this ledger is per-R-session state and cannot, and does not attempt to, coordinate admission across separate forked/parallel worker processes (e.g.parallel::mclapply()), since each fork gets its own independent copy of R’s memory. Verified with new tests:admit_execution_resources()’sin_usereduces available capacity as expected; two overlapping async handles under aprocesses = 1policy now genuinely compete (the second is rejected while the first is still running, then admitted once the first finalizes); and the GC-based safety net actually releases an abandoned handle’s commitment.The VCF-normalization cache trusted mtime, not content.
prepare_vcf_input()treated its cached, sorted/BGZF-compressed/indexed copy as current whenever its mtime was>=the source VCF’s mtime, without ever checking content – a source VCF replaced with different content but an equal-or-older mtime (e.g. restored from a backup or viagit checkout, both of which can leave an older or unchanged mtime on genuinely new content) would silently serve stale cached genotypes for the rest of the run, with no error or warning. Fixed by keying cache validity on adigest::digest(vcf, algo = "sha256", file = TRUE)content fingerprint recorded in a small sidecar file next to the cache, checked on every call instead of comparing timestamps; hashing costs far less than the sort/index step this cache exists to avoid, so there is no fast-path short-circuit that could reintroduce the same class of bug. Verified with new tests: an unchanged source correctly reuses the cache (cached file’s mtime is untouched by a second call); a source whose content changed but whose mtime was deliberately set older than the existing cache (the exact scenario that defeated the old check) is correctly detected as stale and rebuilt; andforce = TRUEstill always rebuilds.Release-candidate evidence artifacts could alias the same underlying content under two different declared paths.
evaluate_release_candidate_dossier()already rejected duplicate declared path strings, but a symlink, hard link, or a literal duplicate copy could make two different declared paths resolve to byte-identical content, letting one physical file be checksummed and counted as two independent pieces of retained evidence. Fixed by additionally rejecting any two artifacts with identicalsha256– a content-based check that catches all three mechanisms uniformly (a symlink-resolution-only check, usingnormalizePath(), would have missed hard links and literal copies). Verified with a new test that makes one gate’s evidence file byte-identical to another’s (declared checksum updated to match, so each individually still passes its own per-artifact integrity check) and confirms the new cross-artifact check is what fails it.ADMIXTURE’s chromosome-code normalization mutated a PLINK bundle shared with fastStructure by default.
run_admixture_cv()rewrote non-integer BIM chromosome codes to0(ADMIXTURE’s own strict requirement) directly onplink_prefix’s.bimfile, in place.prepare_structure_plink_input()already caches and reuses one canonical PLINK bundle across ancestry backends by default (run_module_faststructure()explicitly reusescontext$structure_plinkfromrun_module_admixture()when both are enabled, to avoid redundant PLINK export work) – so whichever backend happened to run first would silently mutate the shared.bimfile fastStructure then read afterward, an execution-order-dependent side effect on cached, shared state, even though fastStructure never actually needs or benefits from ADMIXTURE’s chromosome-code convention. Fixed by havingrun_admixture_cv()stage a private copy of the PLINK bundle scoped to its ownoutput_dirand normalize that copy, leavingplink_prefix’s own files – and therefore any other backend reusing them – completely untouched regardless of execution order. Output file naming is unaffected (the private copy keeps the same basename ADMIXTURE’s own.Q/.Poutput naming already assumed). Verified with a new test: after a successfulrun_admixture_cv()run against a bundle with a non-integer chromosome label, the original bundle’s.bimis byte-for-byte unchanged and gained no chromosome-map sidecar file, while the private, normalized copy and its sidecar exist inoutput_dirinstead.Refreshed the public API baseline (638 entries, unchanged) to record
admit_execution_resources()’s new optionalin_useargument – and, folded into the same refresh, two already-shipped-but-never-recorded optional arguments from the ADMIXTURE-timeout fix earlier this cycle (run_admixture_cv()/run_faststructure()’stimeout_seconds), closing out an advisory finding that had been accumulating unnoticed since that round. All four fixes are internal robustness/correctness hardening with no observable effect on the quickstart demo’s own default-config output (none of the four code paths run underdefault_config()), confirmed by an unchanged reference-pipeline run. No public API changes beyond the recorded optional arguments above (no new or removed exports). -
Corrected the
popgenvcf_software_identity_path()fix shipped earlier this cycle, which brokeR-CMD-checkandtest-coverageCI, and fixed the same stale-install-shadowing bug in two more places found while investigating.The earlier fix’s own test (
test-software-identity-path.R) usedtestthat::local_mocked_bindings(system.file = fn, .package = "popgenVCF")to stubbase::system.file()– this passed 100% locally underpkgload::load_all(), butlocal_mocked_bindings()can only mock a function genuinely bound within the target package’s own namespace, whichbase::system.fileis not (popgenVCF calls it via ordinary search-path scoping, not an explicit import).pkgload::load_all()’s dev-mode namespace simulation is permissive enough to let this appear to work anyway; a real installed package’s properly locked namespace (R CMD check,covr, or any genuine end-user install) throwsError in testthat::local_mocked_bindings(...): Can't find binding for 'system.file'instead – exactly what happened in CI on both workflows once this shipped. Fixed by switchingpopgenvcf_software_identity_path()to dependency injection instead: it takes asystem_fileparameter, and its tests pass a fake function directly rather than mocking anything, sidestepping the namespace question entirely.Choosing that parameter’s default itself surfaced a second, independent trap of the same general kind, caught only by a full local
testthat::test_dir()run (not by testing the affected file in isolation, which happened to never exercise the fallback branch): the first version of this fix defaulted tosystem_file = base::system.file, explicitly qualified.pkgload::load_all()makessystem.file()correctly resolve a dev-loaded package’s own source-tree files by inserting a patched binding into that package’s imports environment, which an ordinary unqualifiedsystem.file(...)call from inside the package’s own code picks up automatically – but an explicitbase::system.filereference bypasses the imports environment entirely and reads the real, unshimmedbasenamespace directly, silently returning""instead. This stayed invisible through every test run oftest-software-identity-path.Ralone (its source-tree candidates always matched first, so the broken fallback path was simply never reached), and only surfaced running the complete suite, where three unrelated, already-passing tests (test-fair-export.R,test-publication-companion.R,test-release-metadata-contract.R) callpopgenvcf_software_identity()for real, with no fakesystem_filesupplied, from a point in the suite where the source-tree candidates no longer resolve – hitting the broken default and failing with “canonical software identity metadata is unavailable”. Fixed by defaulting to the unqualifiedsystem_file = system.filesymbol instead: this correctly picks up pkgload’s dev-mode shim during local/CI test runs and resolves to genuinebase::system.filein a real install (which has no shim to bypass), matching how every othersystem.file()call in this codebase is already written. Re-verified with a fulltestthat::test_dir()run (not just the individual affected test files) and, again, a realR CMD build+R CMD checkagainst the built tarball.Investigating this surfaced two more real instances of the identical backwards-priority bug, both fixed the same way (prefer the checked-out source tree when reachable, fall back to
system.file()/script-relative resolution otherwise):inst/scripts/continuous_benchmark_tiers.R’ssys.source()-based loading of its four helper modules (newresolve_benchmark_helper_script(), preserving this file’s own documented reason for staying underinst/–system.file()reachability from a realR CMD checkinstall – while making local/CI-checkout runs prefer the current commit’s own files instead of a possibly-different installed copy) andscripts/build_release_benchmark_archive.R’s resolution of that same tier module (switched to the--file=-based script-relative resolutionscripts/build_release_candidate_evidence_index.Ralready established, since this top-level script is always invoked directly from a real checkout and never needssystem.file()at all). A fifth, previously-unaudited instance was also found and fixed directly intests/testthat/test-benchmark-tier-datasets.R’s ownbenchmark_tier_module_path()helper. Testingresolve_benchmark_helper_script()needed a third technique beyond mocking and dependency injection: it is a loose function loaded viasys.source(), not part of any package namespace at all, so neitherlocal_mocked_bindings()nor a namespace-based approach has anything to attach to – new tests injectsystem.filedirectly into the function’s own defining environment (assign("system.file", fn, envir = env)) instead, which works reliably in both dev-mode and installed contexts since it never touches a package namespace.All five instances (three release-candidate-dossier helpers fixed earlier this cycle, plus these two, plus
popgenvcf_software_identity_path()) now share the same resolution convention. No public API changes; no analysis output affected (all five are test/build/release-tooling infrastructure, never on therun_pipeline()code path). -
Fixed another instance of the same audit-found bug class:
popgenvcf_software_identity_path()(R/software_identity.R) checked asystem.file()-resolved installed copy of popgenVCF before the checked-out source tree – the exact backwards priority already found and fixed twice this cycle inrc_policy_path()/rc_module_dir()/rcec_policy_path()(the release-candidate dossier test helpers). This is the functionpopgenvcf_software_identity()calls to locateinst/metadata/software-identity.json, which feedsfair_documents()(R/software_identity_integration.R) – a stale installed popgenVCF (different version, license terms, or DOI than the current checkout) sitting anywhere on the R library search path could silently shadow the current commit’s real identity metadata, corrupting a real FAIR/RO-Crate/CodeMeta export or a metadata-contract test’s result with no indication anything was wrong.Unlike the two release-candidate-dossier fixes, this function needed a more careful fix than a straight reorder:
system.file()resolving to a genuinely installed copy is the correct primary behavior for a real end user with no accompanying source checkout at all – reordering naively would have broken that far more common case. The fix instead makes the checked-out-source-tree candidates take priority when reachable (a real installed package has no such candidates to find, so it falls through tosystem.file()exactly as before) and only falls back tosystem.file()when none of them exist. Newtest-software-identity-path.Rgives this function dedicated unit coverage for the first time (previously only exercised indirectly): source-tree-preferred-when-both-exist, installed-copy-used-when-no-source-tree-reachable (the normal end-user case, explicitly verified not to regress), fail-closed with a clear error when neither is reachable, and the existing (previously untested)POPGENVCF_SOURCE_ROOToverride. -
Fixed another real bug found by the same full-codebase audit: ADMIXTURE and fastStructure subprocess calls (
run_admixture_cv(),run_faststructure()) had no timeout at all, despite this package already having a complete, tested subprocess-supervision layer (external-process-supervision.R, built onprocessx, with proper timeout and process-tree cleanup) that these two callers simply never used. A degenerate or malformed genotype matrix – a documented real ADMIXTURE/fastStructure failure mode – could hang either indefinitely with no way to recover, no resource-admission check, and no cancellation-token honoring.New shared internal helper
run_supervised_line_command()(R/external-process-supervision.R) wrapsrun_supervised_external_command()while preserving the exactsystem2(..., stdout = TRUE, stderr = TRUE)return shape (output/status) both callers’ existing parsing logic was already built against, so neitheradmixture_output_tail()/faststructure_output_tail()/parse_admixture_cv()needed to change at all – only the subprocess-invocation internals did. Newanalyses.admixture.timeout_seconds/analyses.faststructure.timeout_secondsconfig keys (default 14400 seconds = 4 hours per subprocess call – generous for real genomic datasets while still providing a real safety net against a genuinely hung process, not a limit meant to interrupt legitimately slow-but-progressing computation).run_admixture_cv()’s no-longer-neededsetwd()/on.exit(setwd())dance was also removed:processx::run(wd = ...)sets the subprocess’s working directory directly, which is both correct (ADMIXTURE writes its.Q/.Poutput relative to its own CWD) and safer (the old code changed the whole R process’s working directory as a side effect for the run’s duration).Verified three ways before shipping, not just code-reviewed: (1) the pre-existing
test-admixture-runtime-failures.Rfailure-message test (a fake executable that echoes diagnostic text and exits non-zero) passes unchanged, confirming the rewrite preserves exact error-message format; (2) new tests with a fake executable that genuinely sleeps past a short timeout confirm the process is actually killed (elapsed wall-clock time close to the configured timeout, not the full sleep duration) and the resulting error correctly surfaces “exceeded timeout”, forrun_supervised_line_command()directly and end-to-end through bothrun_admixture_cv()andrun_faststructure(); (3) a real end-to-end reference run (real ADMIXTURE and fastStructure binaries, K=2:4) confirms normal execution is completely unaffected – computed CV errors and Q-matrix values match already-established run-to-run floating-point noise exactly, not a regression. (One real reference run did hit the new timeout during this verification, from unrelated CPU contention with a concurrent local test-suite run on the same machine – confirmed by re-running the identical scenario in isolation afterward, completing in under a second; a useful, if accidental, demonstration that the timeout mechanism itself works correctly under real contention, not evidence of a bug in it.)ADMIXTURE and fastStructure are off by default (
analyses.admixture/analyses.faststructure, bothenabled: false), so this does not affect the quickstart demo’s own default-config output; no committed figures, tables, or PDF pages changed. No public API changes beyond the two new config keys (both new optional fields on already-nested config lists, not new exported functions). -
Fixed three real, previously-undetected correctness bugs found by a full-codebase audit (five parallel review passes over
R/,scripts/, andinst/scripts/, each independently re-verified by tracing the actual code paths and, for the most severe one, live-reproducing the bug against real SNPRelate output before accepting it as real).PCA percent-variance-explained was inflated by roughly 3-4x for every PC, in every run.
run_pca()(R/ordination.R) computed each component’s reported percentage by dividing its eigenvalue by the sum of only the requested/retained eigenvalues (eig$values / sum(eig$values)), instead of the true total variance across the full spectrum.SNPRelate::snpgdsPCA()already returns this correctly (z$varprop, computed against the true total trace regardless of how many components were requested viaeigen.cnt), but that field was never used. Verified two ways before shipping: (1) live against a synthetic 30-sample/500-SNP dataset requesting 5 PCs – SNPRelate’s ownvarprop[1]= 5.16%, the pre-fix formula gave 21.72% (a ~4.2x inflation); (2) on this package’s own real quickstart dataset, the shipped PDF report’s committed PC1 percentage was 26.48%, corrected to 9.13% (~2.9x) – the ratio differs between the two checks because the inflation factor scales withrequested_components / (n_samples - 1), which differs between a 30-sample synthetic test and the real 160-sample dataset. Fixed by usingz$varpropdirectly, which is already correctly computed in both the primary SNPRelate path and the rare covariance-eigendecomposition fallback path (recover_pca_eigensystem(), which was never affected – it already divides its own truncated eigenvalue subset by the true full-spectrum positive total). This affects every PCA figure’s axis labels, every13_PCA_variance.tsv, andchromosome_summary.tsv’spc1_percentthis package has ever produced; the actual PC scores/embedding themselves (z$eigenvect) were never affected, only the reported percentage – separation patterns, ANOVA comparisons, and every other PCA-derived conclusion already documented in the wiki/vignette remain valid. Newtest-pca-variance-explained.R: confirms the reported percentage matches SNPRelate’s ownvarprop(not the old truncated-sum formula) and, critically, that PC1’s percentage no longer changes depending on how many total components were requested (a correct percentage is intrinsic to that component; the pre-fix formula’s denominator grew withn_pcs, so the same PC1 would misleadingly shrink as more components were requested).genome_scan(on by default) crashed with'population' should not have missing values!'whenever sample-level QC excluded even one sample.run_module_genome_scan()(R/module_registry.R) passed the raw, pre-QCcontext$ids(captured beforeharmonize_samples()runs) intorun_genome_scan_fst(), which built itspopulationfactor andsample.idargument directly fromids$sample– whilecontext$metadata(also passed in) was already filtered to the QC-retained set. Any sample present in the raw GDS but excluded byqc.max_sample_missingproduced anNApopulation value, whichSNPRelate::snpgdsFst()rejects outright. This was never caught bytest-genome-scan.R’s own existing fixture, which happened to build both itsidsandmetadatadirectly from the raw GDS/TSV without ever routing throughharmonize_samples()– keeping them consistent by construction and masking the exact mismatch that exists in the real pipeline call – nor by any reference run this whole development cycle, since the curated 160-sample quickstart dataset happens to have zero samples excluded by sample QC. Any real-world dataset where QC excludes at least one sample – a routine, expected occurrence, not a corner case – crashed the entire pipeline. Fixed inrun_genome_scan_fst()(R/genome_scan.R) by derivingsample.id/populationfrommetadata(already correctly QC-filtered, exactly like every other module) instead ofids$sample. New test case reproduces the exact crash against the real quickstart dataset with one sample manually excluded frommetadatabefore the fix, confirms it no longer errors after the fix, and confirms the result is identical to passing anidsobject whose$samplewas already pre-restricted – proving the excluded sample is genuinely ignored, not silently included. No visible change to the quickstart demo’s own output (it has no QC-excluded samples), confirmed by the isolation diff below.Disabling a module via config (e.g.
analyses.diversity: false) was silently overridden whenever anything else enabled still depended on it.resolve_analysis_order()’s dependency-closure walk (add_with_dependencies(),R/registry.R) recursively pulled in a module’s declaredrequiresunconditionally, without checking whether that dependency was itself enabled – so leavingfst/dapc/bottleneck/genome_scan/population_tree/amova(allrequires = "diversity") ortree/ibd(requires = "ibs") enabled while disabling their prerequisite silently ran the “disabled” module anyway, with no warning or indication anywhere inanalysis_capabilities.tsvor the execution ledger that the user’s explicit setting was overridden. Fixed by making this configuration state a clear, immediatestop()naming exactly which disabled module(s) are required and by which dependent module, rather than silently deciding for the user which of the two contradictory settings should win. Verified this doesn’t affect either shipped config preset:default_config()enables every module in everyrequireschain together, andtemplate_config()(used by--write-config) disables every dependent ofdiversity/ibsalongside the dependency itself wherever it disables one – confirmed by cross-referencing everyrequiresedge in the registry against both presets before shipping, not assumed. New tests cover both the direct case (a registered module explicitly disabled) and the real diversity/fst relationship using anenabledfunction matching the production registry’s own convention.Reference-run verified together (the same one-off ADMIXTURE/fastStructure/sNMF-enabled config
docs/examples/README.mddocuments). PNG/table-diffed against the immediately-prior reference run: only13_PCA_variance.tsv,chromosome_summary.tsv, and the two PCA-derived figures (07_PCA_PC1_PC2,07b_PCA_PC1_PC2_by_sex) changed as intended (no genome_scan or registry-related output changed, exactly as expected since this dataset has no QC-excluded samples and no disabled-dependency config); the ADMIXTURE K=3 cross-validation-error table differs by 0.00003 (5th decimal place) and the29_fastStructure_runs.tsv/27b_ADMIXTURE_K_selection_scores.tsv/28_ADMIXTURE_Q_K3.tsvdiffs are the same already-established run-to-run floating-point/path-string noise confirmed benign in every prior round this cycle, not a regression. Committed example PDF unchanged at 142 pages. No public API changes. -
Added two more genuine analysis gaps found by the same standard-toolkit audit: population-specific FST (Weir and Goudet 2017) alongside the existing global/pairwise FST, and a run-length-class breakdown (Ceballos et al. 2018) alongside the existing per-sample FROH.
Population-specific FST / beta. Global and pairwise FST (Weir and Cockerham 1984) both describe pairs or the whole dataset – neither answers “how differentiated is this one population from all the others combined,” a question conservation and management applications routinely ask (e.g. flagging the single most distinct population in a panel). Weir and Goudet’s (2017) beta reformulates FST as a per-population parameter via a common (“global”) kinship reference rather than a pairwise one.
run_module_fst()(R/module_registry.R) now callshierfstat::betas(dat, nboot = 0)directly – Goudet’s own reference implementation, not a reimplementation;hierfstatis already an optionalSuggestsdependency used for allelic richness, andhierfstat_encode_genotype()already exists to feed it. NewR/population_specific_fst.R:compute_population_specific_fst()returns$betaiovl(per-population beta) and$betaW(the overall weighted mean); a newglobal_beta_fstcolumn on17_global_FST.tsv, a new51_population_specific_fst.tsv, and a bar-chart figure with the overall value as a reference line. Transparently skipped with a logged warning (matching this codebase’s established convention for optional-dependency modules) whenhierfstatis not installed or fewer than two populations are present – no fabricated numbers.fst_module_spec()’sreferencesnow also cites Weir and Goudet 2017.Real quickstart result:
global_beta_fst= 0.09150000098 versusglobal_fst(Weir and Cockerham 1984) = 0.09150000098 – agreeing to 9 significant figures, a real and expected internal-consistency check (betaWis, by construction, the sample-size-weighted mean of the per-population betas, which coincides with the classical multi-population FST estimator for this dataset’s population sizes) rather than a coincidence. Per-population beta ranges from ITU (0.0446, least distinct from the rest of the panel) to PEL (0.1558, most distinct) – PEL (Peruvian) being the standout outlier matches this package’s own prior Nei’s-distance and population-assignment results, an independent third confirmation of the same real population-structure pattern in this dataset. Verified with newtest-population-specific-fst.R: exacthierfstat::betas()values pinned from a seed-fixed synthetic 3-population fixture (cross-checked against the real installedhierfstat, not hand-derived, since this is a direct pass-through to the reference implementation), the fewer-than-two-populations and hierfstat-unavailable skip paths, and the figure.ROH run-length-class breakdown. The existing
rohmodule already reports per-sample FROH (total autozygosity fraction) but collapses all runs together; Ceballos et al. (2018)’s review of ROH interpretation distinguishes three length classes with different demographic origins: short runs (tens to hundreds of kb, reflecting background LD), intermediate runs (hundreds of kb to ~2 Mb, reflecting distant common ancestors via genetic drift), and long runs (over ~1-2 Mb, reflecting recent close inbreeding – e.g. parental relatedness). Newroh_length_class()(R/roh.R) classifies each run by itslength_bpagainst two configurable boundaries (analyses.roh_length_class_short_max_bp, default 500,000;analyses.roh_length_class_long_min_bp, default 2,000,000), explicitly documented as this package’s own clean, round-number convention chosen within Ceballos et al.’s fuzzy published ranges, not a universal standard.run_roh()gains alength_classcolumn on37_ROH_runs.tsvand six new per-class columns (total_length_bp_short/intermediate/long,froh_short/intermediate/long) on38_ROH_sample_summary.tsv; the threefroh_*columns sum to the existingfrohby construction (checked directly, and now enforced byvalidate_roh_result()to within 1e-6). New24b_ROH_FROH_by_length_classfigure: a stacked bar chart per sample, legend labels showing the actual configured Mb thresholds.roh_module_spec()’sreferencesnow also cites Ceballos et al. 2018.Real, honest finding investigated before writing this up: all 160 quickstart samples show
froh_long = 0– zero runs classified “long” anywhere in the dataset. This is not a demographic claim (extremely low recent inbreeding); it is a mechanical artifact of the quickstart demo’s own analyzed region. Confirmed directly from the reference run:roh_analyzed_footprint_bp()reports the analyzed chr22 region spans only 999,963 bp end to end, and the longest observed run in the whole dataset is 574,060 bp – both comfortably below the 2 Mb “long” threshold, making a “long”-classified run structurally impossible here regardless of true biology, since no single run can exceed the width of the region it was called in.mean(froh_short)= 0.155,mean(froh_intermediate)= 0.0036 across the 160 samples – intermediate runs are real and present, just never long enough to cross into the “long” class at this genomic scale. This is the same general class of caveat already documented for Ne(LD) and Tajima’s D elsewhere in this package: a real result correctly computed from a small demo region, honestly caveated rather than either hidden or overclaimed. A user running this module against a whole-genome VCF (the realistic use case for FROH-by-class) would not hit this ceiling. Verified with newtest-roh.Rcases:roh_length_class()boundary unit tests, arun_roh()integration test proving the same known 3901bp run reclassifies from “short” (default thresholds) to “long” under custom thresholds (short_max=1000, long_min=3000) – confirming the parameters are actually threaded through, not just accepted and ignored – the per-class-zero empty-runs case, the figure, and the validator’s new sum-invariant and non-negativity checks.Both features were verified together against one real ancestry-enabled reference pipeline run (the same one-off config
docs/examples/README.mddocuments). No new exported functions, so no public API baseline refresh was needed this round (confirmed via an emptyNAMESPACEdiff afterroxygen2::roxygenise()+tools/reconcile-rd-aliases.py --write). PNG/table-diffed against the immediately-prior ancestry-ordering/spatial-autocorrelation reference run: exactly the expected changes – new51_population_specific_fst.tsv/figure,17_global_FST.tsvgainingglobal_beta_fst,37_ROH_runs.tsvgaininglength_class,38_ROH_sample_summary.tsvgaining the six new per-class columns, and the new24b_ROH_FROH_by_length_classfigure; the ADMIXTURE K=3 cross-validation-error table differs by 0.00003 (5th decimal place) and the29_fastStructure_runs.tsvdiff is identical after path normalization – both the same already-established run-to-run floating-point/path-string noise confirmed benign in every prior round this cycle, not a regression. Committed example PDF now 142 pages (from 141). Ordered the population-organized ancestry membership figures (ADMIXTURE, fastStructure, sNMF, and DAPC) by ancestry-pattern similarity instead of alphabetically, following user feedback that alphabetical population order scatters genetically similar populations arbitrarily far apart in the plot (e.g. two African populations could land at opposite ends of the panel purely because their names start with different letters).
plot_q_matrix()(R/admixture.R) is the single shared function all four callers already route through (R/nonhuman_plink_export.R’s ADMIXTURE/fastStructure runners,R/module_registry.R’s sNMF runner, andR/dapc.R’s membership plot), so the fix lives in one place, not duplicated per backend. Newpopulation_ancestry_similarity_order()helper: average-linkage hierarchical clustering (the same linkage convention this package’s kinship dendrogram already uses) on Euclidean distance between each population’s mean per-cluster ancestry proportions, computed fresh per K (a population’s ancestry profile differs across K). Only the population-organized view’s row and facet order changes; the data-driven view (already ordered by dominant-cluster membership, not population identity) and the raw.tsvoutput tables (which record samples in their original file order, unaffected by this purely visual reordering) are untouched. Real quickstart result at ADMIXTURE K=7: LWK and YRI (both African), ITU and STU (both South Asian), and GBR and PUR (both dominated by the same admixed-ancestry component) are now each adjacent pairs instead of being separated by 1-3 unrelated populations under alphabetical order – confirmed visually and via a new hand-verifiable test (test-admixture.R) using a synthetic fixture where two similar populations are deliberately named to sort apart alphabetically (A_pop/C_popsimilar,B_popdifferent) and are recovered as adjacent regardless. Verified end to end against the real reference pipeline run: exactly the 32 population-organized Q-matrix/membership figures (every K across all three backends plus DAPC) changed, nothing else. No public API changes.-
Added spatial autocorrelation analysis (Smouse and Peakall 1999), a distance-class correlogram complementing the single overall Mantel r the
ibdmodule already computes, found by the same standard-toolkit audit. Newspatial_autocorrelationmodule (on by default viaanalyses.spatial_autocorrelation, needs geographic coordinates likeibd) bins sample pairs into equal-width geographic-distance classes and computes an autocorrelation coefficient r per class from a squared individual genetic-distance matrix, with a permutation-based null envelope and two-sided p-value per class (analyses.spatial_autocorrelation_bins, default 10;analyses.spatial_autocorrelation_permutations, default 999, matching the existing Mantel permutation convention). New50_spatial_autocorrelation.tsvand figure (a correlogram: r vs. distance class, with the permutation envelope as a shaded band).Verified two ways before shipping, not just formula-derived by hand: (1) the per-locus genetic-distance term reduces algebraically for biallelic markers to the squared difference in genotype dosage – confirmed, not just derived, by sourcing the reference implementation’s own R source directly (
PopGenReport::gd.smouse(), Adamack and Gruber) and comparing outputs on a synthetic fixture, matched to floating-point precision; (2) the r-per-distance-class statistic (classical Gower 1966 double-centering of the squared-distance matrix) matchedPopGenReport::spautocor()’s own source exactly (max absolute difference ~1e-16, machine precision) on the same fixture.PopGenReportitself is not installable as a dependency in every environment (its own package dependencies – raster/terra/gdistance – are unrelated to this specific functionality and failed to build even in the development environment used here), so only the small, self-contained, already-verified pieces are reimplemented, sourced directly for verification rather than requiring the whole package. One real, minor quirk found and deliberately not replicated: the reference’s own permutation shuffles the lower and upper triangles of the genetic-distance matrix independently, which can produce an asymmetric matrix; this package instead permutes individual labels jointly (the standard Mantel-style permutationvegan::mantel()already uses elsewhere in this codebase), which is the more conventional, symmetry-preserving approach.Real quickstart result, and an important, honest caveat investigated before writing it up: several of the shortest distance classes have zero pairs, and most classes show negative or non-significant r. This traces directly to the quickstart dataset’s geography: because its 8 populations use real, documented population-level representative coordinates (not per-individual GPS – true individual locations are never published for de-identified 1000 Genomes samples), 2,720 of 12,720 sample pairs (21%) are at exactly zero geographic distance (same population, or the populations sharing one representative point), and the smallest nonzero distance is 3,503 km – a large gap with no pairs at all in the two shortest bins under the reference method’s standard open-at-zero binning convention (confirmed directly from the geographic-distance matrix, not assumed). This means the correlogram mostly resolves population-site-to-site differences rather than the fine-grained, continuous individual-level spatial autocorrelation the method was originally designed to detect, and the real quickstart values should be read with that resolution limit in mind, not as evidence about within-population spatial genetic structure. New
spatial_autocorrelation_module_spec(),references = "Smouse and Peakall 1999". Refreshed the public API baseline (637 -> 638 entries). No public API changes beyond the new module-spec export. -
Added Jost’s (2008) D, an alternative population-differentiation measure to FST, found by the same standard-toolkit audit that previously surfaced Nm. FST is mathematically constrained by within-population heterozygosity – a highly polymorphic marker set can never show a high FST even under strong true differentiation – and Jost’s D (“GST and its relatives do not measure differentiation”, Molecular Ecology) was specifically designed to avoid that constraint, so it directly complements rather than duplicates FST’s existing role. New
global_jost_dcolumn on17_global_FST.tsv,jost_d/jost_d_n_snpscolumns on18_pairwise_FST.tsv, and a new19b_pairwise_jost_d_matrix.tsv, matching the existing FST table conventions; no new figure. Reusescompute_diversity()’s already-computed per-population, per-locus allele frequencies (context$diversity_full$locus) rather than a new per-locus computation –fst_module_spec()now declaresrequires = "diversity"to guarantee that data exists before the module runs (previously undeclared, relying only on incidental registry-list ordering, which the scheduler’s dependency-wave logic does not actually guarantee once a module needs another module’s context output).Uses the Nei and Chesser (1983) bias-corrected Hs/Ht estimators, matching the standard reference R implementation (
mmod::D_Jost(), Winter 2012) exactly. Verified two ways before shipping, not just formula-derived by hand: (1) a small, fully hand-computable two-population example (p = 0.2 and 0.8, n = 10 each) pinned to 10 decimal places; (2)mmod(not a dependency of this package, installed only for this verification) run on a synthetic four-population fixture with unequal, non-round sample sizes (15/22/9/18) for both the global and a pairwise (two-population) case – this package’s own implementation matchedmmod::D_Jost()’s output to floating-point precision (max absolute difference ~2e-16) in every case tested. The pairwise-D result is pinned intest-jost-d.Ras a literal derived from that real cross-check, not re-installingmmodat test time.Real quickstart result: global Jost’s D = 0.0387 (versus global FST = 0.0915 – Jost’s D is numerically smaller than FST here, an expected, well-documented property of the two measures, not a discrepancy). Pairwise D correlates strongly with pairwise FST (r = 0.998) but is not a strict re-ranking of it: FST’s closest pair is ITU-STU (FST = 0.00515) with LWK-YRI a close second (FST = 0.00523), while Jost’s D reverses that order (LWK-YRI closest at D = 0.00196, ITU-STU second at D = 0.00211) – both pairs are near-ties on both measures, so this is presented honestly as the two statistics agreeing overall while not being interchangeable in fine detail, not as a contradiction. PNG-diffed against the immediately-prior bottleneck reference run: zero figure differences (no new figure); only the two FST tables (new columns) and the new
19bmatrix table changed as intended; the ADMIXTURE K=3 cross-validation-error table differs by 0.00002 (5th decimal place) and the29_fastStructure_runs.tsvdiff is identical after path normalization – both the same already-established run-to-run floating-point/path-string noise confirmed benign in every prior round this cycle, not a regression. Committed example PDF unchanged in page count (140 pages, no new figure). No public API changes beyondfst_module_spec()’sreferencesfield gaining “Jost 2008” and itsrequiresfield now correctly declaring “diversity”. Fixed a real, user-reported rendering bug in the new
48_site_frequency_spectrumfigure (plot_bottleneck(),R/bottleneck.R): the subtitle text was long enough (175 characters, combining the practical description and the method citation into onepaste()d string) to overflow the plot width, and ggplot2 does not wrap subtitle text automatically – it was silently clipped mid-sentence in both the wiki and the committed example PDF. Fixed by splitting it into a short subtitle (the practical description) and a caption (the interpretation note plus the Luikart and Cornuet 1998 citation), matching the subtitle/caption split this codebase’splot_ibd()already uses for the same reason. Verified visually by re-rendering the figure from the exact already-cachedanalysis_results.rdsof the reference run that shipped it (guaranteeing identical underlying data, only the label layout changed) and confirming both lines now render in full. Regenerated the committed example PDF from that same cached results object (render_report(), unchanged page count, 140 pages) rather than re-running the whole pipeline, since no analysis output changed. No public API changes.-
Added two more genuine analysis gaps found by the same standard-toolkit audit: Kimura and Crow’s (1964) effective number of alleles (Ae) alongside the existing diversity metrics, and a folded site frequency spectrum with the mode-shift bottleneck-detection test (Luikart and Cornuet 1998).
Effective number of alleles (Ae).
compute_diversity()(R/diversity.R) now computesAe = 1 / (1 - He_unbiased)per locus (a neweffective_allelescolumn on10_population_locus_diversity.tsv) and its per-population mean (a newmean_effective_allelescolumn on09_population_diversity.tsv, averaging the per-locus values rather than transforming the mean He, matching GenAlEx’s convention – these are not interchangeable by Jensen’s inequality). No new table, no new figure – a small, low-risk addition alongside the existing observed/expected heterozygosity and allelic richness columns.He_unbiased’s small-sample bias correction can legitimately push it to or past 1 for very small per-population samples, in which caseAe = Infis reported rather than a fabricated finite number (matching the Nm/Ne(LD) convention); this is a real, expected property of the unbiased He estimator, not a bug – caught while writing the validator: an initialeffective_alleles <= 2bound (mirroring allelic richness’s genuine combinatorial ceiling) is actually not a valid constraint for Ae, since it is a bias-corrected continuous estimator whose point estimate can exceed the biallelic true-parameter bound under small-sample sampling noise (the same class of behavior already accepted for W&C84 FST going slightly negative elsewhere in this codebase); the validator checksAe >= 1instead, the one bound that always holds. Verified withtest-allelic-richness.R, reusing its existing hand-computed fixture: exact per-locus Ae values pinned by hand, a dedicated single-heterozygous-call fixture confirming theAe = Infedge case, and the corrected validator bound. Real quickstart result:mean_effective_allelesranges 1.44-1.51 across the 8 populations. No public API changes.Site frequency spectrum and mode-shift bottleneck screen. New
bottleneckmodule (R/bottleneck.R, on by default viaanalyses.bottleneck, needs population metadata likediversity/ne_ldbut works with a single population – unlikepopulation_tree/population_assignment, this is a within-population test, not a between-population comparison) bins each population’s segregating loci by folded minor allele frequency (analyses.bottleneck_n_bins, default 10 classes spanning 0-0.5) into a new48_site_frequency_spectrum.tsvand figure, then applies the mode-shift test (Luikart and Cornuet 1998): under mutation-drift equilibrium the lowest frequency class is expected to hold the most segregating loci (an “L-shaped” spectrum); a mode shifted to a higher class is a possible recent-bottleneck signature, reported per population on a new49_bottleneck_mode_shift.tsv. Deliberately implements the qualitative screening version of the test (as the BOTTLENECK software also offers), not a p-value – explicitly documented as a signal worth investigating further, not a confirmed bottleneck. Unlike the classic heterozygosity-excess bottleneck test, mode-shift makes no assumption about the locus mutation model (infinite-alleles vs. stepwise), so it is a clean fit for biallelic SNP data, unlike the heterozygosity-excess test. Reusescompute_diversity()’s already-computed per-locusmaf/polymorphiccolumns viacontext$diversity_full(requires = "diversity") – no new per-locus computation. Newbottleneck_module_spec(),references = "Luikart and Cornuet 1998". Verified withtest-bottleneck.R: hand-computed binning and mode-detection for both the expected (mode at the lowest class) and mode-shifted cases, the monomorphic-locus exclusion (matching standard SFS convention – only segregating sites contribute), the right-closed class-boundary behavior, the empty-population edge case, the figure, and the validator.Real quickstart result, and an important, honest caveat: with this package’s default
qc.maf = 0.05filter (applied globally, pooled across all populations, before this or any other module runs), all 8 populations showmode_shifted = TRUEin the shipped result. Investigated before writing this up rather than reporting it uncritically: re-running the same analysis against the same GDS with the MAF filter disabled (qc.maf = 0) shows only 3 of 8 populations (ITU, STU, YRI) are genuinely mode-shifted; the other 5 (CHB, GBR, LWK, PEL, PUR) correctly show the expected unshifted, lowest-class mode once the filter is removed. This confirms the default MAF filter – which systematically removes population-rare variants before this test ever sees the data – is a real, general source of artifactual mode-shift signals for the mode-shift test (and, more broadly, for any SFS-based method) on any MAF-filtered marker set, not a demographic finding specific to this dataset. This is the same general class of caveat already documented for Tajima’s D’s own MAF-filtering bias; a user investigating a genuinely suspected bottleneck should rerun with a low or zeroqc.mafto get an unbiased SFS. Documented honestly in the wiki/vignette alongside the shipped (default-config) result rather than presenting all-populations-bottlenecked as a real finding.Both features were verified together against one real ancestry-enabled reference pipeline run (the same one-off config
docs/examples/README.mddocuments). Refreshed the public API baseline (636 -> 637 entries, the newbottleneck_module_specexport). PNG-diffed against the immediately-prior Nm/population-assignment reference run: only the new48_site_frequency_spectrumfigure and09_population_diversity.tsv/10_population_locus_diversity.tsv(the new Ae columns) changed as intended; the ADMIXTURE K=3 cross-validation-error table differs by 0.00003 (5th decimal place) and the29_fastStructure_runs.tsvdiff is identical after path normalization – both the same already-established run-to-run floating-point/path-string noise confirmed benign in every prior round this cycle, not a regression. Committed example PDF now 140 pages. No public API changes beyond the new module-spec export. -
Added two genuine analysis gaps found by auditing the package against a standard population-genomics toolkit (the same audit method that previously surfaced kinship, ROH, genome scans, LD decay, Ne(LD), and Nei’s distance): Wright’s (1931) island-model gene-flow estimate (Nm) alongside FST, and a frequency-based population self-assignment test.
Nm (gene flow) from FST.
run_fst()(R/fst.R) now computesNm = (1 - FST) / (4 * FST)(a newfst_to_nm()helper) alongside every FST estimate it already produces: a newglobal_nmcolumn on17_global_FST.tsvand a newnmcolumn on18_pairwise_FST.tsv, no new table files, no new figure (the existing10_pairwise_FSTheatmap and its underlying FST values are unchanged; Nm is a direct, monotonic transform of FST already shown there).FST <= 0(no detectable differentiation, including small-sample W&C84 negative noise) reportsNm = Inf– no evidence of restricted gene flow, not a fabricated finite number, matching this codebase’s established convention forne_ld()’s undetectable-drift-signal case.fst_module_spec()’sreferencesnow cites Wright 1931 alongside Weir and Cockerham 1984. Verified withtest-fst-nm.R: the formula pinned against hand values (FST = 0.05 -> Nm = 4.75), the zero/negative-FST -> Inf branch, NA propagation, and a real end-to-end check thatrun_fst()’sglobal_nm/nmmatchfst_to_nm()applied to its ownglobal/long$fst. Real quickstart result: global FST = 0.0915 -> Nm = 2.48; the closest pairwise population pair by FST is ITU-STU (FST = 0.0052, Nm = 48.3) and the second-closest is LWK-YRI (FST = 0.0052, Nm = 47.6) – both already known from this package’s own Nei’s-distance work as the two most genetically similar pairs in this dataset, an independent, cross-method confirmation. No public API changes.Population assignment test. New
population_assignmentmodule (R/population_assignment.R, on by default viaanalyses.population_assignment, needs population metadata and at least two populations likepopulation_tree): a frequency-based self-assignment test (Paetkau et al. 1995, 2004) that scores each sample’s leave-one-out genotype log-likelihood (Rannala and Mountain 1997’s bias correction – a sample’s own genotype is excluded from its own recorded population’s allele-frequency estimate before scoring against it) under every population’s allele frequencies (assuming Hardy-Weinberg equilibrium), then assigns it to whichever population maximizes that likelihood. A sample whose assigned population differs from its recorded metadata label is a candidate migrant or a metadata error – the same “does the data match the metadata” question kinship and sex_check already ask, now applied to population identity. Uses the LD-pruned marker set (like kinship/PCA/IBS), not the full QC-passing set diversity/HWE use: the per-locus likelihoods are multiplied together assuming independence across loci, which linked markers would violate, inflating apparent assignment confidence. A population fixed for the other allele at a locus is corrected to the standard1/(2n)minimum-observable-frequency (Paetkau et al. 2004’s convention, also GenAlEx’s default) rather than a hard zero, which would let one locus veto that population outright with a log-likelihood of-Inf; a locus with zero calls in a candidate population is excluded from that population’s score entirely, the same locus-exclusion conventionpopulation_tree.Ralready uses for Nei’s distance.New
47_population_assignment.tsv(per-samplerecorded_population/assigned_population/mismatch/log_likelihood/likelihood_ratio/posterior_probability/n_loci_used) and a47_population_assignmentconfusion-matrix heatmap figure (recorded population by row, assigned population by column, matching the existing pairwise-FST heatmap’s visual style). Newpopulation_assignment_module_spec(),references = c("Paetkau et al. 1995", "Paetkau et al. 2004", "Rannala and Mountain 1997"). Verified withtest-population-assignment.R: an exact hand-computed fixture (two populations, one locus, one deliberately mislabeled sample per population) pinning the leave-one-out log-likelihood values and the resulting mismatch flags exactly; the zero-calls-locus exclusion; the fewer-than-two-populations empty result; a larger synthetic 3-population fixture with known, well-separated allele frequencies recovering >90% correct self-assignment; the figure; the validator; and registry wiring.Real quickstart result: 106/160 samples (66.25%) correctly self-assign; the 54 mismatches are heavily concentrated in exactly the two most genetically similar population pairs identified above by FST/Nm – 19 of 54 are ITU/STU cross-assignments and 12 of 54 are LWK/YRI cross-assignments (31/54, 57%), with a 357-SNP marker panel genuinely unable to cleanly separate these closely related population pairs, not a defect in the method. This is a real, textbook population-genetics result and an independent cross-validation of the same closely-related-population-pairs finding from the FST/Nm and Nei’s-distance work, using an entirely different statistical approach (per-sample genotype likelihood rather than aggregate allele-frequency distance).
Both features were verified together against one real ancestry-enabled reference pipeline run (the same one-off config
docs/examples/README.mddocuments). Refreshed the public API baseline (635 -> 636 entries, the newpopulation_assignment_module_specexport). Running the officialroxygen2::roxygenise(roclets = c("namespace","rd"))+tools/reconcile-rd-aliases.py --writesequence for the new export also surfaced and fixed one small, unrelated pre-existing documentation gap:pca_metadata_colour_columns()(R/ordination.R, added earlier this cycle) never had a generatedman/page committed; it does now. PNG-diffed against the immediately-prior partial-Mantel reference run: only the new47_population_assignmentfigure is added; the ADMIXTURE K=3 cross-validation-error table differs by 0.00002 (5th decimal place) and the29_fastStructure_runs.tsvdiff is identical after path normalization – both the same already-established run-to-run floating-point/path-string noise confirmed benign in every prior round this cycle, not a regression. Committed example PDF now 139 pages. No public API changes beyond the new module-spec export. -
Parallelized
render_report()’s HTML and PDF rendering when both formats are requested (the default): previouslyvapply()rendered them strictly sequentially, so a full report always paid the HTML render cost followed by the (typically slower, LaTeX-compiled) PDF render cost in series. On non-Windows platforms, both formats now render concurrently viaparallel::mclapply()(fork-based, already a hard dependency viaparallel) – roughly halving report wall-clock time for the common case. Windows has no fork support, sorender_report_formats()(the new small dispatch helperrender_report()now delegates to) explicitly detects.Platform$OS.type == "windows"and always falls back to the original sequential path there; a single requested format also always takes the sequential path (nothing to parallelize).This required a real correctness fix to make concurrent rendering safe, not just faster:
render_standard_report_format()previously letrmarkdown::render()default its knitintermediates_dirto the same directory as the shared input template (system.file(...), one physical file for both formats) – fine sequentially, but two renders racing on the same intermediate filenames concurrently would corrupt each other. Fixed by giving each format its owntempfile()-backedintermediates_dir, cleaned up viaon.exit(); this fix is unconditionally in effect (both the sequential and parallel paths benefit), not gated on whether parallel rendering actually runs.mclapply()does not propagate a worker’s error as a normal R condition the way sequentiallapply()/vapply()do – a failure is returned in-place as a"try-error"-classed value instead of being thrown.render_report_formats()explicitly detects this and re-stop()s with the original error message, preserving the exact error-throwing behaviorrender_report()’s callers (including its own tests) already depend on. Verified with a real end-to-endrender_report(..., formats = c("html", "pdf"))render (both real files produced, correctly named/keyed) plus unit coverage of the new dispatch helper directly: single-format sequential dispatch, multi-format parallel dispatch with correct naming/ordering, and error re-signalling from a failing worker. No public API changes (render_report_formats()is internal;render_report()’s signature, return shape, and default behavior are unchanged). -
Added a partial Mantel test to the
ibdmodule, controlling the existing Mantel test’s genetic-vs-geographic distance correlation for population identity – the standard landscape-genetics follow-up question of whether an isolation-by-distance signal holds once population membership is accounted for, or is purely an artifact of population clustering. Usesvegan::mantel.partial()(Smouse, Long, and Sokal 1986), reusingvegan::mantel()’s already-hard dependency – no new dependency.run_mantel_ibd()builds a same/different (0/1) population dissimilarity matrix from the existingpopulationmetadata column (already joined for the plain Mantel test) and runs it alongside the existing analysis whenever at least two populations are present in the geographic-coordinate-passing sample set; with fewer than two populations, or nopopulationcolumn at all,partial_mantel/partial_mantel_r/partial_mantel_pareNULL/NA, matching this module’s existing “skip transparently, never fabricate” convention.An initial draft comment claimed the partial Mantel result would be “nearly degenerate” here, reasoning that a same/different population indicator built from population-level representative coordinates should collapse almost entirely onto geographic distance. Caught before shipping: the binary same/different indicator only fully collapses onto (is perfectly collinear with) geographic distance when there are exactly two populations (a single between-population distance value); with three or more, real, testable residual variance remains even when coordinates are population-level rather than individual GPS – confirmed with two synthetic multi-population smoke tests before finalizing the shipped code comment. This package’s own quickstart dataset (8 populations) is nowhere near the degenerate case.
plot_ibd()’s subtitle now reports both results when the partial Mantel test ran ("Mantel r = ..., p = ... (partial, controlling for population: r = ..., p = ...)"), falling back to the plain-Mantel-only subtitle otherwise; no change to the figure’s points/curve.ibd_module_spec()’sreferencesnow cites Smouse, Long, and Sokal 1986 alongside Mantel 1967 and Rousset 1997. Verified with newtest-mantel.Rcases: a synthetic two-population fixture with known geographic structure recovers a finite, in-range partial Mantel r/p; a no-population-column fixture and a single-population fixture both correctly leave the partial resultNA; andplot_ibd()’s subtitle branching is covered for both the with- and without-partial-result cases.Real quickstart result: plain Mantel r = 0.3129 (p = 0.001, unchanged from when real coordinates were first added), partial Mantel controlling for population identity r = 0.1627 (p = 0.001) – the isolation-by-distance signal weakens considerably but remains real and significant once population membership is controlled for, exactly the kind of nuanced, honest result this test exists to surface (some but not all of the raw Mantel correlation is attributable to population clustering rather than continuous geographic distance). PNG-diffed against the immediately-prior Tajima’s D reference run: only
12_isolation_by_distance.png(the new subtitle text) and25_Mantel_IBD_summary.tsv(the two new columns) changed; the29_fastStructure_runs.tsvdiff is the same already-established run-to-run path-string/floating-point artifact seen in every prior round this cycle (identical after path normalization), not a regression. Committed example PDF unchanged in page count. No public API changes. -
Added windowed Tajima’s D (Tajima 1989) to the
genome_scanmodule – the next gap found by the same standard-toolkit audit, and a natural extension of the existing windowed-FST/windowed-diversity engine:run_genome_scan_diversity()gainedsegregating_sites/tajima_dcolumns computed on the same window grid, reusing the already-computed per-locuspolymorphicflag andunbiased_expected_heterozygosity(the same units theta_W is estimated in, so no new per-locus computation is needed).The formula (
D = (pi - S/a1) / sqrt(e1*S + e2*S*(S-1)), with the standard Tajima (1989) correction constants a1/a2/b1/b2/c1/c2/e1/e2) was independently verified against a published worked example before shipping: n=10, S=16, pi=3.888889 reproduces D=-1.446172 exactly (matched to 6 decimal places, both the final statistic and every intermediate constant). Haploid sample size (gene copies = 2 x population sample count) is held constant per population per window, matching vcftools’ own--TajimaDsimplifying assumption – the classical 1989 formula does not model per-site missingness; a heavier generalization (e.g. ANGSD’s numerical-integration approach) is out of scope here.New
26b_genome_scan_tajima_d_manhattanfigure; no new config keys (reusesanalyses.genome_scan_window_bp/_step_bp/_min_snps).genome_scan_module_spec()’sreferencesfield now cites both Weir and Cockerham 1984 and Tajima 1989. Verified withtajima_d_statistic()/tajima_d_constants()unit tests pinning the published worked example, edge-case coverage (zero segregating sites, unknown sample size, degenerate variance), and a hand-computedrun_genome_scan_diversity()window recovery. No public API changes.Real quickstart result: 152 of 160 windows have a defined Tajima’s D, almost entirely positive (median 1.34, range -1.71 to 3.60). This is flagged honestly in the wiki/vignette as an expected artifact of this pipeline’s own QC rather than necessarily a real signal: the default
qc.maf(0.05) filter removes rare variants before this scan runs, and removing rare variants is well known to bias Tajima’s D upward (S drops much more than pi does) – a real, general caveat for interpreting Tajima’s D computed from any MAF-filtered marker set, not specific to this dataset. PNG-diffed against the immediately-prior population-tree reference run: only the one new figure and the40_genome_scan_diversity.tsvtable changed; the ADMIXTURE/fastStructure diffs are the same already-established run-to-run floating-point/path-string noise, not a regression. Committed example PDF now 138 pages. -
Added Nei’s (1972) standard genetic distance between populations and a population-level neighbour-joining tree – another gap found by the same standard-toolkit audit, this time surfaced by inspecting the existing
treemodule closely: it builds its NJ tree from IBS distance between individuals, not from allele-frequency-based distance between populations, a genuinely different, standard population-genomics deliverable (the kind of “relationships among populations” tree found in most population-genetics papers, distinct from a sample-relationship tree).New
population_treemodule (R/population_tree.R, on by default viaanalyses.population_tree, requires population metadata anddiversityto have run) computes Nei’s D viaadegenet::dist.genpop()(method 1, its documented default = Nei’s standard distance) – already a hard dependency (used elsewhere for DAPC), so no new dependency. Thegenpopobject is built directly fromcompute_diversity()’s already-computed per-population allele counts (nogenind/df2genind()intermediate needed), excluding any locus where a population has zero calls (allele frequency undefined there). New46_population_genetic_distance.tsv(a symmetric population x population matrix, matching the FST/kinship/IBS matrix-table convention) and apopulation_Nei_neighbor_joining.nwkNewick file (viaape::nj(), requires >= 3 populations) written alongside the existing individual-level tree’s own Newick output – no new figure, matching that module’s own existing Newick-only scope (this codebase has no tree-plotting infrastructure for either tree).Verified two ways before shipping: (1) an independent hand calculation of Nei’s D (I = Jxy / sqrt(Jx*Jy), D = -ln(I), computed by hand from raw allele counts) matched
adegenet::dist.genpop()’s output to 7 decimal places, confirming both the formula and adegenet’s implementation agree; (2) a synthetic 4-population fixture with population allele frequencies of 0.2/0.4/0.6/0.8 recovered the correct monotonic ordering (distance grows with frequency gap). The real quickstart result is a strong, independent correctness signal on top of both: LWK-YRI (Luhya, Kenya and Yoruba, Nigeria, both African) is the single closest pair at D=0.0112, while the largest distances are all African-vs-non-African population pairs (up to D=0.091) – exactly the expected continental-structure pattern, not something asserted in a test but a real, biologically sensible result from real chr22 data.Caught and fixed a real bug during the reference-pipeline verification run (not by the local test suite, which uses
pkgload::load_all()):methods::new("genpop", ...)failed with"genpop" is not a defined classwhen the module actually ran via a properlylibrary()-loaded, installed package –pkgload::load_all()had been masking this by making the S4 class visible in a way a real install does not. Fixed with#' @importClassesFrom adegenet genpop(R/package-check.R, this codebase’s existing central imports-declaration file), regeneratingNAMESPACE’simportClassesFrom(adegenet, genpop)entry. A reminder of why this project always regenerates the reference pipeline run through a real installed package before shipping, not just the localload_all()-based test suite – this is the second time this exact session has caught something the faster dev-loop missed (the first being thesnpgdsGetGeno()/snpgdsSampMissRate()ordering findings, caught by deliberate empirical probing rather than incidentally like this one).population_treewas added tometadata_capabilities.R’spopulation_modulesandmulti_population_modules(needs population metadata and at least two populations, like FST/AMOVA/genome_scan) and is explicitly disabled intemplate_config()alongside them. Newtest-population-tree.Rcovers the hand-verified distance value, the monotonic-ordering recovery, the zero-call-locus exclusion, the fewer-than-two-populations empty result, theape::nj()>=3-tip guard, the validator, and registry wiring. Refreshed the public API baseline again (634 -> 635 entries, the newpopulation_tree_module_specexport). PNG-diffed against the immediately-prior Ne(LD) reference run: zero figure differences, one new table (46_population_genetic_distance.tsv), and the only other table diff traced to the same already-known ADMIXTURE/fastStructure run-directory-path string artifact (values identical after path normalization) rather than a real regression. Committed example PDF remains 137 pages. No public API changes beyond the new module-spec/output exports. -
Added LD-based contemporary effective population size (Ne), the next gap found by the same standard-toolkit audit that surfaced kinship, ROH, genome scans, LD decay, and allelic richness – and a natural extension of the LD-decay module added just above, though it needs an unrelated pair-selection strategy (unlinked pairs, not physically close ones).
New
ne_ldmodule (R/ne_ld.R, on by default viaanalyses.ne_ld, per population likediversity) implements the Waples (2006)/Waples & Do (2008) “LDNe” method: per-population Ne from mean r-squared between cross-chromosome SNP pairs (SNPRelate::snpgdsLDMat(..., slide = -1), the already-verified full-pairwise-matrix mode, restricted to pairs on different chromosomes – the opposite pair selection fromld_decay, which specifically wants physically close pairs), bias-corrected for sampling noise, then converted to Ne viaNe = (1/3 + sqrt(1/9 - 2.76*r2_drift)) / (2*r2_drift). The bias correction itself is piecewise in the harmonic-mean sample size S (E(r2) = 0.0018 + 0.907/S + 4.44/S^2for S <= 30,1/Sfor S > 30) – both formulas were independently cross-checked against multiple secondary sources before shipping (not just recalled), not just algebra-consistency-checked against each other. Reuses this package’s existing QC MAF filter (default 0.05) to satisfy the bias correction’s own documented rare-allele-exclusion assumption, rather than adding a redundantPcritparameter. r-squared attributable to drift at or below the sampling-noise floor is reported asNe = Inf(no detectable signal, not a fabricated finite number, matching NeEstimator’s own convention); an implied-Ne below the formula’s valid domain is reported asNAwith an explicitbelow_formula_domainstatus, never a bogus complex/negative root.New
45_Ne_LD.tsv(per-populationn_samples/n_snps/n_chromosomes/n_pairs/harmonic_mean_n/mean_r2/mean_r2_drift/ne/ne_status) and a45_Ne_LDfigure (log-scale points by population, omitting non-finite estimates). Newanalyses.ne_ld_max_snpsconfig key (default 2000, a deterministic subsample cap bounding the O(n^2) full pairwise-LD computation for large marker sets – SNPRelate’s own C implementation makes the quickstart-scale full matrix trivially fast, verified directly: under 0.2 seconds for the real chr22 marker set).ne_ldrequires population metadata likediversity/allelic_richness(added tometadata_capabilities.R’spopulation_modules) and is explicitly disabled intemplate_config()alongside the other population-requiring analyses, matching that config’s own documented intent.Deliberately did not implement a jackknife confidence interval in this pass, unlike this package’s other estimators with bootstrap/jackknife CIs (FST, diversity): the point-estimate formula and its bias correction were independently verified against multiple sources with high confidence, but a precise reproduction of NeEstimator’s own parametric jackknife CI construction could not be independently confirmed from available sources, and shipping a plausible-looking but unverified CI formula was judged a worse outcome than an honestly incomplete v1 – tracked as a real, documented limitation rather than a silently wrong number.
Verified with a real Wright-Fisher forward simulation with a known true Ne (not just formula self-consistency): random mating, independent parental-allele inheritance per locus across generations (the only way to generate genotypes with real drift-induced multilocus LD – an earlier attempt that sampled each locus’s genotypes independently from its own drifted allele frequency produced zero real cross-locus correlation and validated nothing, a real modeling mistake caught before writing the shipped test). True N=50 recovered as Ne in [38, 48] across 3 replicate seeds; true N=200 recovered as Ne in [175, 202] – genuine order-of-magnitude-plus recovery from a single realization, consistent with the literature’s expectations for this estimator’s real per-realization variance.
test-ne-ld.Ruses a smaller, fast version of the same simulation (runs in well under a second) plus direct unit coverage of the bias-correction formula, the Inf/below-domain edge cases, insufficient-data statuses (too few samples, fewer than two chromosomes), multi-population ordering, the validator, registry wiring, and the figure’s finite/non-finite branching.The quickstart dataset’s retained autosomal marker set is chr22-only (a single chromosome, by design – the same reason chromosome X/Y are reserved for
sex_checkrather than pooled into other modules), so every population correctly reportsne_status = "fewer_than_two_chromosomes"andne = NAon the real reference run – an honest, expected skip, not a bug, exactly the same “this demo dataset cannot exercise a cross-chromosome analysis” situation already documented for Mantel/IBD before real coordinates were added and sex_check before chromosome X/Y were added. Documented in the wiki/vignette as a text-only section (no fabricated demo figure). Refreshed the public API baseline again (633 -> 634 entries, the newne_ld_module_specexport). PNG-diffed against the immediately-prior LD-decay/allelic-richness reference run: zero figure differences beyond the expected emptyne_ldresult (no45_Ne_LD.png, correctly omitted since every population’s estimate is non-finite); confirmed the LD-pruned marker set (357 SNPs) is byte-identical, and traced tiny (5th-6th decimal place) ADMIXTURE Q-matrix/CV-error differences to ADMIXTURE’s own run-to-run multithreaded floating-point noise, not a real regression – same inputs, same K, same order-of-magnitude values. Committed example PDF now 137 pages. No public API changes beyond the new module-spec/output exports. -
Added two genuine analysis gaps found by auditing the package against a standard population-genomics toolkit (the same audit method that previously surfaced kinship, ROH, and genome scans): linkage-disequilibrium decay by physical distance, and rarefaction-corrected allelic richness.
Linkage disequilibrium decay. New
ld_decaymodule (R/ld_decay.R, on by default viaanalyses.ld_decay) reports mean r-squared by physical distance bin, the standard “how far does LD extend” summary shown in nearly every population-genomics paper – previously this package only ever used LD as a pruning filter (ld_prune_exact()), never reported it as a result. UsesSNPRelate::snpgdsLDMat(), already a hardImportsdependency (method = "r", squared to r-squared) – no new dependency. Computed on the QC-passing, unpruned SNP set (context$qc_snps, the same setgenome_scan/diversity/fstalready use), deliberately never the LD-pruned set: pruning selects markers specifically to have low pairwise LD, so decay computed on it would be a systematic, meaningless underestimate.snpgdsLDMat()’sslideparameter bands adjacency by SNP index, not physical distance, so pairs are explicitly re-sorted by(chromosome, position)before the call and cross-chromosome pairs at block boundaries are discarded by an explicit chromosome-equality check – a real limitation is that pairs beyond the SNP-indexslidewindow are never computed even if withinanalyses.ld_decay_max_distance_bp, documented rather than silently truncated (newanalyses.ld_decay_max_distance_bp(default 500000),ld_decay_bin_bp(default 5000), andld_decay_slide(default 100) config keys, all validated). New43_LD_decay.tsvand43_LD_decayfigure. Verified with a deterministic synthetic fixture (three widely-separated 10-SNP haplotype blocks, identical dosage within a block giving exact r = 1, independent draws across blocks giving r near 0) confirming near-block bins recover mean r-squared = 1.0 exactly and far-block bins recover near 0, plusmax_distance_bptruncation, the empty/single-SNP edge case, and registry/validator coverage (test-ld-decay.R). Real result on the quickstart dataset: mean r-squared falls from 0.238 at the shortest bin (0-5 kb) to under 0.04 by ~50 kb, 191,850 SNP pairs across 49 bins, a genuine, biologically sensible decay curve, not a flat/noisy one.Allelic richness. Rarefaction-corrected allelic richness (
hierfstat::allelic.richness(), Suggests-only – optional, exactly like the LEA/ADMIXTURE/fastStructure ancestry backends, so it skips transparently with aWARNINGanalysis message rather than erroring when hierfstat is not installed) is now computed insidecompute_diversity()and added as a newallelic_richnesscolumn on the existing10_population_locus_diversity.tsvand a newmean_allelic_richnesssummary column on09_population_diversity.tsv– no new table files, exactly matching how HWE p-values and private-allele flags were added earlier this cycle. hierfstat’s genotype encoding (each allele occupies one digit: 0/1/2 dosage becomes 11/12/22) is shared via a newhierfstat_encode_genotype()helper (R/utils.R), refactored out of the existing FST scientific-validation reference implementation rather than duplicated. Matched back to loci/populations strictly by name (hierfstat preserves the exactcolnames()passed in asAr’s rownames, verified empirically), never positionally – this codebase’s established discipline for every SNPRelate/hierfstat return whose order is not guaranteed to match the request (see the chromosome Y call-rate entry below). New44_allelic_richnessfigure (boxplot by population). Verified with a hand-computed synthetic fixture (reusing the private-allele/HWE fixture’s known genotypes) asserting exact per-locus richness values (2 when both alleles are present in a population, 1 when monomorphic within it,NAwhen the population has no calls at all), the hierfstat-unavailable skip path (mocked vialocal_mocked_bindings(requireNamespace = ...)), and a newvalidate_diversity_result()bound (richness cannot exceed 2 for a biallelic SNP) (test-allelic-richness.R). Real result: 1969 chr22 SNPs, rarefied to 40 allele copies (the smallest population’s 20 samples), mean allelic richness ranges narrowly from 1.82 to 1.94 across the 8 populations – expected and unremarkable since every quickstart population has exactly 20 samples; the metric earns its keep on real user datasets with unbalanced population sizes, where richness and raw heterozygosity can disagree.A previously-latent
SNPRelate::snpgdsGetGeno()finding, investigated but confirmed not a live bug: it silently returns genotypes in the GDS’s own native storage order, ignoring the requestedsample.id/snp.idorder entirely (same behavior class assnpgdsSampMissRate()’s ordering, found during the chromosome Y work below) – verified empirically with an out-of-order syntheticsnp.idsequence.compute_diversity()’s existing positional use of itsgenomatrix againstsnp_idsturns out to be safe only because every caller (harmonize_samples(),compute_diversity()) always passes order-preserving subsequences ofids$sample/ids$snp, never a shuffled order – documented with a comment at the call site rather than left as an unexplained, fragile assumption.Also fixed two real, pre-existing documentation/tooling gaps found while shipping this:
inst/example_config.ymlwas missing thesex_check_y_*keys from the chromosome Y work below entirely (now added alongside the newld_decay_*keys); and the reportskeleton.Rmd’s sex-check section still said “from X-chromosome heterozygosity” only, and its row-priority sort omitteddiscordantfrom the priority list entirely (silently sorting discordant rows after plainmatchrows, the opposite of the intended “most concerning first” ordering) – both stale since the chromosome Y signal shipped and now fixed. Refreshed the public API baseline (inst/api-contract/public-api-baseline.tsv.gz.b64/.dcf, 614 -> 633 entries): it had not been refreshed since beforekinship_module_spec,roh_module_spec,sex_check_module_spec,genome_scan_module_spec,quickstart_dataset_paths, and several canonical-ancestry evidence functions were added earlier this cycle – all purely additive (advisory, not CI-blocking per policy), now reconciled alongside the newld_decay_module_specexport.Regenerated the reference pipeline run and confirmed, via a PNG-only diff against the immediately-prior chromosome-Y reference run, that the only changed/added figures are the two new ones (
43_LD_decay,44_allelic_richness) – every other figure (PCA, DAPC, kinship, ROH, FST, genome scans, all three ancestry backends, sex_check) is byte-identical, confirming this is a fully additive change with no side effects.docs/examples/chr22-quickstart-report.pdfis now 136 pages; the quickstart VCF itself and its checksum are unchanged (no new raw data, only new derived analyses). No public API changes beyond the two new module-spec/output exports. -
Extended sex_check with a second, complementary signal – Y-chromosome genotype call rate – and added real chromosome Y data to the quickstart dataset to demonstrate it, following the same real-data discipline as the chromosome X work above. Real sex-check pipelines use both signals together, not X alone: males have a Y chromosome to call genotypes from, females do not, giving an almost perfectly binary corroborating signal (verified against real 1000 Genomes data before shipping: females showed exactly 0.0 call rate, males 0.995-1.0, well clear of the new default thresholds, male call rate > 0.5 / female < 0.1). Uses
SNPRelate::snpgdsSampMissRate(), already an SNPRelate dependency – no new dependency, no hand-rolled math, same principle as reusingsnpgdsIndInb()for the X signal.run_sex_check()now computes both signals independently (either alone when only one chromosome is present) and combines them: agreement gives a high-confidence call; when both are available and confidently disagree, the combined call isdiscordant– real, informative evidence of a data problem, deliberately not averaged away. Newanalyses.sex_check_y_chromosome_names(defaultc("Y", "chrY")),analyses.sex_check_y_male_call_rate_threshold(default 0.5),analyses.sex_check_y_female_call_rate_threshold(default 0.1) config keys, all validated.42_sex_check.tsvgainedx_inferred_sex/n_y_snps/y_call_rate/y_inferred_sexcolumns alongside the renamed combinedinferred_sex;27_sex_check_F_by_samplebecomes a 2D X-vs-Y scatter when both signals are available, falling back to the previous 1D per-sample strip plot (now generalized to either signal) when only one chromosome is present. Verified with an extendedtest-sex-check.R(X+Y combined recovery, thediscordantcombination rule including corroboration of an ambiguous X call by a confident Y call, X-only and Y-only fallback, and the 2D/1D figure branching) and a newtest-config.Rcase. No public API changes beyond the module’s already-public output shape, which was never covered by a schema-stability commitment.Chromosome Y source: the whole callable non-PAR region
canonical_1000g_chrY_source()already describes and checksums (~60,505 biallelic SNPs, already a small curated set from the same already-approved Zenodo record – no new external source, no bounding needed given its size). This release is genuinely male-only (1233 samples, chromosome Y has no biological meaning for females): the source VCF’s sample list is a strict subset of the cohort.scripts/derive-quickstart-dataset.Rextracts the 77 selected males’ real genotypes, then pads in the 83 selected females as explicit, correctly-missing genotype columns at the same real sites viabcftools merge -m noneagainst a header-only placeholder VCF – not fabricated data, an honest representation of “no chromosome Y”, exactly what a real chrY VCF including females would show.Concatenating chromosome Y into the same VCF surfaced two further real, more severe pooling bugs than the chromosome X kinship one, both affecting real users with any real whole-genome VCF containing chromosome Y, not just this demo. (1)
harmonize_samples()‘s per-sample missingness QC computed missingness across every SNP in the GDS unconditionally; since chromosome Y is ~100% “missing” for one whole sex by biology, not by data-quality problem, this silently dropped every sample of the unaffected sex out of the entire pipeline before this was fixed (confirmed concretely: all 83 female samples were dropped, leaving only 77 males, and the report’sgenome_scanmodule then hard-errored on “population should not have missing values” once population-level calculations only had one sex’s worth of some populations left). Fixed with a new optionalsnp_idsparameter onharmonize_samples(), restricting its missingness calculation to autosomal markers by default, computed early inrun_pipeline()before any SNP-level QC exists yet. (2)variant_qc()’s per-SNP missingness check likewise failed every chromosome Y SNP out of QC for the same reason (each Y SNP’s real ~52% cross-sex “missingness” is entirely expected, not a quality problem), leaving sex_check with zero chromosome Y markers even after fix (1). Fixed with a newsex_limited_chromosome_namesparameter onvariant_qc()(populated fromanalyses.sex_check_y_chromosome_names, the same list sex_check itself uses, avoiding a duplicate config key) that exempts those chromosomes from the missingness threshold specifically – MAF filtering is completely unaffected, still computed and still able to fail a Y SNP on its own terms. Verified end to end against the real quickstart dataset: all 160 samples now correctly retained, all 60,468 MAF-passing chromosome Y SNPs available to sex_check, and the known duplicate pairs’ kinship values remain exactly as the chromosome X fix left them (0.4525, 0.4459) – both real pooling fixes coexist correctly. Newtest-autosome-filtering.Rcoverage: a synthetic sex-limited-chromosome fixture unit-testing bothvariant_qc()’s exemption andharmonize_samples()’s restriction directly, plus extended real-pipeline assertions (all 160 samples retained, real chromosome Y evidence,discordantpresent andambiguousabsent on the real dataset). No public API changes.A real, illuminating result from combining the two signals on the quickstart dataset: of 160 samples, 155 classify as a confident
matchand 5 asdiscordant– zeroambiguous, zeromismatch. Combining resolved every one of the 40 samples that wereambiguouson chromosome X alone (a single bounded 1Mb region is a noisier marker set than chromosome Y’s near-binary call rate). The 5discordantsamples are the same 5 that showed as spurious chromosome-X-alonemismatches before: all 5 have real chromosome Y call rate of exactly 0 (confidently female, matching their real metadata-reported sex) while chromosome X’s F-statistic gives a spuriousmalereading just above threshold – a real, concrete demonstration of chromosome X small-region noise producing a wrong call that chromosome Y’s cleaner signal catches, exactly the value of combining both signals rather than trusting either alone.Propagated to the docs: regenerated the reference pipeline run (same one-off ancestry-enabled config
docs/examples/README.mddocuments) and confirmed everything downstream of the autosomal LD-pruned marker set (DAPC, all three ancestry backends, kinship, FST, genome scans, AMOVA, Mantel) is unaffected – byte-identical or trivially so, same as the chromosome X propagation above. Only the QC-stage figures reflecting the larger combined raw input (01_MAF,02_variant_missingness,04_SNP_retention) and the sex-check figure itself (now a 2D scatter) changed. Updated the wiki and vignette’s Quality control and Sex check sections with the real combined numbers and the new figure; updatedinst/extdata/quickstart/README.mdwith full chromosome Y provenance and both pooling-bug findings. Committed example PDF unchanged in page count (132 pages: one figure was replaced, not added). No public API changes. -
Added a real bounded chromosome X region to the bundled quickstart dataset, giving the sex-check module (added just above) real data to demonstrate, and along the way found and fixed a genuine correctness gap affecting every ploidy-sensitive module for any real user’s whole-genome VCF, not just this demo. Chromosome X:
X:70000000-71000000(non-PAR; PAR1 ends at 2,699,520, PAR2 starts at 154,931,044, GRCh37), same 1Mb-region convention as chr22, same 160 samples, from the same already-approved Zenodo record (10.5281/zenodo.3359882) chr22 already uses –ALL.chrX.phase3_shapeit2_mvncall_integrated_v1b.20130502.genotypes.vcf.gz, MD5-verified against the record’s declared checksum before use. A real, load-bearing data-preparation finding made this usable at all: this 1000 Genomes release represents males’ non-PAR chromosome X genotypes as genuinely haploid VCFGTfields (a single allele, e.g.0or1), confirmed by direct inspection;SNPRelate::snpgdsVCF2GDS()does not parse a haploidGTfield as “duplicate the observed allele” – empirically, it pads the missing second allele with the ALT allele index, silently turning every male REF-hemizygous call into a false heterozygous dosage and every male ALT-hemizygous call into a homozygous-ALT dosage. Fixed at dataset-derivation time withbcftools +fixploidy -- -f 2(correct here because the extracted region is entirely non-PAR; verified female calls are left untouched).Concatenating the fixed chromosome X region into the same VCF the rest of the pipeline already uses (via
bcftools concat) surfaced a second, much larger finding while verifying the demo: pooling chromosome X markers into the same marker pool that kinship, PCA, DAPC, the ancestry backends, ROH, FST, genome scans, diversity, and AMOVA already used measurably corrupted results – a known real duplicate/MZ-twin pair’s kinship collapsed from 0.4459 to 0.044, and a second real high-kinship pair’s collapsed from 0.4525 to -0.003, purely from adding 174 chromosome X markers to the existing 357 chr22 ones. Root cause: KING-robust kinship’s denominator depends on each sample’s own heterozygosity count, and a hemizygous male has zero heterozygosity on X by construction, so any pair involving a male sample is mathematically degenerate on chromosome-X-only markers – pooling those markers into the same estimate as autosomal ones dilutes/corrupts the result. This is exactly why standard tools (PLINK, KING, GCTA) exclude sex chromosomes from genome-wide kinship/PCA/GRM computations by default, and this package previously had no autosome-only filtering anywhere inrun_pipeline()– meaning any real user submitting an ordinary whole-genome VCF (chromosome X is extremely common in real WGS data) would have silently gotten the same corrupted kinship/PCA/DAPC/ADMIXTURE/ROH/FST/genome-scan/diversity/AMOVA results, with no warning, in the already-released v0.10.0.Fixed with new
qc.autosome_only(defaultTRUE) andqc.non_autosomal_chromosome_names(defaultc("X", "Y", "MT", "M", "chrX", "chrY", "chrM", "chrMT")) config keys.run_pipeline()now computesqc_snps_all(every QC-passing SNP, any chromosome) and a separately-restrictedqc_snps(autosomal only, whenautosome_onlyis on) that feeds LD pruning and every ploidy-sensitive module;sex_checkis the sole, deliberate exception, receiving the unrestrictedqc_snps_alland doing its own chromosome-name-based restriction to X internally, exactly as before.ld_prune_exact()gained an optionalsnp_idscandidate-restriction parameter (SNPRelate::snpgdsLDpruning()’s ownsnp.idargument, not previously used) to implement this without relying on SNPRelate’s ownautosome.onlyheuristic, keeping the same explicit, user-configurable chromosome-name-list conventionsex_check_x_chromosome_namesalready established.run_roh()(which filters its own VCF subset directly viabcftools, not throughqc_snps) gained the same exclusion viabcftools view --targets ^<names>. Verified end to end against the real quickstart dataset: with the fix, both known high-kinship pairs are correctly recovered (0.4525 and 0.4459, identical to their pre-chromosome-X values) whilesex_checkstill sees its full 1,148 chromosome X SNPs unaffected. Also caught and fixed a real, easy-to-reintroduce bug while writing the new config’s YAML:inst/example_config.yml’snon_autosomal_chromosome_nameslist silently corrupted an unquotedYinto the booleanTRUE(YAML 1.1’s expanded boolean-literal set, parsed by theyamlpackage) untilYwas explicitly quoted – pinned with a new regression test that parses the shipped file and asserts no boolean corruption. Newtest-autosome-filtering.R(ld_prune_exact()’s new parameter, config defaults/validation, and a real end-to-end pipeline test against the quickstart dataset confirming both the exclusion and the kinship recovery). No public API changes.A related, independent finding surfaced during this same investigation:
HG03873/HG03998, described throughout this development cycle as a “known real duplicate/MZ-twin pair,” is not one. Their real chromosome X genotypes (not just their metadatasexlabels) showHG03873is genuinely female (real heterozygous chrX calls) andHG03998is genuinely male (real hemizygous calls); MZ twins/duplicates share genetic sex by definition, so despite their real, high chr22-only kinship (0.4525, correctly classifiedduplicate/MZ twinby KING’s own threshold), this pair cannot be the same individual or identical twins. The autosomal kinship signal itself is real, not a computation artifact – but a single small autosomal window is not sufficient evidence for “duplicate” on its own, and this pair is now documented as exactly that cautionary lesson (deliberately kept in the dataset, not removed) rather than as a second confirmed duplicate.NA19331/NA19334(same-population, LWK) remains a fully confirmed real duplicate/MZ twin: chromosome X independently confirms both are genuinely male, with no contradicting evidence. Corrected throughoutwiki/Results-and-Interpretation.md,vignettes/interpreting-results.Rmd,vignettes/quickstart.Rmd,inst/extdata/quickstart/README.md,scripts/derive-quickstart-dataset.R, andtest-quickstart-dataset.R’s comments (the test assertions themselves were already correct – KING really does classify this pairduplicate/MZ twin, and both real recorded sex values are asserted correctly; only the biological interpretation in surrounding prose was wrong). Not retroactively rewritten in this file’s own earlier, now-superseded entries, consistent with treatingNEWS.mdas an append-only record.Propagated to the docs: the reference pipeline run was regenerated against the updated dataset (same one-off ADMIXTURE/fastStructure/sNMF-enabled config
docs/examples/README.mdalready documents). With the autosome-only fix in place, the LD-pruned marker set used by PCA/DAPC/ADMIXTURE/kinship/etc. is back to exactly the original 357 chr22-only SNPs (byte-identical figures confirmed for everything except three real, expected changes:01_MAFand13b_ADMIXTURE_cluster_number_selectionnow reflect the larger combined raw input, and the new27_sex_check_F_by_samplefigure). Added real numbers to the wiki/vignette: chromosome X gives 1,148 QC-passing SNPs (reserved for sex-check, excluded from everything else); of 160 samples, 115 classify as a confidentmatch, 40 asambiguous, and 5 asmismatch. A follow-up check against genome-wide (not just this bounded 1Mb region) chromosome X data for the same 160 samples confirmed the signal is directionally sound throughout the genome – zero genuine mismatches at that scale, and the 5 apparent mismatches seen in the bounded-region demo resolved to a clean match – documented as a real, honest lesson that a single small region is noisier than production-scale sex-checks, not that the demo’s specific calls are wrong. Regenerated the committeddocs/examples/chr22-quickstart-report.pdf(132 pages) and republished the wiki. No public API changes. Added genetic sex-check QC: infers each sample’s sex from X-chromosome heterozygosity and flags mismatches against a supplied
sexmetadata column, the standard upstream check for sample swaps, contamination, or a metadata labeling error (the same category of issue kinship’s duplicate-pair detection already catches for relatedness). UsesSNPRelate::snpgdsIndInb(method = "mom.visscher")(Visscher et al. 2010), already an SNPRelate dependency and already used elsewhere in this package for population-level inbreeding, restricted to X-chromosome SNPs – no new dependency, no hand-rolled statistic. Hemizygous males show excess homozygosity (F close to 1) relative to diploid females (F close to 0); default classification thresholds (male F > 0.8, female F < 0.2) are PLINK’s own documented--check-sexdefaults, verified before shipping against a hand-built synthetic GDS with known male/female X genotypes: simulated males gave F in [0.84, 1.13], simulated females in [-0.06, 0.10], a clean separation either side of both thresholds. NewR/sex_check.R:run_sex_check()restricts to QC-passing X-chromosome SNPs (context$qc_snps, not the LD-pruned set – LD pruning would needlessly shrink an already-small X marker pool for a statistic that, unlike PCA/kinship/IBS, is not sensitive to LD structure, the same reasoning already applied to ROH’s SNP-set choice), classifiesinferred_sex, and – when asexmetadata column is present – compares against it (recognizingmale/m/1andfemale/f/2synonyms, matching PLINK’s own.fam-file convention) to produce amatch/mismatch/ambiguous/not_reportedstatus. Deliberately does not exclude pseudoautosomal (PAR) regions: their boundaries are genome-build- and species-specific, and this package has no genome-build-identification mechanism for arbitrary user VCFs – the same reasoning already documented for not supplying species-specific genetic maps to the ROH module. PAR SNPs are diploid in both sexes and dilute, not reverse, the separation. New modulesex_check_module_spec()(VCF-only, on by default viaanalyses.sex_check,resource_class = "standard") writes42_sex_check.tsvand a27_sex_check_F_by_samplefigure (styled like ROH’s24_ROH_FROH_by_sample: per-sample points ordered by F,coord_flip(), coloured by match/mismatch/ambiguous status); skips both transparently – and the whole module produces no error – when fewer than 20 QC-passing X-chromosome SNPs are found (configurable chromosome-name aliases viaanalyses.sex_check_x_chromosome_names, defaultc("X", "chrX")), exactly like Mantel/isolation-by-distance skipping without coordinates. Report gained a NULL-safe “Sex check” section, sorted to surface mismatches and ambiguous calls first. The quickstart dataset’s chromosome-22-only region has no X chromosome, so this module skips transparently for it despite the dataset’s realsexmetadata column added earlier – documented honestly in the wiki and vignette rather than fabricating a demonstration figure. Verified with a newtest-sex-check.R(synthetic-fixture recovery of known male/female genotypes, mismatch-flagging against deliberately mislabelled metadata, both skip paths, and the figure), an extendedtest-config.Rcase, and confirmed the real quickstart pipeline test correctly logs the skip and completes without error. Also fixed a real, pre-existing gap found while regenerating documentation for this feature:inst/example_config.yml(the wiki’s linked canonical configuration example) was already stale, missing thepca_metadata_color*keys from the per-metadata-column PCA feature above – fixed alongside the new sex-check keys. No public API changes beyond the newsex_check_module_spec()export, matching every other*_module_spec().Propagated the new
sexmetadata and per-metadata-column PCA feature to the docs: regenerated the reference pipeline run against the updated quickstart dataset (same one-off ADMIXTURE/fastStructure/sNMF-enabled configdocs/examples/README.mdalready documents; VCF hash unchanged, confirming this is purely additive) and confirmed every figure unaffected by the newsexcolumn is byte-identical to the previous run. Added the new07b_PCA_PC1_PC2_by_sexfigure and real interpretive prose to the wiki andinterpreting-resultsvignette’s PCA section, verified empirically before writing it up (ANOVA on this exact PCA fit: p = 0.24 on PC1, p = 0.62 on PC2 – no significant sex separation, the expected result for an autosomal analysis). Also added the real, already-noted-in-NEWS finding that the cross-population duplicate pair records different sex while the same-population duplicate pair records matching sex, to both the wiki’s and the vignette’s kinship sections. Regenerated the committeddocs/examples/chr22-quickstart-report.pdf(130 pages, +1 from the new figure). No public API changes.Added real, self-reported sex to the bundled quickstart dataset’s metadata, sourced from the same authoritative panel file already used for population assignment (
integrated_call_samples_v3.20130502.ALL.panel’sgendercolumn, MD5-verified againstcanonical_1000g_chr22_source()’s declared checksum before use) – not a separate lookup, an inference, or a fabricated value.scripts/derive-quickstart-dataset.Rnow carries this column through assexalongsidepopulation; the VCF and its index are completely unchanged (only the metadata TSV gained a column), verified by reconstructing the metadata directly from the already-shipped VCF’s real sample order and confirming every pre-existing column (sample,population,latitude,longitude) is byte-for-byte identical to before. 77 male / 83 female across the 160 samples, plausibly distributed across all 8 populations. This is real, immediately useful ground truth for the per-metadata-column PCA feature above:sexclears the default minimum-group-size threshold in every population, so07b_PCA_PC1_PC2_by_sexnow actually renders for this dataset (previously nothing beyondpopulationqualified). Also surfaced a genuinely interesting, real data point while adding the coverage: the two known duplicate/MZ-twin pairs behave differently under this new column – the same-population pair (NA19331/NA19334, both LWK) records the same sex for both entries as expected, but the cross-population “duplicate” (HG03873filed as ITU,HG03998filed as STU) records different sex for the two entries, reinforcing that this pair is a same-individual cross-population cataloguing artifact rather than genuine identical twins (who would essentially always share recorded sex) – consistent with, and adding real evidence for, this pair’s already-noted unusually high cross-population kinship. Extendedtest-quickstart-dataset.Rwith real-value assertions for the new column (including the duplicate-pair sex values above) and a new real-pipeline integration test confirming07b_PCA_PC1_PC2_by_sexactually renders, and thatpopulation/latitude/longitudecorrectly do not get a redundant panel. No public API changes.Added static PCA panels coloured by arbitrary sample metadata columns, not just
population.plot_pca()already hardcodedcolour = population, shape = population; the PC1/PC2 scores now also get one extra panel per qualifying metadata column (07b_PCA_PC1_PC2_by_<column>), reusing the already-computed PCA fit – no new model fitting or pipeline stage. “Qualifying” means factor-like with a minimum group size, matching how the report already treats population-organized results: after dropping missing values, a column needs at least 2 distinct values, each occurring at leastanalyses.pca_metadata_color_min_grouptimes (default 3), and no more thananalyses.pca_metadata_color_max_levelsdistinct values (default 12, avoiding an unreadable legend/palette for high-cardinality columns like a collection-date field).populationitself,latitude/longitude(continuous, not categorical),sample/alias(identifiers), and columns with only one qualifying level (e.g. aspeciescolumn with a single value for every sample) are automatically excluded; a per-sample identifier-like column (all-unique values) is excluded too, since it always exceedsmax_levels. Newanalyses.pca_metadata_colorconfig key (defaultTRUE, cheap to compute since it reuses the existing PCA scores) to disable the extra panels entirely. Newpca_metadata_colour_columns()/plot_pca_by_metadata()(R/ordination.R), reusing the existing genericpopulation_palette()/population_shapes()helpers (they already operate on any group-label vector, not specifically population).plot_pca()gained an optionalmetadataparameter (defaultNULL, so every existing 3-argument caller/test is unaffected);run_module_pca()now passescontext$metadata. The bundled quickstart dataset has no metadata column beyondpopulation/latitude/longitudethat currently qualifies, so this does not change the committed example report yet. Verified with a newtest-pca-metadata-color.Rand an extendedtest-config.Rcase. No public API changes.Added the genome-scan figures (
25_genome_scan_FST_manhattan,26_genome_scan_diversity_manhattan) to the wiki and interpreting-results vignette’s “Genome scans” section, which was text-only despite genome scans already being a default-on module with its own committed report section. Real values from the quickstart example: 20 windows range from FST 0.051 to 0.172 (highest at 20.80-20.85 Mb, 104 SNPs, only moderately above the region’s known global FST of 0.0915); mean expected heterozygosity per window per population ranges from 0.099 to 0.398 across the 8 populations. Sourced from the same comprehensive reference run used for the ancestry-backend figures added earlier (no new pipeline run needed); already present in the committed example PDF’s figure gallery (Figures 118-119), which globs the full figures directory automatically, unlike the wiki/vignette’s hand-curated figure selection.Extended the wiki and interpreting-results vignette’s ancestry-backends section with real fastStructure and sNMF demonstrations, matching the ADMIXTURE treatment added earlier: cluster-number-selection figure first, then only the consensus K’s Q-matrix figures (population-organized and data-driven views). fastStructure’s consensus is K=3 (marginal likelihood peaks at -0.849 versus -0.854 to -0.872 elsewhere in K=2..9); sNMF’s is K=6 (mean cross-entropy across 5 replicates is lowest at 0.627 versus 0.628-0.650 elsewhere). All three backends’ independently-selected consensus K values differ (7, 3, 6) – left as-is and called out explicitly in the new prose as a real, expected illustration of why this section already said to distinguish computational agreement from biological truth, not averaged or otherwise reconciled by default (that reconciliation is a separate, heavier release-gate process,
docs/SCIENTIFIC_CONCORDANCE.md). Generated via the same one-off enabled config used for ADMIXTURE (default_config()unchanged);docs/examples/README.md‘s regeneration snippet updated to include all three backends’ settings, and the committed example PDF regenerated (129 pages) to include all three sections.Fixed
docs/examples/README.md’s regeneration instructions, which had gone stale as a side effect of the ADMIXTURE demonstration added above: they claimed “every module used its documented default configuration – no non-standard settings were applied,” but the committed PDF was actually generated with ADMIXTURE force-enabled (to source the wiki/vignette ancestry figures). Running the previously-documented snippet as-is would reproduce a report missing the entire ADMIXTURE section, not the real 94-page file that ships. Added the exact ADMIXTURE settings used (K = "2:9", 5-fold cross-validation) to the snippet, and an explicit note thatvignette("quickstart", package = "popgenVCF")uses pure defaults and will not reproduce this file.Added basepair-position tick marks to the PCA and DAPC SNP-loading Manhattan plots (
plot_pca_loading_manhattan()/plot_dapc_loading_manhattan()). Previously the x-axis carried exactly one tick per chromosome, centered and labelled only with the chromosome name – uninformative for judging real positional clustering of high-contribution SNPs within a chromosome. Newmanhattan_bp_breaks()(R/utils.R) computespretty()-rounded breaks per chromosome, Mb-formatted (scales::label_number(scale = 1e-6, suffix = " Mb")) to stay compact, with the tick budget divided across chromosomes (capped at 6 per chromosome, ~14 total) so genome-wide multi-chromosome data doesn’t get an unreadably dense axis; only the first break per chromosome is prefixed with its name (e.g."chr22: 20 Mb") so chromosome identity is preserved without repeating it on every tick.manhattan_layout()now also returns the per-chromosomeoffsetvector it already computed internally, needed to place these breaks in the same cumulative x-coordinate space; this is a backward-compatible addition (existing consumers use only$x/$ticks). Scoped to only these two plots, not the other Manhattan-style figures (FST/genome-scan), which keep their existing chromosome-center-only ticks. Verified with new tests (test-dapc-loadings.R) and by re-rendering the real quickstart report.Reworked the DAPC section of the wiki and
interpreting-resultsvignette to show the automatic cluster-number selection first, then only the consensus K’s figures, replacing a previously hardcoded, scientifically arbitrary K=8. The consensus for the quickstart example is K=3, withreplicate_max_rmseeffectively 0 (fully reproducible) – a materially better demonstration than the old K=8, whose RMSE (0.183) exceeds the reproducibility threshold (0.05) and should not have been presented as an interpretable result. Added explicit guidance: the full report contains one complete set of results for every requested K, and a given K’s clusters/loadings should only be trusted when itsreplicate_max_rmseis at or belowanalyses.structure.reproducibility_rmse(default 0.05).Added a real ADMIXTURE demonstration to the wiki and vignette’s ancestry-backends section, which previously had no figures at all. Both sections now open with an explicit reminder that ADMIXTURE, fastStructure, and sNMF are not run by default (external, optional dependencies – ADMIXTURE and fastStructure are separate command-line programs, not R packages), followed by the automatic cluster-number-selection figure and then only the consensus K’s Q-matrix figures (population-organized and data-driven views), not all 8 requested K values. The quickstart example’s real ADMIXTURE run (K=2..9, 5-fold cross-validation) selects K=7. This required actually running ADMIXTURE against the quickstart dataset once for documentation purposes, via a config enabled only in that one-off generation script –
default_config()is unchanged and ADMIXTURE/fastStructure/sNMF remain disabled by default.Added the PCA and DAPC SNP-loading figures (Manhattan and ranked, the ones the natural-sort fix below actually corrects) to the wiki and
interpreting-resultsvignette. Both sections’ prose already referenced “the Manhattan/ranked loading figures” but never embedded them – only the top-level PCA/DAPC scatter plots were shown. Added17_PCA_loadings_manhattan,18_PCA_loadings_ranked, and the K=815_DAPC_loadings_manhattan_K8/16_DAPC_loadings_ranked_K8(matching the already-curated K=8 DAPC scatter) from the same real reference pipeline run used for every other quickstart figure; confirmed byte-identical to the fresh post-fix run for the pre-existing scatter plots (07/11), confirming those were never affected by the sort bug – only the loadings figures were. Republished the live GitHub Wiki with these additions.Published the Wiki:
scripts/publish-wiki.sh --pushhad never been run against the changes accumulated this development cycle, so the live GitHub Wiki was missing all 9 documentation figures (kinship, ROH, isolation-by-distance, etc. all rendered as broken images) and was several pages behind (Documentation-Map.md,Getting-Started.md,Results-and-Interpretation.md,User-Guide.md). The pkgdown-built vignette site is unaffected by this (it rebuilds automatically on every push, unlike the Wiki, which is a separate repository requiring an explicit manual publish step). No workflow runs this automatically; it remains a maintainer-triggered action.Added the popgenVCF version to the manuscript report:
PopgenVCFAnalysisobjects already recordedpackage_version(used forrun_manifest.tsv), but the report template never surfaced it, despite the Reproducibility section’s text already claiming “package versions… are stored alongside the report.”skeleton.Rmdnow carriespackage_versioninto the flattened report object and displays “Generated with popgenVCF vX.Y.Z” in the Executive Summary (both HTML and PDF, since both render from the same template); NULL-safe for older archived results or fixtures that predate this field. Verified in a real rendered PDF (not just HTML) that the line renders correctly, and with a newtest-report-figures.Rcase covering both the present and absent cases.Fixed the same lexicographic-vs-natural sort bug (see the report figure gallery fix below) in three more places discovered while investigating a related report bug: the PCA and DAPC SNP-loading Manhattan/ranked figures facet by axis (
PC1…PC10,LD1…LD10) usingggplot2::facet_wrap(~axis), which silently alphabetizes any character/default-factor column, soPC10/LD10appeared right afterPC1/LD1, beforePC2-PC9/LD2-LD9– confirmed as a real bug reported against the shipped example report. The same root cause affects real multi-chromosome VCFs (not visible in this package’s single-chromosome chr22 fixtures):manhattan_layout()(shared by every Manhattan-style figure),R/chromosome.R’schromosome_summary.tsvordering,R/genome_scan.R’s windowed-output tables, andR/roh.R’s runs table all sorted chromosome labels with a plainsort()/setorder(), which would order human chromosomes as 1, 10, 11, …, 19, 2, 20, 21, 22, 3, …, 9, X, Y instead of 1-22, X, Y. Added a sharednatural_sort_key()/natural_sort_levels()helper (R/utils.R, zero-pads embedded digit runs so lexicographic order becomes numeric order; non-numeric strings likeX/Ycorrectly sort after all digit-leading ones) and used it consistently everywhere a chromosome or per-K/per-axis label is ordered.plot_pca_loading_manhattan()/_ranked()andplot_dapc_loading_manhattan()/_ranked()now also return theirggplotobject (invisibly) rather than only saving it as a side effect – both existing callers already discarded the return value, and this makes the fix directly testable. Verified with new tests acrosstest-utils-natural-sort.R,test-dapc-loadings.R,test-pca-loadings.R, andtest-genome-scan.R, and by re-rendering the real committed quickstart report. No public API changes.Fixed report figure gallery ordering for stems with an embedded number:
report_figure_inventory()(R/report.R) sorted figures with plain lexicographicorder()on filename, so e.g. the DAPC per-K figures (11_DAPC_K2…11_DAPC_K10) placedK10immediately afterK1-prefixed names, beforeK2-K9– a real, user-visible bug confirmed in the already-committed example report (“Figure 13: DAPC K 10” appeared before “Figure 14: DAPC K 3”). Added anatural_sort_key()helper (zero-pads embedded digit runs so lexicographic order becomes numeric order) and used it for the figure sort key; affects every per-K figure series (DAPC, and the ADMIXTURE/fastStructure/sNMF ancestry backends when enabled). Verified with a newtest-report-figures.Rcase (K2/K9/K10) and by re-rendering the real committed quickstart report from its already-computedanalysis_results.rds– confirmed corrected order (K3, K4, …, K9, K10) with no other content change (same 76 pages, same Mantel section); the regenerateddocs/examples/chr22-quickstart-report.pdfreplaces the previously-shipped, incorrectly-ordered one.Renamed the 35
R/source files that used an escalatingz-/zz-/zzz-…/zzzzzzzzzz-prefix naming scheme (e.g.zzzzzzzzzz_graphical_abstract.R) to descriptive names with the prefix simply dropped (graphical_abstract.R). Verified before touching anything that the scheme bought nothing functionally:DESCRIPTIONhas noCollatefield, so R sourcesR/files in plain alphabetical order regardless of name, and none of the 35 files contain S4/R6 classes or top-level code depending on another file having loaded first – confirmed by grepping every file for top-level statements. The one exception,R/zzz.R, is the standard, legitimate R-package idiom (holdsutils::globalVariables(), conventionally named to sort last) and was left alone. Checked all 35 new names for collisions against the rest ofR/(none) and fixed the handful of real cross-references found by a repo-wide search: prose comments inR/canonical_drift_detection.RandR/fair_export.R, a hardcoded path intest-package-check-hygiene.R, a doc reference, a code comment, and – the one reference that would have silently broken CI triggering –release-metadata.yml’spaths:filters, which named the oldR/zzz_software_identity_integration.Rpath twice. RegeneratedNAMESPACEandman/*.Rdwith roxygen2 8.1.0 (upgraded locally to match CI’s unpinnedany::roxygen2install) via the exact sequencegenerated-documentation.ymluses, including thereconcile-rd-aliases.py --writegrouping step;NAMESPACEis byte-identical (only file renames, no content change) and only the 7man/*.Rdpages whose owning file was actually renamed changed (just their “Please edit documentation in” comment).DESCRIPTION’s roxygen stamp updated toConfig/roxygen2/version: 8.1.0, matching that version’s own current convention. No public API changes.Updated documentation that had drifted behind three features added earlier in this same development cycle (kinship, runs of homozygosity, and sliding-window genome scans):
DESCRIPTION’sDescriptionfield andREADME.md’s opening paragraph never mentioned any of the three;wiki/User-Guide.mdandvignettes/getting-started.Rmd’s analysis-mode guides listed kinship and runs of homozygosity as requiring population metadata when both actually need only genotypes (verified againsttemplate_config(), which does not disable either for the metadata-free template, unlike the modules that genuinely do need population labels), and omitted genome scans from the population-metadata row entirely;inst/doc/architecture.md’s SNP-set description omitted kinship (LD-pruned set, verified againstrun_module_kinship()’s exact call), genome scans (QC-passing set, verified againstrun_module_genome_scan()), and runs of homozygosity (its own independent VCF-level filtering, using neither set – verified againstrun_module_roh()).Added real, documented geographic coordinates to the bundled quickstart dataset, so its Mantel/isolation-by-distance module actually runs instead of transparently skipping for lack of coordinates (the “skip, don’t fabricate” behavior this package correctly applies whenever geographic metadata is genuinely absent). Coordinates are real population-level collection-site points, not invented: source is
igsr/1000Genomes_data_indexes’sREADME_populations.md, the authoritative IGSR population description for the exact 8 quickstart populations. GBR (England/Scotland), ITU (Indian Telugu, collected in the UK), and STU (Sri Lankan Tamil, collected in the UK) share one representative London point since the source names no more specific city for any of the three – a real, documented fact (ITU/STU are genuinely UK-collected diaspora cohorts, not India/Sri Lanka fieldwork), not an approximation. YRI is Ibadan, Nigeria; LWK is Webuye, Kenya; CHB is Beijing, China; PUR is represented by San Juan, Puerto Rico; PEL is Lima, Peru. These are population-level representative coordinates, matching standard population-genetics practice, since individual sample locations are never published for de-identified 1000 Genomes samples.scripts/derive-quickstart-dataset.Rgained a small, citedpopulation_coordinatestable merged onto the existing per-sample selection by population; sample selection, the VCF, and its index are completely unchanged (verified byte-for-byte after decompression – only the bcftools command-line header comment, which embeds a timestamp and temp path, differs between runs). Found and fixed a real, pre-existing latent bug in the same script while regenerating the dataset:data.table::fread()’s automatic header detection misidentifies the upstream 1000 Genomes panel file as headerless, because its header row has two trailing empty tab-separated fields that data rows don’t – fixed with an explicitheader = TRUE. Verified end to end with a realrun_pipeline()execution against the updated dataset: Mantel (Pearson, 999 permutations) gives r = 0.3129, p = 0.001 (the permutation floor), slope = 0.00275, R² = 0.137, across all 12,720 complete real sample pairs – a genuine, statistically significant, biologically sensible isolation-by-distance signal, not a coin flip that happened to land significant. The committed example PDF/HTML report,wiki/Results-and-Interpretation.md, andvignettes/interpreting-results.Rmdall now show the real12_isolation_by_distancefigure and these real values (previously text-only guidance with no figure, unlike every other analysis section, since the module had never actually executed for this dataset before).vignettes/quickstart.Rmdupdated to describe the module as running rather than skipping. Extendedtest-quickstart-dataset.Rwith real-value coordinate assertions (e.g. Beijing’s documented CHB coordinates, the GBR/ITU/STU shared point) and a new real-data test confirming the IBD module actually executes and produces a finite, significant, positive-slope Mantel result. No public API changes.Added sliding-window genome scans (windowed FST and diversity), the other genuine gap flagged alongside kinship and ROH earlier this session (both already shipped): everything else this package computes is either genome-wide (global FST, diversity) or per-chromosome-summary (
chromosome_summary.tsv, one value per whole chromosome) – there was no positional resolution within a chromosome to spot localized differentiation/diversity outliers. Verified empirically before committing to the design: timed per-windowSNPRelate::snpgdsFst()calls against the real quickstart dataset at 50kb windows – 20 windows across the ~1Mb region computed in 0.06s total, with real, plausible, variable per-window FST (0.05-0.17) consistent with the region’s known global FST of 0.0915. NewR/genome_scan.R:run_genome_scan_fst()computes only the global (all-population) FST per window via the sameSNPRelate::snpgdsFst()callrun_fst()already uses, not the full pairwise matrix per window (O(windows x population pairs)is disproportionate for an exploratory scan).run_genome_scan_diversity()aggregates the diversity module’s already-computed per-locus table into the same windows – no new per-locus computation, no new SNPRelate calls, just spatial binning of results that already exist. Windows below the newanalyses.genome_scan_min_snps(default 5) getNA, not a misleading value from an underpowered estimate. New modulegenome_scan_module_spec()(requires = "diversity"to reuse its output) writes39_genome_scan_fst.tsv,40_genome_scan_diversity.tsv, and a filtered41_genome_scan_FST_outliers.tsv(top 20 windows by FST); two new Manhattan-style figures (25_genome_scan_FST_manhattan,26_genome_scan_diversity_manhattan) reuse the already-genericmanhattan_layout()helper already used by the PCA/DAPC loading plots. New config keysanalyses.genome_scan(defaultTRUE, gated the same wayfstalready is),analyses.genome_scan_window_bp(default 50000),analyses.genome_scan_step_bp(default 50000, i.e. non-overlapping; set belowwindow_bpfor a real overlapping/sliding scan),analyses.genome_scan_min_snps(default 5). Module availability required a real, necessary edit to the centralizedR/metadata_capabilities.Rcapability-gating lists (population_modules/multi_population_modules) – module availability isn’t decided by the module spec’s ownenabledfield in this codebase, a gap that would have silently made the module unavailable even with population metadata present if missed. Found and fixed two real implementation bugs during verification: a column-name collision inside a per-windowdata.tablejexpression (the windows table’s ownchromosomecolumn shadowing the per-SNPchromosomevector, breaking the window-filtering logic silently), and a spurious warning from an unnecessary cleanup line after:=withby=(which never adds the grouping key as a column, unlike aggregatingj). Report gained a NULL-safe capped “Genome scan FST outliers” section. Verified with a newtest-genome-scan.R(hand-verified window-boundary generation and diversity aggregation arithmetic, plus real-data FST values) and the full local suite. No public API changes beyond the newgenome_scan_module_spec()export.-
Added a bundled real quickstart example dataset, real example figures in the wiki and
interpreting-resultsvignette, a committed example PDF report, and a newquickstartvignette – so a prospective user can see real popgenVCF output, and reproduce it locally in a few minutes, before installing anything for their own data. The dataset (inst/extdata/quickstart/, new exportedquickstart_dataset_paths()) is 160 real samples from 8 populations (GBR, YRI, LWK, CHB, ITU, STU, PUR, PEL) derived from the same already-approved chr22 1000 Genomes source and bounded region (22:20000000-21000000) this package’sproduction_baselinegate, continuous-benchmarkcanonicaltier, and this session’s kinship/ROH verification already use (scripts/derive-quickstart-dataset.R, deterministic, reproducible) – not a new data-governance decision. Deliberately includes two known real duplicate/MZ-twin pairs confirmed against this exact source (HG03873/HG03998, a genuine cross-population duplicate labelled ITU/STU;NA19331/NA19334, same-population LWK), so the bundled kinship demo has real, verifiable, and pedagogically interesting signal (a duplicate individual mislabelled across two populations) rather than an arbitrary sample selection. A single real referencerun_pipeline()execution against this dataset (report enabled, every default-on module) is the one source for every figure and table shown in the wiki, the vignette, and the committed PDF – a user who runs the bundled quickstart data sees output matching what they already saw in the docs, rather than a bigger dataset they can’t easily reproduce.Rendering that reference report from real data (24+ figures, several 10+-column result tables) surfaced two real, previously-latent LaTeX defects in the PDF report path that the small fixtures used elsewhere never triggered: (1) “Too many unprocessed floats” – pandoc wraps every markdown image in a floating LaTeX environment by default, and LaTeX’s default float queue (18) is smaller than this report’s real figure count once every per-K DAPC/PCA loading figure is included; fixed by forcing immediate
[H]placement for every figure (report_pdf_preamble(),R/report.R), matching the one-figure-per-page behavior the template’s existing\newpagecalls already intended. (2) Wide result tables (population diversity, PCA scores) overflowing the page width into illegible overlapping text; fixed by havingreport_table_section()(skeleton.Rmd) explicitly request LaTeX-formatkable()output (previously implicitly emitting pandoc markdown-table syntax, invalid once wrapped in a raw LaTeX box) and wrap it in a measured\sbox/\resizebox, shrinking only when a table is actually wider than the page and never stretching a naturally narrow one. Both fixes are pure LaTeX (float,booktabs, already-available packages) – no new dependency. Verified by re-rendering both the PDF and HTML formats of the real reference report end to end and visually inspecting the result (including the two known duplicate pairs correctly flaggedduplicate/MZ twinat kinship ~0.45-0.46).scripts/publish-wiki.shnow also syncswiki/figures/(previously copied exactly one hardcoded logo image) so the new figures actually reach the published GitHub wiki. Newquickstart.Rmdvignette added to_pkgdown.yml’s Guides menu. Verified with a newtest-quickstart-dataset.R(dataset/metadata shape, both duplicate pairs present) and the full local suite. No public API changes beyond the newquickstart_dataset_paths()export. Added runs-of-homozygosity (ROH) detection and per-sample FROH, the other genuine gap flagged alongside kinship earlier this session: diversity’s
inbreeding_coefficientis a population-level FIS summary and kinship is pairwise, but neither captures the per-sample, per-genomic-region signal of long homozygous stretches (a standard inbreeding/autozygosity indicator). Usesbcftools roh(Narasimhan et al. 2016, HMM-based, the modern standard method) –bcftoolsis already a requiredSystemRequirementsdependency (used for VCF sorting/BGZF/indexing before the pipeline can even build a GDS), so this adds no new dependency at all. The only CRAN alternative,detectRUNS, was checked and rejected: last updated 2019,NeedsCompilation: yes, and pulls in 6 dated transitive dependencies (plyr/iterators/itertools/reshape2/Rcpp/gridExtra) for one feature. Verified empirically against real data before committing to the design:bcftools roh -G30 --estimate-AF -(self-estimated allele frequencies from the analyzed samples – this package has no mechanism to attach an external reference-panel AF to an arbitrary VCF, so self-estimation is the only sound default) against the real, already-approved chr22 1000 Genomes region gave real, plausible signal (a 15-sample real multi-population subset: 1-4 runs of 15-160kb per sample within a 1Mb window, FROH 0.02-0.4); confirmed--AF-dflt(a naive constant AF) produces obviously-degenerate output (near-100%-of-window “runs” for every sample), ruling it out. Deliberately does not apply this package’s fixed MAF filter (maf=0.05) before ROH calling – MAF filtering is appropriate for PCA/structure work but is known in the ROH literature (Purcell et al. 2007; McQuillan et al. 2008) to bias run lengths downward by removing informative rare-homozygous tracts; ROH instead reuses only the missingness half of the fixed QC contract (F_MISSING > 0.2, i.e.cfg$qc$max_variant_missing, not a new config key) plus a biallelic-SNP-only restriction. NewR/roh.R:run_roh()derives the exact analyzed sample/site subset via onebcftools viewcall, computes the analyzed genomic footprint (not the whole genome – this package accepts arbitrary VCF inputs, so a single-region VCF and a whole-genome VCF give very different FROH for the same biology) from that same site set, and callsbcftools roh, producing a full per-run table and a per-sample summary where a sample with zero detected runs still appears withn_runs=0/froh=0(real information, not silently omitted). New moduleroh_module_spec()writes37_ROH_runs.tsv,38_ROH_sample_summary.tsv, and two new figures:23_ROH_length_distribution(log-scale histogram, skipped only when zero runs exist anywhere) and24_ROH_FROH_by_sample(always drawn, coloured by population when available). Newanalyses.rohconfig key (defaultTRUE, likeibs/kinship– bcftools being mandatory rather than optional is exactly why) andanalyses.roh_gt_error_phred(default 30, bcftools’s own documented “safe value… to account for GT errors”). Deliberately does not impose any short/medium/long ROH-length classification convention (species/study-specific, not a property of the estimator itself, unlike KING’s kinship thresholds) and does not expose genetic-map/HMM-transition-probability tuning (no supply mechanism for species-specific genetic maps in this general-purpose tool). Report gained a NULL-safe “Runs of homozygosity” section (highest-FROH samples, capped at 20, pointing to the complete on-disk table), matching the “Close relatives”/“Private alleles” precedent. Verified with a newtest-roh.R, including a purpose-built synthetic VCF fixture with real, verifiable ground truth (one sample homozygous throughout -> one run spanning the full analyzed span; one heterozygous throughout -> zero runs), plus real-data smoke checks against the cached chr22 region and the tiny bundled CI fixture (structural-only, matching the established HWE/kinship precedent for that undersized fixture). No public API changes: onlyroh_module_spec()is exported, matching every other*_module_spec().Added pairwise sample kinship/relatedness estimation, a genuine gap distinct from the existing IBS analysis: IBS answers “how similar are these samples overall,” kinship answers “are these two individuals related, and how closely” – the standard upstream check for cryptic relatives, which silently biases FST, diversity, and structure analyses computed downstream. Uses
SNPRelate::snpgdsIBDKING()(KING-robust, Manichaikul et al. 2010, already an SNPRelate dependency – no new package).type = "KING-robust"is hardcoded rather than exposed as a config toggle:"KING-homo"assumes a single homogeneous population and would silently give biased results whenever real population structure exists, which this package explicitly supports. Verified empirically before committing to the design: the tiny 9-SNP CI validation fixture gave nonsensical kinship values (outside the theoretical bounds, including identical values for a same-population pair and an arbitrary pair), confirming KING genuinely needs realistic marker density; the real, already-approved chr22 1000 Genomes region (2,504 samples x 21,418 SNPs) gave real signal (the two highest-kinship pairs ~0.48, consistent with 1000 Genomes Phase 3’s known duplicate/MZ-twin pairs) in 6.5 seconds. Also discovered empirically, and important for interpretation: KING-robust’s kinship estimator is bounded above at 0.5 (self/duplicate) but is not bounded below at -0.5 for arbitrarily divergent pairs – real chr22 data restricted to the pipeline’s own LD-pruned SNP set showed off-diagonal kinship as low as -8 for pairs spanning highly differentiated continental populations, so the new validator only enforces the upper bound. NewR/kinship.R:run_kinship()(mirrorsrun_ibs()’s exact filtering defaults) returns the kinship matrix, IBS0 matrix, and a long-form pairs table joined to each member’s population, classified intorelationship_degree("duplicate/MZ twin","1st-degree","2nd-degree","3rd-degree","unrelated") using the standard KING thresholds (Manichaikul et al. 2010, not invented).plot_kinship()draws21_kinship_heatmap(gated at <=300 samples, matchingplot_ibs()’s existing gate; clusters on a kinship-derived pseudo-distance via the already-generichclust_dendrogram_segments()helper, but displays the real kinship coefficient as fill) and22_kinship_IBS0_vs_kinship(the standard KING diagnostic scatter, coloured by relationship degree). New modulekinship_module_spec()writes33_kinship_matrix.tsv,34_kinship_IBS0_matrix.tsv,35_kinship_pairs.tsv(every pair), and a filtered36_close_relatives.tsv(only pairs above the newanalyses.kinship_close_relative_thresholdconfig key, default 0.0442, the 3rd-degree cutoff – configurable since which degree counts as “close enough to flag” is a legitimate per-study policy choice, unlike the classification scale itself). Newanalyses.kinshipconfig key (defaultTRUE, likeibs– needs only genotypes, no population/geographic metadata required). Report gained a NULL-safe “Close relatives” section (capped at 20 rows, pointing to the complete on-disk table), matching the “Private alleles” section’s precedent; the full O(n^2) matrices are intentionally not surfaced in the report, matching how IBS’s own matrices aren’t either. Verified with a newtest-kinship.R(relationship-degree classification at every threshold boundary;run_kinship()against a synthetic fixture with a known exact duplicate and a simulated Mendelian parent-offspring pair, both real ground truth, not the undersized 9-SNP fixture; module-spec and validator coverage), extendedtest-config.Randtest-pipeline.Rcoverage. No public API changes:run_ibs()/plot_ibs()/ibs_heatmap_plot()are all internal, sorun_kinship()/plot_kinship()stay internal too.Added the
canonical,medium, andlargecontinuous-benchmark dataset tiers.docs/CONTINUOUS_RELEASE_BENCHMARKING.mddeclared 4 dataset tiers (synthetic,canonical,medium,large), butscripts/build_release_benchmark_archive.Ronly ever producedsynthetic– flagged as deferred when closing issue #43.canonicalreuses the real, already-approved chr22 1000 Genomes source (popgenVCF::canonical_1000g_chr22_source()) and the exact same bounded 1Mb region (22:20000000-21000000)production_baselinealready uses – the same acquisition pathscripts/run-approved-canonical-validation.Ruses, not a new data subset; found and fixed a real bug during verification, where the defaultrun_canonical_production_execution()inspection function is chrY-oriented and misclassifies chr22’s index, requiring the samecanonical_production_inspect_bcftools_compatibleoverriderun-approved-canonical-validation.Ralready uses.medium(300 samples x 20,000 SNPs) andlarge(1,000 x 100,000) are larger synthetic datasets – an explicit scoping decision, since no medium/large real dataset is registered/approved anywhere in this repository and sourcing one would need the same governance process (licence, citation, checksum inventory, scientific review) chr22 itself went through. All 3 tier sizes came from real local timing measurements against the actualrun_pca/run_ibs/compute_diversity/run_fstpipeline functions (not guessed):medium~1.9s/repetition,large~37s/repetition,canonical(2,504 real samples x 21,418 SNPs x 26 real populations) ~86s/repetition – the slowest tier totals under 9 minutes at the existingwarmup=1, iterations=5schedule. All 4 tiers are opt-in via a newPOPGENVCF_BENCHMARK_TIERSenv var (comma-separated, defaultsynthetic) and, inrelease-benchmark-archive.yml, a newbenchmark_tiersworkflow_dispatchinput with a conditional bcftools install step – ordinary pull-request and tag-push CI triggers are completely unaffected.write_continuous_benchmark_evidence()already accepted a list of observations/comparisons, so multipledataset_tiervalues sharing onebenchmark_idround-trip throughcontinuous_benchmarks.jsonwith no schema change, verified with a new test covering exactly that shape. Verified with a newtest-benchmark-tier-datasets.R(synthetic/medium/large shape checks unconditionally; the real chr22 acquisition path opt-in viaPOPGENVCF_TEST_CANONICAL_BENCHMARK=true, exercised directly against the real source using a locally cached copy from an earlier acquisition to avoid a redundant fresh download during verification) and a real local end-to-end run of the full script across two sequential releases (no-baseline, then baseline-compared, both tierspassed). No public API changes.Fixed
plot_diversity()unconditionally referencinghwe_pvalueondiv$locus, which broke two pre-existing tests (test-plot-palettes.R,test-publication-plot-quality.R) that build a minimaldivfixture with nolocuselement at all – a real regression this session introduced, caught by CI (R-CMD-check, test-coverage) rather than locally. The local miss traced to a real gap in this session’s own test-verification script: it only ever checkedsum(df$failed), neversum(df$error)– an uncaught R error inside atest_that()block shows up aserror = TRUE,failed = 0, and only checkingfailedmakes such an error invisible. Both columns are now checked on every full-suite verification pass in this repository going forward. Guarded the HWE-histogram code to fall back to an empty table whendiv$locusis absent or lackshwe_pvalue, matching how the rest of the function already treats other missing optional data.Fixed the
codecovbadge being permanently stuck on “unknown”:covr::codecov()’s tokenless upload was being rate-limited (confirmed from real GitHub Actions run logs:"Rate limit reached. Please upload with the Codecov repository upload token"), and noCODECOV_TOKENexisted anywhere in the workflows. Switchedtest-coverage.yamltocovr::to_cobertura()followed bycodecov/codecov-action(pinned tov5.5.5’s real commit SHA, resolved via the GitHub API, matching this repository’s immutable action-pin requirement) once a real token was created and added as a repository secret. Verified end to end from the actual run logs: real token used (not tokenless),cobertura.xmlfound and uploaded, and the badge itself confirmed to show a real percentage afterward.fail_ci_if_errorwas initially leftfalseas a safety net for the first rollout, then tightened totrueonce the real upload was confirmed working.Made the manuscript report actually surface the new DAPC/PCA loading and HWE/private-allele outputs, and fixed a real data-loss bug found while checking this. The figure gallery already worked without any change needed – it globs the
figures/directory generically (report_figure_inventory()), so all 6 new figures (15/16/17/18/19/20) were already appearing automatically, confirmed with a real rendered HTML report before touching anything. The population-level HWE/private-allele summary counts were also already appearing, since they were added as new columns on the existingdiversity$populationtable the report already renders. What was actually missing: (1)run_module_pca()stored the analysis result aspca[c("scores", "variance")], silently droppingloadingsbefore it ever reachedanalysis_results.rds– a real loss of already-computed data, not just a report gap, now fixed topca[c("scores", "variance", "loadings")]. (2) The report template had no section for the private-allele detail list or the DAPC/PCA top-loading rows (only the population-level summary counts and the figures, not the underlying tables). Added three new report sections (skeleton.Rmd), each capped at 20 rows and pointing to the complete on-disk table: “Private alleles”, “PCA SNP loadings” (sorted by|loading|), and “DAPC SNP loadings” (combined across all K, sorted by contribution). All three are NULL-safe and correctly absent from reduced reports with no underlying data, matching the report’s existing “omit unavailable sections” behavior. Verified against a real rendered HTML report (all three sections present with correct real data) and a new pair of tests exercising both the present and absent cases.Added per-population Hardy-Weinberg equilibrium (HWE) testing and private-allele detection to the
diversityanalysis, reusing per-locus-per-population data (x,p,n_called)compute_diversity()already computed rather than adding a parallel computation. HWE usesSNPRelate::snpgdsHWE()(the exact Wigginton et al. 2005 test, already a dependency, confirmed to return one p-value per requested SNP in request order) called once per population; loci monomorphic within that population are reported asNArather than SNPRelate’s trivialp = 1, since piling spuriousp = 1values into the summary counts and histogram would misrepresent “not tested” as “not rejected”. Private alleles use the standard presence/absence definition (matches hierfstat’s/poppr’sprivate.alleles()): an allele is private to a population when every copy of it, genome-wide across all retained populations, is found in that one population; theprivate_allelecolumn can be"ref","alt","both"(the rare case where every other population has zero calls at that locus), or"none", and correctly excludes cases where a population merely appears monomorphic locally but another population’s heterozygotes still carry the “missing” allele – verified with a hand-constructed synthetic GDS (built viaSNPRelate::snpgdsCreateGeno()) covering all four outcomes plus the fewer-than-two-populations no-op case. This is reporting-only: no new QC gate, no SNP filtering. New outputs:09_population_diversity.tsvgainshwe_tested_loci/hwe_significant_loci/hwe_significant_loci_fdr(Benjamini-Hochberg viastats::p.adjust)/private_allele_loci;10_population_locus_diversity.tsvgainshwe_pvalue/reference_allele_count/private_allele; a new filtered32_private_alleles.tsvlists just the loci with a private allele; two new figures,19_HWE_pvalues(p-value histogram faceted by population, styled like01_MAF’s threshold-line histogram) and20_private_alleles(bar chart by population), both skipped when there is nothing to show. Newanalyses.hwe_alphaconfig key (default 0.05). Verified with a newtest-diversity-hwe-private-alleles.R, extendedtest-config.Randtest-pipeline.Rcoverage, and visual inspection of the rendered PNGs against real data.Added a PCA SNP-loading Manhattan plot and a rank-ordered loading plot, plus a companion top-contributing-SNP table, mirroring the DAPC loading feature below but adapted to PCA’s real differences rather than blindly copying the DAPC schema. Loadings come from
SNPRelate::snpgdsPCASNPLoading(), computed once from the already-fitted PCA object (no new model fitting) only on the primary SNPRelate eigensystem path – the rare covariance-eigendecomposition fallback path (used when SNPRelate’s own eigensystem is non-finite) cannot be fed throughsnpgdsPCASNPLoading()meaningfully, so loadings are honestlyNULLthere rather than computed from a substituted eigendecomposition. Unlike DAPC’svar.contr(always non-negative), PCA SNP loadings are signed correlations between each SNP and each component, so the newpca_loading_table()keeps the signedcontributionfor both plots (showing direction) alongside amagnitudecolumn used only for top-N ranking and sort order – “top contributing” means largest|loading|, not largest raw value. The join to chromosome/position is by real SNP ID viamatch()againstsnpgdsPCASNPLoading()’s own returnedsnp.id(which can be a strict subset of the requested SNPs, e.g. viaremove.monosnp), not by position – confirmed empirically against the real validation fixture, where one of nine SNPs was dropped this way.run_pca()gained an optionalidsparameter (the sameget_gds_ids()-shaped lookup already threaded everywhere else in the pipeline); loadings are remapped from raw SNPRelate component indices to the pipeline’s final retainedPC1..PCnnumbering, since some requested components can be dropped for non-positive variance.plot_pca()draws17_PCA_loadings_manhattanand18_PCA_loadings_ranked(reusing the DAPC feature’smanhattan_layout()helper) when loadings are present;run_module_pca()writes the top-N-per-axis table as31_PCA_loadings.tsv, where N is the newanalyses.pca_loading_top_nconfig key (default 20). Verified with a newtest-pca-loadings.R(thesnploadingjoin/rank/magnitude logic, the real-fixture end-to-end path, and theids-supplied vs. omitted paths), extendedtest-config.Randtest-pipeline.Rcoverage, and visual inspection of the rendered PNGs against real data.Added a DAPC SNP-loading Manhattan plot and a rank-ordered loading plot, plus a companion top-contributing-SNP table, for every configured DAPC K value.
adegenet::dapc()already computesvar.contr(per-SNP contribution to each discriminant function) as part of fitting each K, so this adds no new model fitting – only cheap post-processing of results that were already being produced.genlight_from_gds()now accepts an optionalsnp_idsargument to setlocNames()on the genlight object; the newdapc_loading_table()readsmodel$var.contrand joins it to chromosome/position by position, not by row name –var.contr’s rownames turned out to be plain positional indices (“1”, “2”, …), not the genlight object’s locus names, discovered by inspecting a real fitted model rather than assuming adegenet propagates names through.run_dapc_analysis()/run_dapc_k_task()gained optionalsnp_ids/chromosome/positionparameters (defaultNULL, so the unrelated unsupervised-clustering caller inpopulation_structure.Ris unaffected) and store the resulting long-form loading table per K.plot_dapc()draws both new figures per K from that table when present:15_DAPC_loadings_manhattan_K<k>(x = genomic position via a newmanhattan_layout()helper inR/utils.R, alternating chromosome colours, faceted by discriminant function) and16_DAPC_loadings_ranked_K<k>(x = SNP rank by descending contribution, adegenet’s classicloadingplot()style).run_module_dapc()writes the top-N-per-axis table as22f_DAPC_loadings_K<k>.tsv, where N is the newanalyses.dapc_loading_top_nconfig key (default 20). Scoped to every configured K (not just the best K): the expensive DAPC fitting already happens for every K regardless, so restricting to the best K would only reduce output file count, not save meaningful compute. Verified with a newtest-dapc-loadings.R(layout offsets, thevar.contrjoin/sort, and thesnp_ids-supplied vs. omitted paths), extendedtest-dapc-plots.Randtest-config.Rcoverage, and a real end-to-endrun_pipeline()run against the validation fixture with visual inspection of the rendered PNGs.Added
scripts/write_scientific_review_gate_record.R, letting a scientific reviewer turn a small declarative review-spec JSON (gate_id,status,summary,artifacts, optionalapproval) into a correctly-shaped, checksum-verified<gate_id>-gate-record.jsonfragment, reusing the sharedwrite_release_candidate_gate_record()helper instead of anyone hand-computing checksums or hand-writing JSON. Intended for the 4 gates on the named reviewer’s standing assignment that recur every release (production_baseline,external_concordance,ancestry_three_backend,benchmark_history) – unlikescientific_approval,release_authorization, andarchival_assets, which are one-off, release-specific sign-offs. Fails closed before writing anything if the spec references a missing artifact, omits a required field, or lists no artifacts. Run against the real, retainedv0.10.0Release evidence for all 4 gates, using the exact review determinations already recorded indocs/developer/canonical-autosomal-baseline-proposal.md,docs/SCIENTIFIC_CONCORDANCE.md,docs/user/ancestry-backends.md, anddocs/CONTINUOUS_RELEASE_BENCHMARKING.md: merging the resulting 4 fragments with the 8 CI-auto-collected fragments from a realrelease-candidate-collector.ymlrun reproduced12 / 15gatespassed, matching what was independently proven true for the realv0.10.0release. Verified locally with a newtest-scientific-review-gate-record.Rcovering the approval and no-approval success paths (with independently recomputed checksums), the missing-artifact and missing-field failure paths, and that the resulting fragment merges correctly throughbuild_release_candidate_evidence_index.R. Seedocs/developer/release-candidate-closure.md.Fixed
oci_distributionsilently stayingnot_runinrelease-candidate-collector.ymleven whencontainer.ymlsucceeded and wrote its fragment correctly:container.ymlalso produces a*.dockerbuildDocker Buildx cache artifact (viacache-to: type=gha) thatgh run downloadcannot extract as a normal zip, and downloading every artifact in one call aborted entirely on that one broken artifact – silently discarding the realcontainer-release-metadataartifact alongside it. The download step now enumerates real artifacts via the REST API, skips anything ending.dockerbuild, and downloads each remaining artifact individually. Found by dispatching the collector withpublish_container=truefor real and noticingoci_distributionwas stillnot_rundespite every job reporting success – confirmed via the raw run logs (error extracting zip archive: zip: not a valid zip file) rather than assuming success from green checkmarks alone.Fixed the
dispatchmatrix job inrelease-candidate-collector.ymlfailing on every gate (“fatal: not a git repository”): removing its unusedactions/checkoutstep also removed the local git contextghCLI uses to auto-detect which repository to target. SetGH_REPOexplicitly instead of restoring the unnecessary checkout.Parallelized
release-candidate-collector.yml’s sub-workflow dispatches: split the original single sequential job (dispatch, wait, download, repeat – total time was the sum of every gate workflow’s duration) into aplanjob that computes a dispatch matrix, adispatchmatrix job that runs one gate workflow per parallel runner (fail-fast: false, so one gate’s CI failure doesn’t stop the others from being collected), and anassemblejob that downloads every matrix instance’s fragment and runs the collector script. A real end-to-end dispatch of the original sequential design confirmed the core dispatch/wait/download logic worked correctly for all 5 gates, and also caught a real bug fixed here: the collector’s dependency-install step was missingdata.table, whichread_release_candidate_policy()requires.Added the per-gate CI-fragment evidence collector for future releases.
scripts/build_release_candidate_evidence_index.Rno longer hand-authors 0.10.0-specific prose in R source for each gate; it now recursively scans a directory for small, self-describing<gate_id>-gate-record.jsonfragments (via the newinst/scripts/release_candidate_gate_record.Rhelper), re-verifies each artifact’s checksum, flattens nested artifact paths (GitHub Release assets cannot contain directory structure), and errors closed on duplicate or unknowngate_ids or checksum mismatches.release-metadata.yml,public-api-contract.yml,tagged-source-release.yml(which now produces three fragments:source_package_check,scientific_validation,source_distribution),apptainer.yml, andcontainer.ymleach write their own fragment as a normal part of their run;canonical-real-data.ymlalready did (it was the prototype for this pattern). A new.github/workflows/release-candidate-collector.ymldispatches all six producing workflows for a given candidate ref, verifies each landed on the exact resolved commit, downloads their fragments, and assembles a partial evidence index covering the 8 gates CI can determine on its own – without needing anyone to manually dispatch and hand-collect each gate’s CI run the way this session did for 0.10.0. The remaining 7 gates that require real scientific review, a named approval, or an actual Zenodo deposition still cannot be automated, and are honestly leftnot_runby this collector; a human supplies their fragments and re-runs the same script locally to produce the final index. Seedocs/developer/release-candidate-closure.mdfor the fragment contract and the full gate-to-workflow mapping. Verified locally with a newtest-release-candidate-evidence-collector.Rcovering fragment merging, path flattening, duplicate/unknown-gate-id rejection, checksum-mismatch rejection, and a synthetic all-15-gates-supplied case reachingrelease_ready: TRUE.
Fixed R-CMD-check and test-coverage failing on main immediately after the release, caused by the release itself: release_reconciliation_version_signals() hardcoded the expectation that NEWS.md’s header always reads "# popgenVCF <version> development" and that README.md always says "Development series: **<version>**", with no path for an actual release ever happening. It now reads release_status from inst/metadata/software-identity.json and expects "# popgenVCF <version>" (no development suffix) and "Current release: **<version>**" once released. Updated README.md’s intro blockquote to match and to state the real release date rather than “under release-conformance review”. This test only runs where the checked-out source tree is reachable (real CI, covr’s install layout), which is why it never surfaced in any local test run this session.
popgenVCF 0.10.0 is released. DOI 10.5281/zenodo.21747548 (concept DOI 10.5281/zenodo.21747067), 2026-08-01. All 15 release-candidate policy gates passed with real, checksum-verified evidence; the dossier reports READY for the first time in this repository’s history. Reconciled the real DOI, concept DOI, and publication date across inst/metadata/software-identity.json, CITATION.cff, codemeta.json, .zenodo.json, README.md, and docs/reproducibility.md; release_status transitions from development to released. Fixed two negative-path tests (test-release-metadata-contract.R, test-zenodo-archival-metadata.R) that had coupled themselves to the repository’s real (then-development) metadata state instead of constructing an isolated fixture – both now build their own synthetic development-state identity, independent of the real repository’s release status. Also fixed a stale hardcoded “DOI-free” success message in validate_zenodo_metadata.R.
popgenVCF 0.10.0
Wired
scientific_approvalandrelease_authorizationintoscripts/build_release_candidate_evidence_index.R. Marc-Olivier Duceppe, as named scientific reviewer, approved the complete scientific evidence set (production_baseline,external_concordance,ancestry_three_backend,benchmark_history); as release owner, he authorized tagging, publication, container-image publication, and Zenodo deposition/DOI assignment for the exact commit being released, contingent onarchival_assetscompleting as part of that same deposition (Zenodo evidence can only exist after the real GitHub Release it deposits from is published). Verified end to end through the realbuild_release_candidate_dossier.R:14 / 15required gates now pass – onlyarchival_assetsremains, pending the realv0.10.0release and Zenodo deposition.Fixed the same missing-
GH_TOKENbug found inrelease-benchmark-archive.ymlintagged-source-release.yml: its own “Publish GitHub Release” step (gh release create/gh release upload) would have failed outright on the first real tagged release, since the job only ever setGITHUB_PAT, neverGH_TOKEN. Found by auditing every workflow that calls theghCLI before cutting the realv0.10.0release, rather than discovering it mid-release.Wired
oci_distributionintoscripts/build_release_candidate_evidence_index.Rusing a real container push:container.ymldispatched withpublish=truebuilt and pushed an image toghcr.io/duceppemo/popgenvcf(tagged onlysha-<commit>– nolatestor semver tag, since those apply only to actual Release-triggered publishes), pulled it back by digest, re-ran the package-version check and scientific/population-structure validation against the pulled image, and generated a real BuildKit SPDX SBOM and maximum SLSA provenance attestation, both checksum-verified before inclusion. Verified end to end through the realbuild_release_candidate_dossier.R:12 / 15required gates now pass, up from11 / 15– onlyarchival_assets,scientific_approval, andrelease_authorizationremain, all tied to the still-deferred Zenodo deposition decision.Transitioned
benchmark_historyfromblockedtopassedinscripts/build_release_candidate_evidence_index.R, now that theGH_TOKENfix produced a real, reviewable comparison:release_ready: true, both validation-suite digests unchanged, and all three performance budget checks passed without regression against the0.10.0-rc1baseline. Reviewed and approved by Marc-Olivier Duceppe, 2026-08-01 (see docs/CONTINUOUS_RELEASE_BENCHMARKING.md “Second execution and review”). Verified end to end through the realbuild_release_candidate_dossier.R:11 / 15required gates now pass, up from10 / 15– onlyoci_distribution,archival_assets,scientific_approval, andrelease_authorizationremain, all deferred pending the real registry push and Zenodo deposition decisions.Fixed
release-benchmark-archive.yml’s baseline-discovery and auto-attach-to-Release steps: both call theghCLI directly, but the job only ever setGITHUB_PAT(used by R’s package installers), neverGH_TOKEN/GITHUB_TOKEN, whichghitself requires – everygh release list/gh release download/gh release uploadcall in this workflow was silently failing with an auth error. This was a real, previously undiscovered root cause for why benchmark-trend comparisons have never worked in this repository, deeper than “zero published GitHub Releases existed”: even after this release cycle published the first real Release (0.10.0-rc1) and attached a realscientific-benchmark-archive.tar.gzbaseline to it, discovery still returnedno-baselineuntil this fix. Confirmed by inspecting a dispatched run’s raw logs (gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable.) rather than assuming the missing-Release explanation still applied.Wired
canonical_validationintoscripts/build_release_candidate_evidence_index.Rusing a freshcanonical-real-data.ymlproduction run (candidate_id0.10.0-canonical-validation-1) for the exact evaluated commit: the approved 1000 Genomes chr22 source was acquired in a clean environment, verified against the approved upstream MD5 inventory, promoted to SHA-256, and structurally inspected with bcftools against the complete sample metadata, with no raw genotype data retained in the evidence. Unlike the other gates wired so far, this one required a real dataset acquisition/verification run rather than reusing an existing fast CI artifact. Verified end to end through the realbuild_release_candidate_dossier.R:10 / 15required gates now pass, up from9 / 15– onlybenchmark_history(genuinely blocked),oci_distribution,archival_assets,scientific_approval, andrelease_authorizationremain.Wired
public_api_contractintoscripts/build_release_candidate_evidence_index.Rusingpublic-api-contract.yml’s real canonical-baseline verification for the exact evaluated commit: 627 entries, 0 blocking findings. 17 advisory findings (all new public exports and optional arguments added this release cycle) are disclosed rather than hidden – expected and non-blocking, since the canonical baseline is refreshed and reviewed separately from this gate. Verified end to end through the realbuild_release_candidate_dossier.R:9 / 15required gates now pass, up from8 / 15.Wired
metadata_consistencyintoscripts/build_release_candidate_evidence_index.Rusingrelease-metadata.yml’s three real validator results for the exact evaluated commit:validate_release_metadata.R(DESCRIPTION/CITATION.cff/codemeta.json/software-identity consistency, including the development-release boundary),validate_license_metadata.R(LICENSE/SPDX consistency), andvalidate_zenodo_metadata.R(.zenodo.jsonconsistency) – all passed with no failing checks. Verified end to end through the realbuild_release_candidate_dossier.R:8 / 15required gates now pass, up from7 / 15.Wired
source_package_checkintoscripts/build_release_candidate_evidence_index.Rusingtagged-source-release.yml’s realR CMD check --as-cranlog and clean-library install log for the exact built source tarball: 0 errors, 0 warnings, 2 NOTEs (a known R-CMD-check CITATION-before-install artifact, and a data.table NSE column-reference false positive inpopulation_structure.R), disclosed rather than glossed over. Verified end to end through the realbuild_release_candidate_dossier.R:7 / 15required gates now pass, up from6 / 15.Wired
scientific_validationintoscripts/build_release_candidate_evidence_index.Rusingrun_scientific_validation()’s andrun_population_structure_validation()’s real results from this release cycle’s scientific-release-integration self-test (hand-calculated allele-frequency/MAF/heterozygosity/missingness cross-checks, PCA eigen-equation residual, SNPRelate LD/FST/IBS consistency, DAPC classification, replicate reproducibility – no failing checks). Verified end to end through the realbuild_release_candidate_dossier.R:6 / 15required gates now pass, up from5 / 15.Wired
source_distributionintoscripts/build_release_candidate_evidence_index.Rusing the real, checksum-verified source-release bundle (tagged-source-release.yml’s tarball, SPDX SBOM, provenance record, manifest, and terminal checksum file) already produced and re-verified this release cycle. Verified end to end through the realbuild_release_candidate_dossier.R:5 / 15required gates now pass, up from4 / 15.Wired
apptainer_distributionintoscripts/build_release_candidate_evidence_index.Rusingapptainer.yml’s new build-metadata artifact: a dispatched run has no publish/registry step to gate, so it’s evidence-equivalent to what a tagged release would produce (unlikeoci_distribution, which still needs a real registry push). Verified end to end against a fresh run at the exact evaluated commit through the realbuild_release_candidate_dossier.R:4 / 15required gates now pass, up from3 / 15.Added a build-metadata step to
apptainer.yml: it previously uploaded only the raw.sifimage itself, with no checksum, digest, or provenance record – unlikecontainer.yml, which writes structured metadata for every build. It now also writes and uploads a smallapptainer-metadata.json(SIF checksum and size, Apptainer version,Apptainer.defchecksum, package version, git commit) plus its own checksum file, giving theapptainer_distributionrelease-candidate gate real, lightweight, checksum-bound evidence to reference instead of only a bare “it built” claim.Gated
container.yml’s image publish step on!github.event.release.prerelease: it triggers on every published GitHub Release (release: types: [published]), not only tag pushes, so there was previously no way to publish a real, non-draft evidence-carrying pre-release without also pushing a container image to GHCR. GitHub pre-releases now take the same build-and-smoke-test-only path as an unpublishedworkflow_dispatchrehearsal; only a non-prerelease published Release still triggers a real publish. Seedocs/developer/release-archival-readiness.md.Added
scripts/build_release_candidate_evidence_index.R, the production counterpart toscripts/build_release_candidate_rehearsal.R(which only ever emits syntheticblockedplaceholders derived from policy alone). It assembles a real, checksum-verifiedrelease-candidate-evidence-index.jsonfrom retained gate evidence – without fabricating evidence for gates that don’t have any. Run against the real, formally approvedproduction_baseline,external_concordance, andancestry_three_backendevidence from this release cycle: those three gates arepassedwith namedapprovedreview records;benchmark_historyis honestlyblocked(notpassed) on the documented zero-published-GitHub-Releases gap; the remaining 11 gates arenot_runbecause no real evidence source is wired in for them yet. Verified end to end through the realbuild_release_candidate_dossier.R: all checksums verified,release_ready: false,3 / 15required gates passed – the state the underlying evidence actually supports. Closes the durable-retention gap for the three approved scientific gates (checksum-bound into a real index artifact, not only prose indocs/); a release-ready production dossier still needs the remaining gates wired up and, separately, this index published as GitHub Release assets. Seedocs/developer/release-candidate-closure.md.Added
read_canonical_ancestry_three_backend_evidence()and, to the scientific-concordance contract,approve_scientific_concordance_record()andread_scientific_concordance_suite(). Both canonical evidence contracts had awrite_function but no matchingread_/approve_counterpart, so retained evidence could never actually be reloaded and formally transitioned fromproposedtoapproved– only recorded as prose indocs/. The readers rebuild validated objects directly from JSON (raw sample identities are never persisted for the ancestry contract, onlysample_order_sha256, so reconstruction goes through the retained fields rather than thenew_*constructors that require them) using the same pattern as the existingread_canonical_real_data_baseline_snapshot(). Used to formally approve the realproduction_baseline,external_concordance, andancestry_three_backendevidence produced earlier this release cycle against their actual reviewed determinations already recorded indocs/.Added
scripts/run-ancestry-three-backend-proposal.R, the first execution driver for theancestry_three_backendrelease gate (#24): derives a wider genomic interval thanproduction_baseline’s 1Mb window from the same approved chromosome 22 canonical source, runs ADMIXTURE, fastStructure, and sNMF across a declared K range and replicate schedule viarun_ancestry(), computes per-backend stability and the cross-backendselect_ancestry_k()consensus, computes cross-backend Q-matrix alignment at the selected K, and assembles anew_canonical_ancestry_three_backend_evidence()proposal with a checksum inventory.role/minimum_alignment_scorefor the cross-backend comparisons are recorded as an explicit, documented placeholder pending named scientific review, not a scoped policy decision. Verified end to end with a fast smoke-configuration run (K=2:3, 1 replicate, the narrow 1Mb region) against the real downloaded chr22 source; the full default sweep (K=2:10, 5 replicates, the wider ~3,500-SNP region proven necessary for good cross-backend convergence) takes several hours and is intended for manual/scheduled production execution, not routine CI.Reconciled two independent benchmark-evidence systems that had never been connected:
scripts/build_release_benchmark_archive.R(wired into.github/workflows/release-benchmark-archive.yml, green on every PR) wrote its evidence vianew_release_benchmark_record()/register_release_benchmark()(a historical trend archive), whileinst/scripts/scientific_review_packet.R– the actual release-review packet builder – looks specifically forcontinuous_benchmarks.json, the artifactwrite_continuous_benchmark_evidence()produces. CI was consistently green, but was never producing the evidence format the release process actually consumes. The script now also builds aPopgenVCFContinuousBenchmarkObservation, compares it against a prior observation extracted from the downloaded baseline archive when available, and writescontinuous_benchmarks.{tsv,json,md}alongside the existing archive (round-tripping through the existingarchive.tar.gzpackaging with no workflow changes needed). Also replaced the benchmark’s timed workload, previously an unrelated synthetic matrix multiplication (sum(crossprod(x))), with the actual analysis functionsrun_pipeline()itself calls (PCA, IBS, diversity, FST) against a deterministic in-memory synthetic genotype matrix – VCF/bcftools-free, so it stays fast enough for ordinary pull-request CI while measuring something real. Verified locally end to end across two sequential runs (no-baseline, then baseline-compared), confirmingscientific_review_packet.Rnow finds and parses real, passing evidence. Thecanonical/medium/largebenchmark-dataset tiers remain unimplemented placeholders (R/benchmark_datasets.R); real production-scale performance benchmarking is a separate, larger follow-up.Fixed
faststructure_backend()’s ancestry replicates carrying no fit metric at all: fastStructure writes its converged marginal likelihood to its own<output-prefix>.<K>.logfile, not to thestructure.pyprocess’s stdout/stderr thatrun_faststructure()was already capturing (which is effectively empty).run_faststructure()now parses that native log forMarginal Likelihood = Xand records it per K;faststructure_backend()passes it through as the replicate’smarginal_likelihoodmetric. This was silently breakingselect_ancestry_k()– the documented cross-backend K-selection consensus mechanism – for any real run mixing fastStructure with ADMIXTURE/sNMF, since fastStructure had zero available metrics andselect_ancestry_k()errors closed rather than silently skipping a backend. Found and fixed while starting the canonicalancestry_three_backendevidence-record contract for release issue #24; confirmed the fix against real chr22 replicate data.Fixed
run_admixture_cv()silently running non-independent “replicates”: it set anADMIXTURE_SEEDenvironment variable that the ADMIXTURE binary does not read (confirmed empirically –stringson the installed 1.3.0 binary shows it documents a--seed=Xcommand-line flag, not an environment variable; repeated runs and runs with differentADMIXTURE_SEEDvalues produced byte-identical Q matrices and log-likelihoods).admixture_command_arguments()now takes an explicitseedparameter and passes--seed=Xon the command line, matching how--cv=is already passed; different seeds now produce genuinely different Q matrices and CV errors, as multi-replicate stability evidence requires. Found while proving out a real-data three-backend ancestry validation run for release issue #24: ADMIXTURE reported perfect (stability = 1.0) replicate agreement at every K from 2 to 10, which turned out to be an artifact of five identical runs rather than genuine convergence robustness.Added
test-pipeline.R, the first end-to-end test ofrun_pipeline()itself (previously exercised only through mocks or source-text pattern matches): a full run against the real validation fixture is checked against its documented tables, RDS, and manifest artifacts, and a second run without a metadata file is checked against the module-skip warning path. This surfaced and fixed a real bug:write_manifest()unconditionally callednormalizePath()/hash_file()oncfg$input$metadata, crashingrun_pipeline()at its final step whenever no metadata file was supplied – a configuration this package explicitly supports elsewhere (metadata_from_samples()). The manifest’smetadata/metadata_sha256fields are nowNAwhen metadata is absent instead of the pipeline failing outright.Removed
DESCRIPTION’s redundantAuthor:field (R derives it automatically fromAuthors@R, and the two had drifted, producing anR CMD checkNOTE); updatedscripts/validate_release_metadata.R’s identity check to match.Added
test-chromosome.R, closing the last gap in chromosome-specific analysis coverage:run_chromosome_analyses()’s per-chromosome grouping is cross-checked against directrun_fst()/run_pca()calls on the same real fixture data, its SNP-count and LD-pruned-count thresholds are exercised independently, andwrite_chromosome_results()’s file output, empty-input, and chromosome-name-sanitization paths are verified.Retired
tools/reconcile-roxygen-owners.py’s andtools/reconcile-legacy-public-exports.py’s stale file/symbol mappings left behind by the duplicate-definition cleanup below, restoring theGenerated documentationCI gate; verified by rerunningroxygen2::roxygenise()and every step of that workflow locally before pushing.Hardened
test-package-check-hygiene.R’s andtest-release-reconciliation.R’s repository-root discovery so hygiene and release-reconciliation checks skip cleanly instead of erroring under any test runner that cannot reach the source tree (covr’s own temp-library install, a plainR CMD checkon a built tarball,devtools::check()), while continuing to run for real under this repository’s own CI and in-treedevtools::test(); confirmed against an actualR CMD checkrun’s00_pkg_src/<pkg>copy and reproducedcovr’s install layout directly.Removed four source files (execution-recovery-checkpoint, artifact-contract, runtime-benchmark-record, and legacy-runtime-deprecation contract constructors) and four narrower dead functions elsewhere, all confirmed to have zero references anywhere in the package, its tests, or its documentation before removal.
Fixed two
R CMD checkNOTEs: a stray.claudedirectory pulled into the built source package, and an unusedviridisLiteImport (ggplot2::scale_fill_viridis_calready bundles its own color maps).Removed 49 duplicate function and S3-method definitions across
R/, left over from a load-order-dependent pattern with noCollate:field to pin it (later-sorting files silently won at runtime); confirmed each removed copy was a strict behavioral subset of the one that actually ran, and ported forward two confirmed validation gaps that existed only in copies shadowed by this pattern –validate_public_analysis_request()/validate_public_analysis_response()now recheck scalar-string shape, status vocabulary, and the error-consistency invariant that construction-time code already enforced.Regenerated the chromosome 22 quantitative proposal from the reviewed serialization fix and independently verified a promotion-eligible, filename-bound snapshot; the observations are unchanged and the snapshot remains explicitly unapproved pending named scientific review.
Added a fail-closed JSON review path for canonical real-data baseline snapshots: source checksum filename bindings now survive serialization, absent approval fields are explicit
nullvalues, proposal imports reject unbound checksum arrays, and promotion requires explicit scientific reviewer metadata.Executed and checksum-verified the first candidate-bound chromosome 22 quantitative baseline proposal, recording six reviewable observations while leaving scientific approval, the production-baseline gate, and external concordance explicitly incomplete.
Hardened metadata import for tabular panels whose headers contain trailing empty delimiter fields, preserving named sample/population columns while removing only generated empty columns.
Hardened approved canonical-source acquisition with a scoped 10-minute minimum download timeout so large checksum-pinned archives are not truncated by R’s 60-second default; incomplete transfers continue to fail closed.
Added a fixed-interval chromosome 22 QC, LD-pruning, and PCA execution that emits six checksum-bound quantitative observations as an explicitly unapproved scientific-review proposal while keeping raw genotypes outside evidence and the production-baseline gate unpassed.
Phase 0.9.32 adds a checksum-pinned 1000 Genomes chromosome 22 source and source-specific mixed-sex production inspection for bounded autosomal validation.
Added the first candidate-bound canonical production execution path for #22: the approved 1000 Genomes chromosome Y source is acquired only through an explicit manual run, verified against upstream MD5 and promoted SHA-256 identities, structurally inspected with
bcftools, bound to the exact candidate commit, and emitted as checksum-linkedcanonical_validationgate evidence while the production baseline and external concordance remainnot_run.Completed the Phase 0.9.31 release-candidate closure mechanism: a versioned 15-gate policy, checksum-verified evidence-index contract, deterministic JSON/TSV/Markdown reviewer dossier, terminal checksum inventory, explicitly blocked pull-request rehearsal, manual production evaluation, named approval requirements, and backend-specific ADMIXTURE, fastStructure, and LEA/sNMF setup guidance now provide a fail-closed path to a final 0.10.0 decision without claiming that production evidence, a release, or a DOI already exists.
Completed Phase 0.9.30.4 archival readiness: DOI-ready but unpublished Zenodo metadata is synchronized and fail-closed, tagged source releases generate SPDX SBOM and provenance evidence for the exact source tarball, OCI releases retain extractable BuildKit SBOM and SLSA attestations, and complete SHA-256 manifests plus deposition and institutional-archive instructions establish an independently verifiable release chain.
Added Phase 0.9.30.3 release-metadata reconciliation: a packaged canonical software identity now synchronizes DESCRIPTION, the installed package citation, CITATION.cff, CodeMeta, FAIR software records, and the reproducibility statement; development metadata cannot claim a release date or DOI, and CI emits machine-readable cross-format validation evidence.
Added Phase 0.9.30.2 publication narrative completeness: all nine canonical result families now contribute deterministic methods, captions, citations, supplementary summaries, explicit fallback states, conflict-checked ownership, machine-readable completeness evidence, and a maintained synthetic pkgdown gallery through the publication companion bundle.
Added the Phase 0.9.30.1 public guide set: a first-analysis tutorial, analysis-specific scientific interpretation guidance, operational troubleshooting, expanded computational reproducibility, and release-neutral Docker, GHCR, Apptainer, Slurm, and HPC deployment instructions, with pkgdown/README integration and fail-closed documentation tests.
Opened Phase 0.9.30 by eliminating all avoidable
R CMD checknotes: repository-only release assets are excluded from source packages, package-internal triple-colon access is removed, imports and data.table NSE bindings are explicit, and generated usage documentation is width-safe.Reconciled the generated public interface as a 613-entry installed API contract comprising 590 ordinary exports and 23 S3 registrations, with unique roxygen ownership, deterministic
NAMESPACE/Rd generation, a refreshed canonical baseline, and read-only CI drift enforcement.Reconciled repository health after Phase 0.9.29: removed runtime namespace mutation, restored explicit public exports, expanded release/API drift evidence, and synchronized README, NEWS, roadmaps, package requirements, and trackers.
Hardened scientific concordance suites so dataset/tool/version identities are unique, declared inventories are mandatory for release readiness, mutated suite state is rejected, and machine-readable evidence exposes missing inventory entries.
Hardened continuous release benchmarking so current and baseline identities, environments, metrics, and repetition counts must be compatible; empty, duplicate, malformed, or incomplete comparison evidence can no longer certify a release.
Added continuous release benchmark observations, versioned performance budgets, historical comparison contracts, deterministic TSV/JSON/Markdown evidence, and explicit
passed,failed, andinsufficient-evidencestates.Added canonical external-tool scientific concordance records and suites with exact versions, commands, tolerance profiles, comparison tables, provenance, citations, diagnostic/equivalence roles, scientific approval, and deterministic evidence.
Added canonical real-data baseline snapshot contracts that bind approved checksum-pinned datasets, complete sample metadata, quantitative metric registries, workflow provenance, source commits, and explicit scientific approval.
Integrated canonical release certificates into scientific release bundles with release identity, provenance, required evidence inventories, artifact-manifest checksum linkage, and fail-closed finalization.
Added canonical release-readiness policies and certificates combining validation, quantitative baseline conformance, longitudinal drift, and approved scientific change reconciliation.
Added scientific change-request registries and deterministic reconciliation of independently measured canonical drift against approved, rejected, pending, or superseded changes.
Added longitudinal canonical baseline drift histories with tolerance-normalized severity classes, deterministic summaries, and methods evidence.
Added versioned quantitative canonical baseline metrics, comparator-aware evaluation, coverage summaries, and deterministic evidence.
Added deterministic multi-dataset canonical validation suites over approved registry entries with checksum verification, custom scientific checks, failure policies, coverage summaries, and evidence.
Integrated the first approved canonical real-data source, the checksum-verified 1000 Genomes Project Phase 3 chromosome Y callset, through a manual full-validation workflow.
Added canonical dataset registry and approval-state contracts plus licensed, checksum-pinned real-data materialization and external numerical-comparison contracts.
Added deterministic publication-quality IBS/MDS coordinates, source tables, validation records, methods, captions, and PDF/SVG/PNG artifacts.
Added a canonical public-API baseline and fail-closed CI compatibility gate for removals, required-argument changes, and default drift.
Reconciled
0.10.0as the authoritative development release identity across package metadata, README, NEWS, roadmaps, and tracking issues.Added a deterministic release/public-API audit for exports, S3 registrations, Rd aliases, roxygen declarations, dynamic namespace mutation, version signals, blocking findings, and machine-readable reconciliation evidence.
Added a unified deterministic analysis execution engine with canonical planning, module ordering, contract validation, and execution metadata.
Added dependency-aware failure propagation with explicit success, failed, and blocked states plus auditable execution ledgers.
Added validated execution checkpoints, deterministic plan reconstruction, safe resume support, and checkpoint persistence.
Added bounded deterministic retry and recovery orchestration with preserved attempt ledgers and validated prerequisite reuse.
Added explicit global and per-module elapsed-time budgets, fail-closed timeout handling,
timed_outledger states, retry integration, and timeout-policy metadata.Added canonical backend-independent ancestry replicate and result contracts for ADMIXTURE, fastStructure, and sNMF.
Enforced sample identity, Q-matrix simplex constraints, replicate uniqueness, fit metrics, convergence, runtime, and provenance.
Added stable replicate-summary and long-form Q-matrix tables for downstream consensus, plotting, and reporting.
Added Hungarian ancestry-replicate alignment with correlation, cosine similarity, permutation matrices, RMSD, and alignment diagnostics.
Added consensus ancestry estimates with mean and median Q matrices, empirical confidence intervals, per-cell variance, cluster stability, sample uncertainty, and global replicate stability.
Added automatic backend-aware K selection with replicate intervals, plateau detection, stability weighting, cross-backend voting, recommendation confidence, and manuscript-ready text.
Added a unified ancestry backend plugin contract, runtime backend discovery, deterministic task scheduling, canonical parsing, execution records, and
run_ancestry()adapters for ADMIXTURE, fastStructure, and sNMF.Added backend-neutral ancestry publication artifacts with metadata-optional ancestry barplots, uncertainty and stability diagnostics, K-selection curves, PDF/SVG/PNG figures, figure source tables, manuscript text, captions, validation records, and strict artifact manifests.
Added validated canonical result contracts for PCA, IBS/MDS, neighbour-joining trees, diversity, FST, AMOVA, DAPC, and isolation by distance, including provenance, parameters, metadata, validation, artifact manifests, stable table accessors, legacy adapters, and deterministic RDS serialization.