Skip to contents

popgenVCF 1.0.16.9000 development

  • Colour-blind-safe colours beyond 8 groups: the accessibility-first and standard-color figure styles carry 8 colours; with 9 or more populations or clusters every figure fell back to hcl.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 in figures/ 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 the png package (new in Suggests); without it the report still renders, at full size, with a warning.

  • gzip instead of xz for saved R objects: analysis_results.rds and the checkpoint/ledger/workspace persistence files are now written with compress = "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 than bottleneck_n_bins called samples (10 by default) the lowest class cannot hold a single locus, the mode can never fall in it, and mode_shifted was TRUE by 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 new mode_shift_status column (tested / too_few_samples / no_segregating_loci), alongside n_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 get NA, 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 its mismatch is NA rather 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, as skipped and left the overall status "passed" – a module that silently stopped producing its output went through the gate. The first is now failed, the second error, and either makes the overall status "failed". Only diagnostic goldens may still be skipped.

  • Fourth code pass, increment 17 (locale independence): base sort() and order() collate strings by the session’s LC_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" under C, after it under en_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 with pmin()/pmax(). A test-suite run with collation forced to en_US showed 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 (maf 0.05 -> 0.5, an added artifact) validated, wrote and read back as if untouched. All recorded component digests are now verified. Doing so exposed set_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.tsv carried role = 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 reason and source_changes were 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 a manual_review section never listed changes arriving through a regenerate edge. 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 a plan_id) and validate_execution_plan() accepted a plan edited that way. They are now sha256-<digest>. The Phase 10 public API fingerprint hashed the whole serialize() stream, whose header records the R version and the session’s native encoding: a request written in a UTF-8 session failed read_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, and validate_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 as launch_failed (the basic runner used to throw from setwd()). Workspace input manifests are ordered in C-locale (radix) order: with default collation the same inputs produced a different workspace identifier under en_US and C, 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. processx 3.9.0’s pipe reader never consumes a NUL, so run_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 returned status = "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 later grepl() 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 with setTimeLimit(), 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-maintained man/execution_timeout.Rd now say what the budget does and does not bound, and point to the genuinely enforced timeout_seconds on the external ancestry backends. No behaviour change.
    • execution-cancellation.R was 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 is parallel_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 from compute.seed and 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 --resume deliberately 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 in run_external_command() and compress_report_pdf(); these three call sites were missed. Every argument is now shQuote()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 gives quarto_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 with rep(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. With sexbias_permutations > 0, samp.within() additionally shuffled values across populations instead of within them, and the FST/FIS tests 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 a sex column. 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 entered glPca()/dapc() as allele frequency 1.0 instead of 0.5 (confirmed directly), and poppr.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 – what t.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.tsv existed only on disk. The report now has a “Bottleneck mode-shift screen” table, including the new mode_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 under qc.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 the 48_site_frequency_spectrum figure 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.
  • 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/.log beside the input document regardless of intermediates_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 .aux and 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 stray population_genomics_report.aux/.toc files 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”), but plot_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. With qc.autosome_only (the default), markers on the configured sex chromosomes leave the analysis set between the missingness filter and LD pruning, but 07_QC_sequential_counts.tsv, 06_QC_independent_counts.tsv, the 04_SNP_retention figure, 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, so Sample ID/Sample.ID, the individual_id synonym 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 configuring input.sample_column/population_column – whose whole purpose is a non-standard header – failed with “require headered metadata” unless metadata_header: yes was 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 .sha256 sidecar are now removed with the other stale outputs when a fresh run starts.
    • utils.R: log_msg() joined its parts with paste(), whose default separator doubled every space callers already supply (“Starting pca”, “popgenVCF v 1.0.15”) in the console and pipeline.log. Now paste0().
  • 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 in figures/, and tables/, 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 --resume is 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.py globs every faststructure.<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, and mcparallel()’s default mc.set.seed = TRUE reseeds that child from the process, discarding the set.seed() made just before it. With clonality_ia_permutations > 0, identical runs gave different p-values. The child now inherits the seeded state.
  • man/publication_ordination_outputs.Rd (after the v1.0.15 tag): the variance_explained_unit argument added in the first review pass was never added to this hand-maintained Rd page, so R CMD check reported a code/documentation mismatch WARNING and the R-CMD-check workflow failed on main. 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 as 1/S + 3.19/S^2 for 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 bare 1/S above 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 bare 1/S gives 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 the snp.id order 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_10 before scaffold_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 returned snp.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 hardcoded latitude/longitude instead of reading input.geographic_columns, and that setting was never normalized the way metadata headers are on import, so geographic_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_columns is 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 own set.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 from compute.seed and 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 view evaluates --exclude against the original sample set, before -S subsetting (confirmed directly). In the module’s single-pass call, F_MISSING therefore 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 with compute.threads > 1. bootstrap.pml(multicore = TRUE) resamples inside forked workers, whose Mersenne-Twister streams do not derive from set.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 pins snpfirstdim = FALSE like 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 from PopGenReport::spautocor(), whose first class has a strict > 0 lower 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 of b / m, which reported p = 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.7 is 0.30000000000000004, so ceiling(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 with file.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_snps exclusion; 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()’s system2(gs, ...) call passed the report’s own output path unquoted – the same system2() args-are-not-shell-quoted gap already fixed in run_external_command(), here affecting the user/config-controlled output directory. Fixed with shQuote() on both the ghostscript output path and the input PDF path. Also hardened render_standard_report_format()’s own file.copy() return value being silently ignored (a failed copy – full disk, permissions, output_dir removed mid-render – previously returned a path to a report that was never actually written).
    • regression_archive.R: write_benchmark_archive() used the caller-supplied record$release string directly as a directory name – validated only as “one non-empty string”, with no character-set restriction, so a release id containing ../ could escape releases_dir entirely. 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 way write_chromosome_results() (chromosome.R) already does for chromosome names; the manifest’s own recorded file path (which verify_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() called utils::untar() with no confinement check on the archive’s own entry names – a crafted .popgenvcf bundle (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 external tar binary R’s untar() 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 stash bisection.

  • 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 in tryCatch(..., error = function(e) NULL) with no warning at all – a genuine failure left permutation as 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()’s fail_if_none only 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 explicit unit/variance_explained_unit parameter ("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 used scores[[group_column]] == group to 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 corrupted mean() 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()’s observed <- observed[names(reference)] silently inserted NA for any reference metric name absent from observed (same length, different names passes the earlier length check), and run_benchmark()’s own if (numerical_pass && ...) then crashed with “missing value where TRUE/FALSE needed” instead of a clear failure – now validated explicitly before the lookup. Separately, a missing passed column from a custom comparator hit a hard stop() outside run_benchmark()’s own tryCatch(), 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 bare draw(); dev.off() and no on.exit/tryCatch – a draw() 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 via on.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 own name variable 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 ..name escape turned out not to resolve reliably for a for-loop variable in this data.table version; fixed instead by indexing the plain module/status/attempt vectors already extracted earlier in the function with base R, which has no such ambiguity for either operand.
    • benchmark.R and canonical_ancestry_three_backend.R: minor cleanup – an unused gc(reset = TRUE) return value and a stale @return doc; two lambda parameters named c (shadowing base::c, though never actually called as a function within those specific lambdas) renamed to pair.
  • Six more findings from the full-repo review’s second batch (canonical-validation/CLI modules):

    • canonical_change_approval.R: three related bugs. decided_at was completely unvalidated in set_canonical_change_status() (any string accepted, not even checked for non-emptiness) and only checked for non-emptiness – not ISO-8601 date format – in new_canonical_change_request(), even though .resolve_governing_approval()’s own tie-breaking logic depends on lexicographic string comparison correctly ordering real dates; both now require YYYY-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 flagged missing_expected_change and blocked release_ready when 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 finite normalized_drift values before summing – a metric with even one Inf (“breaking”) transition among otherwise-finite ones had that transition silently dropped from its own cumulative sum, and a metric whose every transition was Inf vanished from the table entirely, easily misread as “no drift data” rather than “unbounded drift”. Removed the pre-filter; sum()’s own natural Inf propagation 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 the poppr::poppr() 32-bit-crash fallback path, where n_mlg_total is NA and 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 through make.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-report silently set config = "--no-report" and dropped --no-report entirely, 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() used is.list(x) to decide whether x was 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.frame x (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 excludes is.data.frame(x) from that branch.

    As with every fix in this pass, each ships with a new regression test verified via git stash bisection.

  • 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 own env parameter replaces the entire child environment by default (confirmed directly: env = c(FOO = "bar") alone left a child process with no PATH/HOME/etc. at all), unlike run_external_command()’s additive system2(env = ...) path – the same command$environment override 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()’s observed[names(reference)] silently inserted NA for any reference metric name absent from observed, and run_external_reference()’s own if (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 in canonical_real_data.R and benchmark_registry.R. Now validated explicitly before the lookup.
    • execution-ledger-persistence.R: validate_execution_ledger()’s allowed_status list omitted "timed_out", even though classify_timeout_ledger() (execution-timeout.R) rewrites a failed row’s status to literally that value – persisting the final ledger of any execute_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 real mclapply() fork site in the package not guarded by check_mclapply_results() (every other one – diversity.R, fst.R, dapc.R, genome_scan.R, report.R – already is). An OOM-killed forked worker returns NULL for its slot rather than a catchable error; currently unreachable in production (no registered module sets parallel_safe = TRUE yet), 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 stash bisection.

  • 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() used named_vector[[id]] %||% id to 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 from outer(population, population, "!=") without excluding NA population labels first – io.R deliberately converts an empty population string to NA_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 is na.last = FALSE (NA/NaN sorts FIRST, confirmed directly – the opposite of base R’s order()) – 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. Added na.last = TRUE.
    • journal_submission_profiles.R: three related bugs, all from the same “NA reaches a bare if” failure class already fixed elsewhere this pass. journal_profile_roles() validated anyNA(x) after sort(), 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) and highlight_min = NA with no other highlight limits configured both crashed validate_journal_submission() outright instead of correctly treating the check as satisfied/skipped.
    • ibs_publication.R: write_ibs_publication_artifacts(..., k = 1) produces coordinates with no MDS2 column at all (compute_ibs_mds()’s own contract: return exactly k axes). graphics::plot(x, y = NULL) does not error (confirmed directly) but silently falls back to plotting x against 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 stash bisection where the underlying bug is a crash (four of the five); the ibs_publication.R fix corrects a silent mislabeling rather than a crash, so its test instead confirms the k = 1 path 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 matching conditionMessage() text – base R’s own error messages (“missing value where TRUE/FALSE needed”) are translated under a non-English LANGUAGE setting (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 forces LANGUAGE=en for 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 one snpgdsGetGeno() call site in the package not pinning snpfirstdim = FALSE explicitly (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 used which(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 under n_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-supplied fingerprint argument to phase9_closure_review() and phase9_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 own required_ids, its phase9_roadmap_handoff()’s scalar_ids, and phase9_closure_evidence.R’s own scalar_ids) flatten several scalar arguments via c() before checking lengths(...) != 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 compensating length(...) != N check 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 stash bisection where the underlying bug is a crash; the snpfirstdim pin 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() mutates project$artifacts (adding $publication_bundle) but never recomputed component_digests$artifacts afterward – its sibling set_project_artifact_lineage() already does this correctly for the same mutation. Any downstream digest-based change comparison saw no change in $artifacts despite 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 coalesces NULL, not a length-0 vector (module$reason = character(0), a real shape an optional field can take) – left in, the following if (!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() validated shapes for NA but not linetypes, 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 stash bisection.

  • 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 via runs[, 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 became NA and then vanished entirely at the later merge() 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 letting merge() do it invisibly.
    • regression_reporting.R: compare_release_benchmarks() caught any compare_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() and release_reconciliation_dynamic_exports() both used normalizePath(root, ...) directly as an unescaped sub()/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’s file column) or matched something other than intended. Confirmed directly with a checkout root named .../release-reconciliation-root.name+test(v2)[final]-.../pkg. Added release_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 stash bisection.

  • 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() returns NULL) – 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’s is.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 through as.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 via vcf_index_is_valid() unconditionally – bcftools index --nrecords only 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.gz was 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-in check_mtime argument (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 called set.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.seed around 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’t sort(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 used max()/range() with no na.rm, so a single NA position on one chromosome poisoned the cumulative offset for every chromosome laid out after it too (cum accumulates), 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, unlike write_scientific_release_bundle()’s own overwrite = TRUE handling (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. Now unlink()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-level sprintf() 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 uses digest::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), since names(character(0)) is NULL) – 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 stash bisection.

  • 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 as paste0("title: \"", gsub("\"", "'", title), "\""), which only neutralizes literal double quotes. A title containing a newline (both functions accept a caller-supplied title, not necessarily typed by a trusted author) could close the quoted scalar, inject new YAML keys, or open a fenced ```{r} code chunk that quarto render would execute — confirmed directly: a proof-of-concept title reliably broke a 2-fence front matter into 3 and would have executed an injected system() call on render. Fixed by adding yaml_scalar_line() (R/utils.R), which builds the line via yaml::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 real write_population_genomics_report() path and confirms it survives as one literal title value with the template’s own execute.echo: false untouched; confirmed via git stash bisection 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 stash bisection 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 convention population_tree.R already 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 indexed gc()’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 preceding gc(reset = TRUE) exists specifically to make a max-used reading meaningful. Peak memory was systematically understated, silently defeating the memory_regression release 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-native sample_ids argument against metadata$public_sample whenever that column existed – which, since normalize_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 on metadata$sample, matching this function’s actual, VCF-native argument contract (distinct from mantel_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 comparing rep$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 validated PopgenVCFAncestryResult, 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 check new_ancestry_result() already applies, which a plain-list input bypasses).
    • canonical_real_data.R: compare_external_results()’s documented “scalar or named vector” tolerance contract 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 of value_cols, so tolerance = c(fst = 0.01, pi = 1e-6) compared against value_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 each value_col’s tolerance by name when tolerance is 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 stash bisection to fail against the pre-fix code and pass against the fix):

    • fair_export.R: the RO-Crate dataset’s license field was built as metadata$rights_uri %||% metadata$license – but %||% coalesces NULL only, and rights_uri’s own default is the real value NA_character_, not NULL. Every default-configured FAIR bundle (no explicit rights_uri supplied, the common case) published a literal "license": null instead of the declared license. Fixed with an explicit is.na() check.
    • regression_reporting.R: .component_validation_regressed()’s own regression check used !all(obs_vals, na.rm = TRUE) – so an observed passed column 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 by na.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)) (no na.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 checked file.exists(path) before appending the .popgenvcf extension, so a caller-supplied path lacking it bypassed overwrite = FALSE entirely and the unconditional unlink() 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’s setwd() 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 from tempfile()) 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-string sep was then passed straight through to data.table::fread(), which does not treat sep = "" 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 passing fread() its own sep = "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 literal 0.2/"20%" – config.R’s own default for qc.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 from cfg$qc$maf/cfg$qc$max_sample_missing respectively; this was the one figure that was missed, so any user who actually changed qc.max_variant_missing got 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 – but manuscript_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”, but base::system2() only shQuote()s the executable itself (paste(c(env, shQuote(command), args), collapse = " "), per its own source) — the args and env parameters 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 through run_supervised_external_command()’s processx::run() path instead, which spawns without a shell), but run_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 by shQuote()-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 own commandArgs()/Sys.getenv()) and confirms both survive as literal values with no injected file created; git stash bisection 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/.pdf template (inst/rmarkdown/templates/popgenvcf_report/skeleton/skeleton.Rmd), not a one-off document, so every future run’s report carries it. Every report_table_section() call (and the hand-written AMOVA/sex-biased-dispersal blocks) now takes an optional note, 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 as wiki/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.css gives 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 the report_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) adds xcolor/titlesec section-heading color and a framed-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’s figure-gallery chunk) is a single flat, numerically-ordered loop over every figure file with no per-module grouping or explanation at all. Added report_figure_family_catalog(), an ordered list of (stem regex, heading, note) entries built directly from the real save_plot()/sprintf() stem patterns across R/*.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 shared 14_..._Q_K<k> shape); both the HTML and PDF figure-gallery loops now call a shared report_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_manhattan sorts 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 synthetic 99_unknown_future_stem fixture. Verified end-to-end with a synthetic multi-family figure set across both formats (correct heading/note placement, correct graceful degradation, test-report-figures.R still 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 alongside table { 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 independent display: table makes 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 the thead, tbody, tr rule 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 .tsv tables by write_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 each plot_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 the ne_ld module (LD-based effective population size, Waples & Do 2008) silently reported Ne = Infinity for every single population, and its figure was entirely missing from the report. Root-caused against the real cached GDS rather than guessed: SNPRelate::snpgdsLDMat() returns NA for 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) in ne_ld_one_population() (R/ne_ld.R) had no na.rm = TRUE, so a single NA pair silently collapsed the entire population’s mean_r2 to NaN, 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 were NA across every population in the run, including a 32-sample one. Fixed by filtering to valid (non-NA) pairs before computing both mean_r2 and harmonic_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 the min_pairs threshold on the valid pair count, not the raw candidate count. Re-run against the real production data: Ro1 now reports a genuine finite Ne = 5.69 (status: ok) where it previously showed Inf; Ro2 and TBD now correctly report status: below_formula_domain (implying an even smaller effective size than the formula’s valid domain covers) instead of a wrong Inf/ok; Ro4 and Ro5 (n=2 and n=4) still correctly report Inf, now for the right reason (a genuinely negative bias-corrected r2, not a NaN-poisoned mean). This bug has existed in every release since the module was added (685bfe2, 2026-08-10) – every release from v1.0.0 through v1.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 raw SNPRelate::snpgdsLDMat() probe that this specific degeneracy – a fixed/homozygous locus, not merely uniformly heterozygous – is what actually triggers NA, not just any zero-variance case) and fails 4 ways against the pre-fix code, passing clean against the fix. The affected production run’s analysis_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’s r-*/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 was vignettes/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, inflating inst/doc/interpreting-results.html alone to 47.7 MB. Resized every PNG in vignettes/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 actual R CMD build --compact-vignettes tarball 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$ .Rbuildignore entry – actual submission is a separate PR to the external bioconda/bioconda-recipes repository, reviewed on bioconda maintainers’ own timeline). Its dependency list mirrors inst/conda/environment.yml’s required set exactly, but is deliberately narrower on the run: 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 required SystemRequirements) and Pandoc (exercised by every report-rendering code path) stay as required runtime dependencies. build.sh also installs a thin popgenVCF CLI wrapper into $PREFIX/bin, since the R package source tarball intentionally excludes the repository’s own popgenVCF.R (a commandArgs()-based cli_main() wrapper for local/Docker use). The recipe is structurally checked – Jinja-rendered YAML parses correctly, every listed dependency confirmed to resolve today via mamba search, no r-popgenvcf name collision, and the test: block’s popgenVCF --help invocation is a real, working cli_main() code path – but has not yet been linted or built with real bioconda-utils tooling (not installed in this environment), spelled out honestly in packaging/bioconda/README.md alongside the note that version/sha256 need re-pinning to whichever release is actually submitted.

    Documented the future mamba install r-popgenvcf command in wiki/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) and wiki/Getting-Started.md’s pointer to it, alongside the existing, genuinely-working environment.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), and wiki/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 in run_ml_tree() (R/ml_tree.R) – the main phangorn::optim.pml() fit, the phangorn::bootstrap.pml() replicates, and the returned result’s own model label – with no config knob at all. Added analyses.ml_tree.model (default "GTR", keeping the previous hardcoded behavior as the default), validated in validate_config() against the complete list of nucleotide substitution models phangorn::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 new ml_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’s model column 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-agnostic ML_tree_gamma_ASC.nwk so 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 model parameter 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 calls update.pml(), which – like R’s own lm()/glm() and much other S3 modeling code – stores its unevaluated call (via match.call()) on the returned object for later re-fitting. A bare-variable argument captures the symbol model, 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 local model variable, and fails with object 'model' not found on 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 the optim.pml() and bootstrap.pml() calls to do.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 fixes pair_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 a genlight object instead of poppr’s genind-based pair_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 raw Ia, 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 isolated poppr::poppr() call crashes: new clonality_genlight_from_matrix() builds a genlight directly from the same 0/1/2 dosage matrix already used elsewhere in this file (no genind round-trip needed), clonality_run_bitwise_ia_isolated() runs bitwise.ia() isolated in a forked child (mirroring clonality_run_poppr_isolated()’s own crash-containment pattern, even though this path has not been observed to crash), and clonality_bitwise_fallback_summary() computes rbarD for every recorded population plus the pooled “Total” row (an NA for 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 is pairs 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/mlg etc. simply read NA on 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 full run_clonality() integration path (mocking clonality_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 wrapped plot.subtitle text, 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 44 ggplot2::labs() calls across R/ 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. Renamed wrap_plot_subtitle() to wrap_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, across R/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, and R/spatial_autocorrelation.R, and R/structure_k_consensus.R. For the table borders: report_kable()’s HTML branch (skeleton.Rmd) renders via knitr::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. Added inst/rmarkdown/templates/popgenvcf_report/skeleton/report.css (a table { border-top; border-bottom } rule) wired into R/report.R’s html_document() call for the HTML format only, restoring the same visual convention without adding a kableExtra dependency. 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 production analysis_results.rds to 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 through kpathsea’s mktexnam/mktexmf METAFONT-synthesis fallback to an emergency stop. pzdr is the Adobe/URW “Zapf Dingbats” PostScript font that psnfss (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 cached analysis_results.rds locally rendered the PDF cleanly, with the resulting .tex containing zero references to pzd, Pisymbol, or dingbat. The real difference was the environment, not the content: this machine’s TeX Live has texlive-fonts-recommended installed (confirmed via dpkg -S, the exact package that ships pzdr.tfm); the container’s Dockerfile and Apptainer.def install LaTeX via apt-get install --no-install-recommends texlive-xetex lmodern [ghostscript], and --no-install-recommends silently drops texlive-fonts-recommended (a Debian Recommends, not a hard Depends, of texlive-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 exact apt-get line has no pzdr.tfm (kpsewhich fails) and cannot cleanly load the sibling PS35 “Symbol” font family either (a minimal pifont test document degrades to garbled “Missing character” output); the same probe rebuilt with texlive-fonts-recommended added resolves both cleanly. Fixed by adding texlive-fonts-recommended to both Dockerfile and Apptainer.def’s LaTeX install step. While fixing Apptainer.def, also brought its LaTeX toolchain to parity with Dockerfile by adding ghostscript (already present in Dockerfile, absent from Apptainer.def), which compress_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 through wrap_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/popgenvcf image (built from the root Dockerfile) does include everything the new n_pcs = "auto" feature needs – LEA installs by default via inst/scripts/install-bioconductor.R – but the investigation surfaced a second, unrelated containers/ directory (Dockerfile, popgenVCF.def) that neither container.yml nor apptainer.yml has ever built from (the real, actually-used definitions are the root Dockerfile and Apptainer.def), unreferenced by any doc, and stale on every axis that matters – no LEA, 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.yml fixes 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 new test-cli.R check 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 against default_config(). Added a test that loads inst/example_config.yml (the literal file --write-config now ships, per the entry below) via read_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 through validate_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, as run_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_pcs can be \"auto\"?”, which surfaced that --write-config and the bundled inst/example_config.yml had already drifted apart. --write-config serialized template_config() (an R list with no comment metadata, via yaml::write_yaml()) from scratch – a completely separate artifact from inst/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.enabled was true in the actual default (R/config.R) but false in 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, unquoted Y is a legacy YAML 1.1 boolean literal, so it parsed to TRUE instead of the string "Y" – the sibling field non_autosomal_chromosome_names already 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 in inst/example_config.yml, then redesigned write_default_config() (R/cli.R) to copy inst/example_config.yml directly instead of serializing template_config(); template_config() itself is removed (it had become a trivial default_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_mb auto-detection had no safety margin at all. detect_available_threads()/detect_available_memory_mb() (R/system_resources.R, used by default_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 --resume feature in the first place (an OOM kill is exactly the failure mode --resume exists 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” in docs/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-config vs. inst/example_config.yml updated 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-based write_default_config() (byte-identical to the reference file, real comments present, the two fixed fields parse correctly) and drop the now-removed template_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 raw runs table verbatim – including executable, log_file, and q_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. executable is also identical on every K row, adding nothing per-row. Fixed by keeping only K, exit_status, and marginal_likelihood – full paths remain available in analysis_results.rds for 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 uses facet_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 a strip_text_size the 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 setting strip.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 stale pca: requested 10 bounded component(s) message from before this session switched the run to n_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 several analysis_results.rds$results$pca fields (requested_components, retained_components) that a real run_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 production analysis_results.rds: re-ran run_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_PC2 figure. Root cause: write_pca_publication_artifacts() (R/pca_publication.R) writes its own standalone, publication-ready PCA_PC1_PC2.pdf/.svg/.png – meant for a user to pull directly into a manuscript – into the exact same figures/ directory that report_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 own save_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 having report_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 via wrap_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 an always_tracy_widom argument (default FALSE, preserving every existing caller’s behaviour unchanged, e.g. the per-chromosome PCA in run_chromosome_analyses()): when set, a fixed n_pcs still 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 missing LEA installation or any other failure degrades to simply not drawing the comparison figure (WARNING logged), 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 passes always_tracy_widom = TRUE, so every real report gets this comparison regardless of whether n_pcs is "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 fixed n_pcs the 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 fixed n_pcs, graceful degradation when LEA is unavailable (mocked), and both single- and dual-reference-line rendering in plot_pca_tracy_widom(). One pre-existing source-pinning test (test-pca-runtime-eigenvalues.R) was updated to match: eigen.cnt is now driven by a dedicated eigen_cnt variable (which itself derives from requested_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 own tracy.widom() example plot). Added plot_pca_tracy_widom() (R/ordination.R), a scree-style plot of percent-of-total-variance vs. component index, only produced when analyses.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, and plot_pca() draws the new figure automatically as its first step whenever that table is present (NULL/absent for a fixed n_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 as min(3L, cfg$analyses$n_pcs) – with n_pcs = "auto" (a string), this silently coerces 3L to "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 explicit if (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. fixed n_pcs, the plot’s highlighting and no-op behaviour, and plot_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?”. Added n_pcs: "auto" as an opt-in alternative to a fixed integer, using the same sequential Tracy-Widom eigenvalue significance test EIGENSOFT’s smartpca uses (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 via pca_significant_component_count() (R/ordination.R), which calls LEA’s compiled R_tracyWidom routine directly rather than through LEA::tracy.widom() (an S4 method hard-tied to LEA’s own file-based pcaProject PCA 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 at min(n_samples - 1, n_snps, 100)), and run_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 for analyses.n_pcs and 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 the LEA package (already an existing optional Suggests dependency 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-end run_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 one 12c_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 with dapc_eigenvalues_inset() (a minimal theme_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 via ggplot2::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-NULL edge 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-end plot_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, generous max_pca as the search ceiling, keeping the small fallback strictly for the no-cross-validation case it was designed for. Also reverted plot_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 any n.da request 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 switching run_module_dapc() to context$final_snps, subsetting the already-loaded diversity genotype matrix by column position exactly the way run_module_clonality() already does for its own LD-pruned copy (final_snps is always a subset of qc_snps, so the match() is a safe positional lookup). Cuts marker count ~10x, which scales directly to both xvalDapc()’s cost and every replicate’s own model-fitting cost. New assertion added to the existing real end-to-end test-pipeline.R integration test: every DAPC loading’s snp_id is 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() – unlike adegenet::glPca(), used everywhere else in run_dapc_k_task() (R/dapc.R) – calls ade4::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 bare tryCatch(..., error = function(e) NULL) with no log line at all, silently falling back to a crude floor(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) as xvalDapc()’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 from 0.8 * n_samples to 0.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 more xvalDapc() 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, mocked xvalDapc() capturing its data argument), and the now-logged failure path. Existing test-dapc-xval.R, test-dapc-loadings.R, test-dapc-plots.R, test-module-dapc-spec.R, test-publication-dapc-outputs.R suites 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+\dp simply 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 \resizebox had to shrink every column, including short numeric ones, to fit. Fixed with report_kable_render(): tables now render via kable(..., longtable = TRUE) with \endhead inserted by hand so headers repeat on every page (base knitr doesn’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 \resizebox behaviour, since \resizebox cannot wrap a longtable at all and an always-longtable earlier version of this fix regressed the common case (a real 10-32-column table still overflowed \textwidth even at \scriptsize, discrete sizing’s smallest step). Long free-text columns (character values over 40 characters) are now wrapped in \parbox instead 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 \ht alone (confirmed directly – \ht alone 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 a results='asis' chunk, rendering as a literal visible brace instead of functioning as grouping – fixed by scoping with \begingroup/\endgroup throughout 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 new wrap_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’s eig vector already holds exactly n.da retained values, adegenet’s dapc() 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 just plot_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 with pdftotext/pdfinfo for 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. Existing test-pipeline-resume.R, test-dapc-plots.R, test-dapc-parallel.R, test-qc-reports.R, test-qc-contract.R suites 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 Ghostscript pdfwrite post-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 logged WARNING, not a hard failure) when gs isn’t on PATH, 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 as rmarkdown::render() produced it. ghostscript added to both Dockerfiles alongside the existing texlive-xetex/pandoc PDF toolchain. New tests cover both the missing-gs fallback and a real compression pass (mocked Sys.which() for the former; skip_if_not() on real gs availability for the latter). Existing test-pipeline-resume.R and test-report-figures.R suites 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 .pdf for 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 in report_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. Existing test-pipeline-resume.R and test-report-figures.R suites 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 full analysis object – 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 whatever xelatex/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 external SIGKILL (OOM-killer or similar), which bypasses run_stage()’s tryCatch entirely – it can’t log what it never gets a chance to catch. Fixed by dropping analysis (rm(); gc()) right before the report-rendering call and reloading it from the analysis_results.rds just 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 existing test-pipeline-resume.R (29 tests, including report rendering across multiple checkpoint-resume scenarios) and test-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-if type instability, and algorithmic scalability at real production scale. The 1:n audit came back genuinely clean (this codebase consistently uses seq_len()/seq_along() throughout – only two colon-range constructs exist anywhere in R/, both already safely guarded) and sapply() is never used at all (this codebase exclusively uses vapply()). The other two found real, live issues.

    1. 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) assumes x already has length 1. A malformed config – a realistic mistake, e.g. an accidental YAML sequence like maf: [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 (DESCRIPTION requires 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 vectorized any() over a combined value vector – both individually in-range elements passed that check silently, and neither field was ever coerced back to scalar afterward, so validate_config() returned a genuinely malformed length-2 config with no error raised at all. Fixed with a single, comprehensive scalar-length guard added early in validate_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-optional pcadapt_k.

    2. 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.

    1. 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) and run_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 with method = "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 asserts population_palette()/population_shapes() output matches the fixed radix order directly, without needing to actually switch locales in CI.

    2. 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() returns NA for such a pair; rs/sgd already used na.rm = TRUE (showing this was anticipated), but the per-bin cx/cxii/cxjj sums downstream did not, so a single NA pair propagated into denom, and if (denom == 0) on an NA denominator is a hard R error, not the graceful NA_real_ this function clearly intends for the analogous zero-denominator case. Fixed with na.rm = TRUE on all three sums and an explicit is.na(denom) guard.

    3. haversine_matrix() (R/mantel_ibd.R, shared by run_mantel_ibd() and run_spatial_autocorrelation()) could return NaN for real geographic coordinates. Its intermediate a term 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 unclamped sqrt(1 - a) silently returns NaN (a well-known haversine-formula pitfall). Confirmed directly with an exact antipodal coordinate pair (lat -89.26/89.26, lon -180/0) that triggers a > 1 on this platform. Fixed by clamping a to [0, 1] before both sqrt() calls; new test verifies the same pair now returns the correct antipodal distance (~20015 km) instead of NaN.

    4. run_tree_bootstrap_replicates() (R/tree_bootstrap.R) – reached via build_nj_tree()/build_population_tree() – was the only remaining mclapply() path in the codebase that didn’t route its worker count through fork_worker_count(), the shared Windows/task-count/thread-budget guard every other fork site uses. Both real callers passed cfg$compute$threads straight through as workers. Not an active crash – mclapply() itself already forces mc.cores = 1 on Windows – but a real, live inconsistency that also meant this path skipped fork_worker_count()’s other protections (NA/zero/negative-threads coercion). Fixed by re-deriving workers via fork_worker_count() inside the shared helper itself, closing the gap for every current and future caller at its root; new test proves an NA workers value (which would have hit if (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, /tmp literals, and executable resolution – came back clean (a minor, non-live file.access() platform-semantics note in R/vcf_input.R was 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.

    1. 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 – and readRDS(), 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, and wiki/Deployment-and-Troubleshooting.md’s --resume section were all corrected to state the real trust boundary plainly: only ever resume from an output.directory your own runs produced, never one downloaded, received from a collaborator, or restored from storage you don’t fully control.

    2. 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 without shQuote()-ing the dynamic file paths. command_runner’s default, system2(), was confirmed to shell-interpret its args vector on Unix (base R joins command and args into one string run through a shell) – an output.directory path containing a shell metacharacter would otherwise either break or be silently shell-interpreted, unlike every other external-tool call site in this codebase, which already shQuote()s dynamic path arguments (and unlike this same module’s own PLINK/ADMIXTURE binary invocations, which correctly route through the shell-free processx::run() instead). Fixed to shQuote() both dynamic paths; new test proves the arguments handed to the command runner are properly quoted.

    3. CI: the public-api-contract.yml and release-metadata.yml workflows’ own gate-record-writing steps had no if: 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.yml dispatches 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 local testthat suite.

    Everything else checked in this pass – bcftools/PLINK/ADMIXTURE invocation elsewhere, path handling for VCF/metadata-derived content, executable resolution, the scripts/*.R CI entry points’ control flow and exit-code correctness, and the whole tests/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.R too) – but the canonical-baseline and benchmark/regression layers turned out to be genuinely live, called from real scripts/*.R tooling 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:

    1. 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 have status = "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 via candidates[[length(candidates)]], where candidates is 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 own supersedes field 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, reaching release_ready = TRUE. Fixed: a new .resolve_governing_approval() excludes any approval a same-metric approval’s supersedes field 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.

    2. 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 – including scripts/build_release_benchmark_archive.R’s own archived run_scientific_validation()/run_population_structure_validation() $checks tables) 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 $checks table’s passed column going from all-TRUE to not-all-TRUE) was silently reported as "changed"/overall "passed", defeating that script’s own release-gate stop() (which only fires on comparison$status == "failed"). The existing test literally asserted this exact behavior as correct. Fixed with a new .component_validation_regressed() check: a component’s own passed/valid field (scalar or column) going from all-TRUE to not-all-TRUE now 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 on threads, 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 the ancestry_*.R family 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 by structure_k_consensus.R’s cluster-number diagnostics) filtered candidate ratios with a blanket is.finite() across each group’s full distance row – correctly excluding the meaningless i == i self-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 as NA. Fixed to exclude only the self-term explicitly, letting a real zero-distance-between-distinct-groups case correctly propagate to NA instead. 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:

    1. 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 own 100 * eigenvalues / sum(eigenvalues) is only the true percent of total variance when eigenvalues is the complete spectrum – R/ordination.R already has its own verified comment about exactly this pitfall and correctly avoids it for 13_PCA_variance.tsv (using SNPRelate’s own total-trace-normalized varprop), but the publication-output sibling was never given the same fix. With the default n_pcs = 10 and 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 optional variance_percent parameter for the correct, pre-computed figure; run_module_pca() now passes pca$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.

    2. 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 loud stop(); 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.

    3. AMOVA and Mantel/IBD permutation counts were hardcoded 999L with no config knob, unlike the structurally identical spatial_autocorrelation_permutations, sexbias_permutations, and clonality_ia_permutations (all genuinely configurable). New analyses.amova_permutations/analyses.mantel_permutations (default 999, validated >= 1 since – 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).

    4. A dead-but-real landmine: no registered analysis module currently sets parallel_safe = TRUE (verified: zero of them do), but if one ever does while context$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, and merge_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 when context$gds is populated, erroring clearly with the fix (give the module its own independent GDS connection per task, the way diversity.R/fst.R already 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 from run_pipeline() (checkpoint/resume was chosen as this project’s actual mitigation for exactly the incidents that layer would otherwise guard against); and execution-timeout.R’s base::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 the R/publication_*_outputs.R/R/manuscript_*.R/R/report_engine.R files it was asked to check are not actually reachable from the real pipeline, any documented workflow, or vignettes/publication-gallery.Rmd at 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:

    1. write_manifest() (R/report.R) recorded hardcoded variant_missing/ld_r2 values of 0.2 in run_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-default qc.max_variant_missing/qc.ld_r2 produced a provenance record that silently lied about what was actually used. Fixed to read cfg$qc$max_variant_missing/cfg$qc$ld_r2 directly; new regression test.

    2. A silent Fst pair-misattribution bug in run_fst(), and the same underlying gap at three more mclapply() call sites. Like the report-rendering crash fixed earlier this cycle, a forked worker killed by a signal (OOM, segfault) returns NULL, not a classed "try-error" – but run_fst()’s parallel path only checked for "try-error", so unlist(results) would silently shrink the Fst-value vector, and the following Map() 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 in execute_dapc_k_tasks() (R/dapc.R), compute_diversity() (R/diversity.R), and both run_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-tested check_mclapply_results() helper (R/utils.R) used at all five sites (plus render_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 return NULL, exactly what a killed worker’s mclapply slot actually contains) plus a thorough direct suite for the shared helper itself.

    3. 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’s build_one already wraps its own body in tryCatch(..., 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’s NULL wasn’t either. Both bootstrap_population_nj_tree() and bootstrap_nj_ibs_tree() then reported replicates = 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 (matching bootstrap_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 full bootstrap_population_nj_tree() integration case with real, deterministic ape::nj() failures.

    4. Minor UX gap: --resume combined with any override flag other than --config silently 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 --resume got no indication otherwise. cli_main() now warns, naming which flag(s) were ignored; wiki/Deployment-and-Troubleshooting.md and cli_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.6 cuts 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 .tsv to a path nothing tracks, and a .dcf missing the Snapshot-Encoding/Snapshot-SHA256 fields public-api-contract.yml’s own verification requires – it could never have actually produced a working baseline. Rewritten to write the real tracked inst/api-contract/public-api-baseline.tsv.gz.b64/.dcf format, shelling out to the real gzip/base64 CLI tools (R’s own memCompress(type = "gzip") was confirmed to emit a zlib stream, not true gzip, on this platform – gzip --decompress rejects it), and strips a .9NNN dev-version suffix before writing Version, matching public-api-contract.yml’s own reconciliation logic. Second, a new scripts/bump_release_version.R consolidates 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 against scripts/validate_release_metadata.R/scripts/validate_zenodo_metadata.R before exiting successfully, which caught a real bug during development: resetting software-identity.json’s release_status/date_released/doi to development/null/null without also removing the equivalent DOI/date fields from CITATION.cff (date-released, the identifiers: 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 touch docs/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”): --resume was 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_DIR is documented to read “no --config or 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 optional config argument (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, new config_fingerprint field on PopgenVCFExecutionCheckpoint) 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.seed values are baked into context (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 --resume with no --config is completely unaffected either way. New tests cover matching/differing/legacy-checkpoint cases directly. wiki/Deployment-and-Troubleshooting.md gained the --resume documentation 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, and poppr::genotype_curve() names every single one via an unconditional message() call that isn’t gated by its own quiet argument (confirmed against the installed poppr source) – polluting the pipeline’s console output.

    clonality_curve_summary() (R/clonality.R) now intercepts that specific message with withCallingHandlers(), 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 new 58c_monomorphic_loci_dropped.csv. No public API changes; both functions are internal. New unit tests in test-clonality.R cover 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”) against default_config() for the requested completeness found it was a real, non-trivial gap: it silently omitted the entire ml_tree, pcadapt, clonality, and sexbias analyses.* blocks (all real, current keys), and admixture.q_sample_file/faststructure.q_sample_file turned out to be dead config – accepted by the schema but never read anywhere (unlike the real, consumed snmf.q_sample_file), since PLINK’s own .fam file already carries authoritative sample order for those two backends.

    inst/example_config.yml now documents every analyses.* key default_config() defines, verified by an exhaustive key-diff against it (not just spot-checked), including honest comments on the two dead q_sample_file keys 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 against R/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 the admixture/faststructure q_sample_file ones found earlier: qc.ld_slide_max_bp/ld_slide_max_n/ld_start_pos were silently reset by validate_config()’s “fixed QC contract” with no warning (unlike ld_r2, which shared that reset and did warn), and analyses.structure.minimum_cluster_correlation was validated but never compared against anything – its sibling reproducibility_rmse gates a real DAPC-instability warning, this one didn’t gate anything. Also found: a real bug in vignettes/getting-started.Rmd (popgenVCF::list_analyses() shown called with no argument, which errors – the function requires one), a stale wiki/Home.md landing-page blockquote still describing the 0.10.0 release, an undocumented dual role for the metadata individual column (it doubles as a sample-identity synonym when no sample/sample_id/id column exists), and an unused patchwork dependency in DESCRIPTION.

    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 takes ld_r2/slide_max_bp/slide_max_n/start_pos/max_missing arguments and threads them into SNPRelate::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. Making max_variant_missing configurable surfaced a second, independent real bug found while wiring it through: run_pipeline()’s main variant-QC stage (R/pipeline.R) was calling variant_qc() with a hardcoded 0.2 literal, not cfg$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 read cfg$qc$max_variant_missing. minimum_cluster_correlation is now wired up in dapc_reproducibility_annotation() (R/dapc.R) and validate_dapc_result() (R/validators.R), alongside reproducibility_rmse, both gating DAPC’s own replicate-stability warning. Fixed the four documentation issues above; inst/example_config.yml and wiki/Configuration-Reference.md document 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 (mocking SNPRelate::snpgdsLDpruning(), and a synthetic-missingness fixture proving max_missing changes 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()’s mclapply()-based concurrent HTML+PDF rendering only detected an R-level try-error; a forked worker killed by a signal (OOM, segfault) returns NULL instead, which unlist() 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 new report.max_concurrent_figures config 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 a parallel argument for this.

    Investigating the clonality runtime found the real cause: poppr::poppr()’s Ia/rbarD computation does not drop monomorphic loci the way poppr::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 new clonality_monomorphic_loci() (vectorized, ~0.7s even at 560,000 loci) – before any of the expensive poppr calls, rather than relying on genotype_curve()’s own later, curve-only detection. New 57b_monomorphic_loci_dropped.csv (full unpruned set, before 57_MLG_groups.tsv) joins the existing 58c_monomorphic_loci_dropped.csv (LD-pruned set); both are also now surfaced as INFO-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, as snpgdsVCF2GDS()’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, and vignettes/getting-started.Rmd now 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) runs bcftools stats once (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 match SNPRelate’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 new 00_vcf_variant_types.tsv (first table in every run’s output, before any QC), logs a one-line summary, and records an INFO-level pipeline message. That last part exposed a real, separate, pre-existing gap: analysis$messages (every WARNING/INFO message 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 carries messages through, and a new “Pipeline notices” section (skeleton.Rmd) shows every WARNING/INFO message 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 optional panel_height_in parameter (the actual physical height, in inches, of the one facet panel or single-panel figure the label row belongs to); when in vertical mode, pad is 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.R x3, 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_in is 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 far manhattan_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 pushing plot.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_in fix 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 problem panel_height_in was supposed to prevent, just reintroduced through a different mechanism. panel_height_in’s clearance math grew pad, a value in the plotted data’s own y-units, to guarantee a rotated label’s full physical length fit below the panel – but pad participates 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()’s panel_height_in parameter and pad-inflation block are removed entirely. pad is back to the same small, fixed 14%-of-range value used in horizontal mode, unconditionally; all real clearance instead comes from plot.margin (points, a physical-device quantity entirely outside the data coordinate system) combined with coord_cartesian(clip = "off"), plus a smaller fixed 7pt vertical-mode font (independent of base_size, down from a base_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 in test-manhattan-chromosome-row.R pin the fixed pad value and the fixed font size directly.

  • Fixed a second, distinct real squeeze found regenerating the pcadapt figure specifically while verifying the fix above: plot.margin is a fixed point quantity that does not scale with the caller’s chosen plot height, and on pcadapt’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 a manhattan_extra_height_in attribute 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 real pcadapt figure (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 and save_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 than range(contribution) alone would suggest, silently invalidating the externally pre-computed y_range that plot_pca_loading_manhattan()/plot_genome_scan() pass into manhattan_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 and genome_scan.R’s FST and Tajima’s D panels all draw a geom_hline(yintercept = 0); the diversity panel and DAPC-loadings have no such line and needed no change – by computing y_range as range(c(data, 0), na.rm = TRUE) instead of range(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 in test-pca-loadings.R pinning the exact mechanism: a synthetic low-variance PC facet with a narrow range far from zero, asserting the label’s computed y-position matches the range(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 calls adegenet::xvalDapc() to pick the number of retained PCs by cross-validated assignment success whenever analyses.dapc_cross_validation is 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 new plot_dapc_xval() draws that curve straight from the already-retained cv object 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 into plot_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’s xvalDapc()” request directly against the installed packages first: there is no separate poppr implementation to switch to – xvalDapc and 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’s xvalDapc(..., parallel = "multicore", ncpus = 4L) example – real, but it’s adegenet’s own ... being forwarded three calls deep into boot::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 own xvalDapc(..., xval.plot = TRUE) default plot’s exact axis labels, currently suppressed (xval.plot = FALSE) in favor of the mean-curve-only plot_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: this xvalDapc() call resamples with sim = "parametric", which per boot::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 gave n.pca = 10 (replicate RMSE ~0, fully reproducible) serial vs. n.pca = 40 (RMSE 0.076, exceeding the stability threshold) with parallel = "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 reimplementing xvalDapc()’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 (from cv’s already-retained, previously-unused Cross-Validation Results raw 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. New plot_dapc_eigenvalues() (12c_DAPC_eigenvalues_K{k}) draws the standard barplot(dapc$eig, ...) DA-eigenvalues diagnostic as a ggplot2 bar chart – a second, independent way to sanity-check the retained axis count (this one validates n.da coming out of dapc(), plot_dapc_xval() validates n.pca going 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 into plot_dapc(), and that run_dapc_k_task()’s xvalDapc() call never requests boot::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 used ggplot2::geom_jitter()’s default, unseeded position_jitter(), so the exact same retained cv object 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, so clonality’s already-existing LD-pruned genclone object (the same one poppr()’s Ia/rbarD summary and the genotype accumulation curve already use, for the same reason: approximately independent loci) instead feeds the general poppr::poppr.msn(), with poppr::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 to provesti.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_network figure (base-graphics, like the NJ trees, via poppr::plot_poppr_msn() – no ggplot2-native equivalent exists) and 58b_MSN_edges.tsv (every edge’s endpoints and genetic distance), computed and plotted whenever clonality’s LD-pruned set is usable, degrading gracefully (a new msn_failed flag/WARNING, independent of the existing poppr_failed path) rather than failing the whole module otherwise. One real layout bug found rendering the actual figure: plot_poppr_msn() builds its own multi-panel graphics::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-margin mtext(..., outer = TRUE) instead, unaffected by which inner panel is active. New required dependency igraph (a real, already-present transitive dependency via poppr itself, not a new capability) added to DESCRIPTION and environment.yml, confirmed by the existing test-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.tsv directly 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 both vignettes/interpreting-results.Rmd and the wiki’s mirrored Results-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 file during 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’s self_contained = TRUE finalization step embeds every image and then deletes that directory once done – which can race ahead of the PDF format’s xelatex/xdvipdfmx compiler 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_dir isolation (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 to render_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, and manhattan_chromosome_row() compares each contig’s real label width (measured via graphics::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” idiom manhattan_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_in directly, 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 real manhattan_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 existing test-pca-loadings.R/test-dapc-loadings.R coverage confirmed to still pass unchanged. No public API changes; manhattan_chromosome_row()’s new plot_width_in parameter 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 shared log_msg() destination at a withr::local_tempdir() that was later cleaned up, without resetting it back to NULL afterward – every subsequent log_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 in test-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 in tryCatch(), 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_file on exit, matching the convention already followed by test-pipeline.R, test-quickstart-dataset.R, and test-autosome-filtering.R. Verified: the full suite’s previously-flaky test-phase10-runtime-binding.R failures and the new --resume integration tests (which hit the identical hazard once added) both now pass reliably; a direct repro (pointing log_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 the clonality fix below: a real 44-hour run on a 561,767-locus cohort had to be docker stop-ped and restarted from zero once the clonality fix 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 into run_pipeline() or the CLI. Two designs were considered: checkpoint only at cooperative-cancellation points (cheap, but does nothing against a real docker 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) and execute_analysis_registry() (R/registry.R) both gained an optional, backward-compatible checkpoint_path parameter; when set, a checkpoint is written after every batch completes, regardless of what runs next. run_pipeline() passes <output_directory>/execution_checkpoint.rds in automatically. A live gdsfmt/SNPRelate GDS connection is an external pointer that cannot survive serialization at all – the same fork-safety fact behind this file’s earlier diversity/fst/genome_scan parallelization entries turns out to also apply to saveRDS()/readRDS(), not just forking – so the new execution_checkpoint_safe_context() strips context$gds before every checkpoint is built (including the pre-existing cooperative-cancellation checkpoint path in R/execution-cancellation.R, which had the identical latent bug); a resumed run re-opens its own fresh, read-only connection from the plain context$gds_path string instead. New run_pipeline_resume(output_directory, registry) reads the checkpoint, reopens the GDS handle, and calls the existing resume_analysis_execution() to run only the modules the checkpoint doesn’t already have – rebuilding the checkpoint object from scratch via new_execution_checkpoint() rather than mutating the validated one in place, since checkpoint_digest covers 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 normal run_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 --config or 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 new test-cli.R coverage for the flag itself, and a real, full CLI-level rehearsal of the actual incident: ran the quickstart dataset through diversity/fst/clonality, kill -9-ed the real R process at the exact moment clonality finished but before that batch’s checkpoint had hardened (the genuine worst case), then confirmed --resume correctly picked up from “2/3 modules complete,” re-ran only clonality, and produced a normal, complete analysis_results.rds – with no duplicated or corrupted output. No public API changes beyond the new exported run_pipeline_resume() and the two additive checkpoint_path parameters.

  • 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() built sequential$step ("Input biallelic", "After MAF", "After missingness", "After LD pruning") and independent$criterion as 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 inside qc_reports() itself – so every consumer (the plot, the TSV files, the report’s kable() 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 bypasses qc_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 actual 07_QC_sequential_counts.tsv.

  • Fixed a real production incident: clonality took 29+ hours (still running when investigated) on a real 50-sample, 561,767-locus unpruned cohort, entirely inside poppr::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 routing poppr()’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 proving mlg.id() uses the full set (a genotype pair that only matches on the LD-pruned subset is correctly not flagged as a duplicate) and poppr()/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. See R/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 fst and genome_scan’s remaining serial loops, using three different task shapes rather than one copy-pasted pattern. The last of the modules surveyed alongside the diversity parallelization and population_tree vectorization above, on the same real production cohort. (1) run_fst()’s pairwise loop: each population pair’s snpgdsFst() 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 like diversity’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 the diversity entry below). (2) run_genome_scan_fst()’s per-window loop: a genome-wide scan has thousands of windows, each individually cheap (one snpgdsFst() call over a handful of SNPs) – forking one process per window the way fst/diversity fork per task would pay fork overhead thousands of times over for little work each, so windows are instead split into exactly workers contiguous chunks via parallel::splitIndices() (the same balanced-partitioning helper mclapply() 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.table work 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 as diversity’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 behind cfg$analyses$bootstrap$enabled. Verified with new unit tests (test-fst-parallel.R, test-genome-scan-parallel.R: parallel output matches serial to 1e-12, sequential fallback confirmed when no gds_path is 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 identical fst/genome_scan output between threads = 1 and threads = 3.

  • Vectorized population_tree’s per-SNP allele-count matrix assembly, replacing an R-level for loop with two whole-matrix column assignments. Found alongside the diversity parallelization 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 that adegenet::genpop()/dist.genpop() consume for Nei’s distance, from the full diversity_full$locus table (the same 500K+ QC-passing-locus scale as the diversity module itself, not the LD-pruned subset) – but did so with a for loop assigning two columns per SNP one iteration at a time, an R-level cost that grows with locus count on every real run since population_tree is enabled by default. Replaced with two vectorized assignments (tab[, ref_cols] <- as.matrix(ref_wide[, snp_ids, with = FALSE]), same for alt_cols) that do the identical interleave via whole-matrix column indexing; both wide tables are still indexed by the same snp_ids name vector rather than assumed column order, so this stays correct even if dcast() 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 existing test-population-tree.R (26 assertions, including the Nei’s-D hand-calculation and monotonic-divergence checks) and test-tree-bootstrap.R suites both pass unchanged.

  • Parallelized the diversity module’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-running clonality module (separately confirmed genuinely slow rather than hung, via live ps/stat inspection of their production log) could be sped up with parallelism, and whether other modules could benefit similarly; diversity was the clearest win, since its per-population HWE/allele-frequency/private-allele computation is already an embarrassingly-parallel lapply over 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 no gds_path is available (e.g. the small CI fixture in scientific_validation.R), so behavior there is unchanged. Generalized DAPC’s existing dapc_worker_count() fork-sizing helper into a shared fork_worker_count() in R/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 clear stop() 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 to 1e-12, sequential fallback confirmed when no gds_path is supplied, and a forced per-worker failure surfaces the expected error) and a real end-to-end pipeline run confirming byte-identical diversity output between threads = 1 and threads = 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_column and enabled metadata-gated analyses by default in the generated --write-config template. Both prompted by the same real user’s feedback. The new config fields let a differently named metadata column (e.g. pathotype) be treated as sample/population without renaming it in the file; an explicit value always wins over the built-in synonym auto-detection, even over an existing literal sample/population column (erroring on that specific ambiguity rather than silently discarding one). Writing this surfaced a real, previously-undiscovered bug: the existing header normalizer applied gsub()’s deliberately lowercase-only character class before tolower(), 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-ordered normalize_metadata_name() helper. Separately, template_config() no longer force-disables population/geography-gated analyses: every one of those 16 fields already skips automatically (with a WARNING) whenever its required metadata is genuinely absent, so the override was pure friction for users who do have complete metadata. ml_tree and the ancestry backends stay off by default, unrelated to metadata. See NEWS.md’s next entry and docs/ROADMAP.md for full detail.

  • Fixed a real production incident: admixture/fastStructure/sNMF crashed with no population metadata, discarding an already-completed real ancestry result. Reported by the same real user immediately after the pcadapt fix below: admixture crashed 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 as pcadapt: 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 a population column 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 missing population column gracefully, confirming the requirement was simply wrong. Consolidated into one shared attach_q_population() helper that omits the column instead of stop()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.0 container image was missing the optional pcadapt package, and the pcadapt module’s own stop() 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_tree work was lost to Error: pcadapt failed: The pcadapt outlier scan requires the optional 'pcadapt' package at the very last module. Root cause: pcadapt is declared in DESCRIPTION’s Suggests and the module defaults on for every user (it needs no population metadata), but inst/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: added r-pcadapt to environment.yml, and changed run_pcadapt_scan()’s missing-package check from a hard stop() to the same graceful failed = TRUE degradation 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 new reason field ("package_missing" vs. "numerical_instability") lets run_module_pcadapt() report an accurate WARNING for 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 from environment.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 other Suggests package with a requireNamespace() 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). New test-container-dependency-completeness.R cross-checks every required Imports package against environment.yml (excluding base R and the Bioconductor packages install-bioconductor.R handles 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 oldrel to the R-CMD-check matrix, closing a real coverage gap. The matrix had only ever tested release/devel R in this repository’s history, despite issue #4’s Definition of Done (and the general R package convention) calling for release/oldrel/devel. Found auditing the repository immediately after closing #4. DESCRIPTION requires R (>= 4.3), well under any current oldrel, 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 archive CI workflow (stop(), no artifacts uploaded) after the environment_compatible fix below stopped masking it: the flat 10% runtime/memory ratio budget is appropriate for the multi-second canonical/large benchmark tiers but not the sub-second synthetic tier, 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 as insufficient-evidence by 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 the synthetic tier; every other tier, including canonical, keeps the strict 10% default, since their absolute time scale makes that margin meaningful. scripts/build_release_benchmark_archive.R now 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’s environment_compatible check: a routine GitHub-hosted runner kernel package update was enough to permanently strand every future comparison at insufficient-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 the v0.10.0 baseline), but the comparison still reported insufficient-evidence because environment_compatible compared the full captured environment fingerprint for exact equality, including the OS kernel release string – and the v0.10.0 baseline was captured on 6.17.0-1020-azure while the rerun landed on 6.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-azure and 6.17.0-1022-azure both normalize to 6.17.0-azure) before the environment_compatible equality 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 exported performance_environment_fingerprint() and stored evidence are unaffected; normalization is applied only at comparison time).

  • Fixed a second, separate real performance regression found rerunning benchmark_history evidence after the allelic-richness fix below: library(popgenVCF) itself eagerly loaded adegenet’s entire namespace, and adegenet’s own heavy transitive dependencies, on every session – not only when population_tree actually 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 the v0.10.0 baseline’s 42.8MB). Traced with a second, targeted git bisect (measuring library(popgenVCF)’s own gc()-reported memory in a fresh session, no benchmark computation involved) to the commit that added the population_tree module: 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 genpop to the package’s static NAMESPACE declarations – which R resolves at library() time, forcing adegenet (and its own transitive dependency tree: ade4, shiny, httpuv, mgcv, igraph, and more) to load eagerly for every popgenVCF session regardless of whether population_tree is 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 calls adegenet::genpop(tab = tab, ploidy = 2L) – adegenet’s own exported constructor, itself just new("genpop", ...) called from inside adegenet’s own namespace, where the class is always resolvable – instead of methods::new("genpop", ...) directly. Verified this gives identical, correct output under the exact same real-installed-package-load scenario the original bug was about, with adegenet now loading lazily on first actual use instead. library(popgenVCF)’s memory footprint is back to 23.8MB, matching v0.10.0. New regression test spawns a fresh subprocess (this test session almost certainly already has adegenet loaded from an earlier test) and asserts "adegenet" %in% loadedNamespaces() is FALSE immediately after library(popgenVCF). No public API changes (the removed importClassesFrom declaration was never part of any exported function’s documented contract).

  • Fixed a real performance regression found bisecting a benchmark_history release-gate finding toward v1.0: compute_diversity() unconditionally paid the cost of hierfstat::allelic.richness() even for callers that never use the result. The pipeline-core-analyses/pca_ibs_diversity_fst continuous benchmark’s synthetic-tier comparison against the v0.10.0 baseline 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 by git bisect across the 97 commits since v0.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 into compute_diversity()’s hot path, unconditionally whenever hierfstat is 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 a compute_allelic_richness parameter (default TRUE, preserving current behavior for the only caller that writes the column to output tables/figures, run_module_diversity) and a matching analyses.diversity_allelic_richness config key (default TRUE) 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.yml flattens 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.txt manifest – source_distribution’s release-SHA256SUMS.txt and apptainer_distribution’s apptainer-metadata-SHA256SUMS.txt both 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 across source_distribution, source_package_check, and scientific_validation, since all three come from one tagged-source-release.yml run. 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 false INTEGRITY FAILED rows 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 against release-candidate-evidence-index.json) and defers to the separate, already-correct indexed_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: clonality could segfault and take down the entire pipeline run, mid-way through the very last module, on a large real dataset. Found generating the same external_concordance release-candidate evidence toward 1.0 as the roh fix below, immediately after it: poppr::poppr()’s Ia/rbarD computation (pair_diffs() -> a compiled C pairdiffs routine) 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 exceeds 2^31 - 1. On the real chr22 dataset (2504 samples, 26 populations, 2028 QC-passing loci) that product is 2504*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 own tryCatch() – 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: roh could crash with a cryptic “subscript out of bounds” on a malformed line from bcftools roh’s captured output. Found while generating fresh external_concordance release-candidate evidence against the real, approved chr22 1000 Genomes dataset (2504 samples) toward 1.0 – roh_parse_regions() and roh_analyzed_footprint_bp() both blindly assumed every captured line from bcftools’s stdout had the exact expected tab-field count via a bare vapply(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.yml build failure was GCC 15 (2025) breaking the old, bundled xz-5.2.9 build inside gdsfmt. Pinning gcc/g++/gfortran to the 13.x conda-forge series resolved it – confirmed by a clean, fully passing apptainer.yml run (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=gnu23 mismatch, make -j parallelism, install.packages()’s Ncpus parallelism, an OOM kill, and unusual ulimits), 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 temporary mxschmitt/action-tmate@v3 debug step used a mutable tag rather than a pinned commit SHA, violating this project’s own test-workflow-action-pins.R supply-chain-security convention (every other action reference in this repo is pinned to an exact commit); now pinned to 35b54afac29c97fb54faba5b513f8fbd1882f113 # v3. No public API changes.

  • Pinned gcc/g++/gfortran to the 13.x conda-forge series in inst/conda/environment.yml (previously the unpinned compilers meta-package, which now resolves to GCC 15.3.0) as the next hypothesis for the apptainer.yml build 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 unusual ulimit/resource constraints, joining the previously-ruled-out disk exhaustion, -std=gnu23 mismatch, make -j, and install.packages() Ncpus parallelism – 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 vendored xz-5.2.9 source inside gdsfmt that’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 temporary scripts/debug-gdsfmt-liblzma.sh diagnostic script added for the live-debug session, its purpose served. No public API changes.

  • Raised the apptainer.yml debug 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 debug input to apptainer.yml (mxschmitt/action-tmate, only active on an explicit workflow_dispatch with debug: true) after options(Ncpus = 1) failed to fix the apptainer.yml build 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 (plain docker 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.yml build-failure investigation: MAKEFLAGS=-j1 (forcing serial compilation within gdsfmt’s own build) did not fix it either – the identical liblzma link failure recurred a fourth time, at the identical spot, with disk still at 83GB free. That rules out make -j race conditions too, leaving one remaining, untested difference between the failing CI build and the earlier successful local repro: install-bioconductor.R installs gdsfmt/SNPRelate/BiocVersion together in one BiocManager::install() call, which can run multiple package installs concurrently (separate R subprocesses) when getOption("Ncpus") > 1 – a different axis of parallelism than make -j, and one the local repro (a single, standalone gdsfmt install) never exercised. install-bioconductor.R now sets options(Ncpus = 1) explicitly; R’s own default is already 1L, but conda-forge’s r-base recipe 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.yml build-failure investigation below: the disk-exhaustion hypothesis is ruled out by real evidence – the df -h diagnostics 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=gnu23 compiler 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 default make concurrency can race against xz-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.def now sets MAKEFLAGS=-j1 around the Bioconductor install step as the standard mitigation, forcing serial compilation for just that step. The df -h diagnostics stay in place either way, so a recurrence is still real evidence rather than another silent Error 1. No public API changes.

  • Added df -h disk-space diagnostics to Apptainer.def’s %post build steps, and an apt-get clean after installing texlive-xetex/lmodern, after a real, reproducible apptainer.yml build failure surfaced while collecting release-candidate evidence toward 1.0: linking gdsfmt’s vendored liblzma failed with Error 1 and 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 identical gdsfmt 1.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, and make/libtool builds commonly report a bare, silent Error 1 on ENOSPC rather 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 interactive sudo on this machine), so this change is diagnostic-first: if it recurs, the next failure will show real free-space numbers instead of another silent Error 1, turning speculation into evidence. No public API changes.

  • Added a sexbias module: a sex-biased dispersal test (Goudet, Perrin, and Waser 2002) via hierfstat::sexbias.test(), another real, previously-unused capability of hierfstat (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, or FST; FIS/FST require 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 existing sex_check module: sex_check asks whether one sample’s own genotype is consistent with its recorded sex; sexbias asks whether the two recorded sexes differ, as groups, in how strongly their genotypes match their recorded population – so it deliberately uses the metadata sex column, not sex_check’s genetically inferred sex. Reuses compute_diversity()’s already-computed genotype matrix (requires = "diversity", matching clonality’s reuse of the same matrix – both are population-gated, so unlike pcadapt this dependency cannot force diversity to run when population metadata is unavailable). Skips gracefully (NULL, WARNING-logged, matching ibd/spatial_autocorrelation’s established pattern), not a hard error, when the optional hierfstat package is not installed or the metadata’s sex column 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 figure 60_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 keys analyses.sexbias (default TRUE, FALSE in template_config() since it needs population metadata), analyses.sexbias_test (default "mAIc"), and analyses.sexbias_permutations (default 0L, 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’s 58_genotype_accumulation_curve figure, and roh’s 24b_ROH_FROH_by_length_class figure. Both split into a short subtitle plus a caption, matching plot_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 three genome_scan figures, 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 from pretty() 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 its axis column. The first implementation set that column to a plain character value; visually inspecting the real rendered figure (not just checking nrow()/levels(), which is all the existing tests happened to assert) caught that facet_wrap() had silently reset the entire plot’s panel order to alphabetical once that mismatched-type layer was added – PC10 rendered second, immediately after PC1, instead of last after PC9, 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 both test-pca-loadings.R and test-dapc-loadings.R with ggplot2::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_scan Manhattan 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 new pcadapt figure, which all already show compact, Mb-formatted manhattan_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_scan predates the helper; pcadapt used it correctly from the start). All three now call manhattan_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 and pcadapt figures’ 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 pcadapt module, via the pcadapt package (a new Suggests dependency). 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 existing genome_scan module’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 uses stats::p.adjust(method = "BH"), the base-R equivalent of Storey’s q-value every pcadapt tutorial uses via Bioconductor’s qvalue package, 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. K defaults 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 set genome_scan/AMOVA/clonality already use – extracted independently in run_module_pcadapt() rather than declaring requires = "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: diversity is population-gated (R/metadata_capabilities.R), but pcadapt is not, so without population metadata the registry’s dependency resolution force-included diversity anyway to satisfy pcadapt’s declared requires, and diversity then crashed on a missing population column it was never supposed to need in this scenario. Fixed by extracting the genotype matrix directly via SNPRelate::snpgdsGetGeno() (matching ml_tree/spatial_autocorrelation’s own pattern) and declaring no requires at 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; new 59_pcadapt_manhattan figure. New analyses.pcadapt (default TRUE), analyses.pcadapt_k (default NULL, auto), analyses.pcadapt_min_maf (default 0.05, matching pcadapt’s own default), and analyses.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_scan output (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 new test-pcadapt.R (deliberately-injected true-positive outlier recovery with zero false positives on synthetic structured data, the resolve_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 new pcadapt_module_spec export.

  • Added Nei’s (1987) Dxy, absolute nucleotide divergence between populations, to the fst module. Found by a second standard-toolkit gap audit (SambaR’s calcdistance() 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). New R/dxy.R: per-locus p1*(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” convention compute_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 reported global_dxy is 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. New global_dxy column on 17_global_FST.tsv, dxy/dxy_n_snps columns on 18_pairwise_FST.tsv, and a new 19c_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 new test-dxy.R (hand-verified per-locus formula, including two closed-form sanity checks – identical population frequencies reduce to 2p(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 clonality module. Found by a standard-toolkit gap audit: poppr is already a required Imports dependency, 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() in R/amova.R) never touches MLGs at all. Distinct from the existing kinship module: 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’s snpclone classes (this package’s usual representation for SNP data, genlight_from_gds()) are explicitly rejected by both poppr() and genotype_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. New R/clonality.R instead builds a genind object 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 than ml_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, from poppr::mlg.id(), with a cross_population flag 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_curve figure and table (mean and 95% envelope of distinct MLGs resolved as loci are subsampled, summarized rather than poppr::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 (default TRUE), analyses.clonality_genotype_curve_replicates (default 100), and analyses.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). Module requires = "diversity" (reuses compute_diversity()’s already-extracted genotype matrix, the same dependency AMOVA declares) and is gated on population metadata like amova/bottleneck (added to R/metadata_capabilities.R’s population-module list and disabled in template_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/tree use), every sample has a unique multilocus genotype: 57_MLG_groups.tsv is 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 own duplicate/MZ twin classification) – 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 new test-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 new clonality_module_spec export.

  • 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 via graphics::layout() rather than an inset legend() 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_tree module distinct from the existing NJ-based tree/population_tree modules 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 new Suggests dependency, checked via requireNamespace() 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 to inst/conda/environment.yml alongside 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, feeding phangorn::phyDat(type = "DNA"). The existing NJ tree (phangorn::NJ() on a dist.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(), native multicore/mc.cores parallel support, 100 replicates by default) is tallied via the same bootstrap_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): new 55_ML_tree figure and 54_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 new ml_tree_module_spec export). No public API changes beyond that export.

  • Fixed a CI break in the tree-bootstrap tests shipped moments earlier: R-CMD-check failed with Error 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 – as r-lib/actions/check-r-package does, but a bare local R CMD check does not, which is why this passed locally before shipping). Two new tests passed cfg$compute$threads – default_config()’s auto-detected full machine core count, unrelated to what the test itself needed – straight into bootstrap_nj_ibs_tree()/bootstrap_population_nj_tree()’s real mclapply() dispatch; on the CI runner’s 4 cores, that’s mc.cores = 4, over the limit. Fixed by capping cfg$compute$threads <- 2L in 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_=true for both the local test run and a real R CMD build + R CMD check against 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_bootstrap config block (enabled = TRUE, replicates = 100L – ape::boot.phylo()’s own documented default, chosen as the standard fast-but-informative choice rather than guessed; parallelized across compute.threads workers). 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) via ape::prop.part()/ape::prop.clades(), ape’s own bipartition-matching algorithm (the same one ape::boot.phylo() calls internally), shared by both tree types in new R/tree_bootstrap.R rather 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 from stats::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 that adegenet::dist.genpop() treats duplicate-named resampled columns exactly like independently-renamed ones (a real risk checked directly, not assumed, since genpop’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_tree figures (ape::plot.phylo()/ape::nodelabels(), this package’s first base-graphics figures; everything else uses ggplot2). New save_base_plot() (R/utils.R) mirrors save_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 existing population_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 new test-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 fresh parallel::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 inside run_scheduled_engine_module() – is not reliable. It intermittently fails with "could not find function 'run_stage'", which the module’s own tryCatch() correctly converts into a fast, near-instant PopgenVCFEngineFailure rather than ever reaching the module’s Sys.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 real R CMD INSTALL of the identical commit (matching what R CMD check and this repository’s own CI always use) that this is specific to the dev-mode namespace, not a real defect in run_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, deterministic finished_numeric inputs (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 when pkgload::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 explicit in_use parameter on admit_execution_resources() (default all-zero, exactly reproducing prior behavior for every existing caller) and a session-scoped ledger (admission_ledger_in_use()/_commit()/_release()) that start_supervised_external_command() now consults and updates, releasing on set_async_terminal() – with a reg.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()’s in_use reduces available capacity as expected; two overlapping async handles under a processes = 1 policy 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 via git 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 a digest::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; and force = TRUE still 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 identical sha256 – a content-based check that catches all three mechanisms uniformly (a symlink-resolution-only check, using normalizePath(), 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 to 0 (ADMIXTURE’s own strict requirement) directly on plink_prefix’s .bim file, in place. prepare_structure_plink_input() already caches and reuses one canonical PLINK bundle across ancestry backends by default (run_module_faststructure() explicitly reuses context$structure_plink from run_module_admixture() when both are enabled, to avoid redundant PLINK export work) – so whichever backend happened to run first would silently mutate the shared .bim file 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 having run_admixture_cv() stage a private copy of the PLINK bundle scoped to its own output_dir and normalize that copy, leaving plink_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/.P output naming already assumed). Verified with a new test: after a successful run_admixture_cv() run against a bundle with a non-integer chromosome label, the original bundle’s .bim is byte-for-byte unchanged and gained no chromosome-map sidecar file, while the private, normalized copy and its sidecar exist in output_dir instead.

    Refreshed the public API baseline (638 entries, unchanged) to record admit_execution_resources()’s new optional in_use argument – 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()’s timeout_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 under default_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 broke R-CMD-check and test-coverage CI, 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) used testthat::local_mocked_bindings(system.file = fn, .package = "popgenVCF") to stub base::system.file() – this passed 100% locally under pkgload::load_all(), but local_mocked_bindings() can only mock a function genuinely bound within the target package’s own namespace, which base::system.file is 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) throws Error 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 switching popgenvcf_software_identity_path() to dependency injection instead: it takes a system_file parameter, 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 to system_file = base::system.file, explicitly qualified. pkgload::load_all() makes system.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 unqualified system.file(...) call from inside the package’s own code picks up automatically – but an explicit base::system.file reference bypasses the imports environment entirely and reads the real, unshimmed base namespace directly, silently returning "" instead. This stayed invisible through every test run of test-software-identity-path.R alone (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) call popgenvcf_software_identity() for real, with no fake system_file supplied, 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 unqualified system_file = system.file symbol instead: this correctly picks up pkgload’s dev-mode shim during local/CI test runs and resolves to genuine base::system.file in a real install (which has no shim to bypass), matching how every other system.file() call in this codebase is already written. Re-verified with a full testthat::test_dir() run (not just the individual affected test files) and, again, a real R CMD build + R CMD check against 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’s sys.source()-based loading of its four helper modules (new resolve_benchmark_helper_script(), preserving this file’s own documented reason for staying under inst/ – system.file() reachability from a real R CMD check install – while making local/CI-checkout runs prefer the current commit’s own files instead of a possibly-different installed copy) and scripts/build_release_benchmark_archive.R’s resolution of that same tier module (switched to the --file=-based script-relative resolution scripts/build_release_candidate_evidence_index.R already established, since this top-level script is always invoked directly from a real checkout and never needs system.file() at all). A fifth, previously-unaudited instance was also found and fixed directly in tests/testthat/test-benchmark-tier-datasets.R’s own benchmark_tier_module_path() helper. Testing resolve_benchmark_helper_script() needed a third technique beyond mocking and dependency injection: it is a loose function loaded via sys.source(), not part of any package namespace at all, so neither local_mocked_bindings() nor a namespace-based approach has anything to attach to – new tests inject system.file directly 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 the run_pipeline() code path).

  • Fixed another instance of the same audit-found bug class: popgenvcf_software_identity_path() (R/software_identity.R) checked a system.file()-resolved installed copy of popgenVCF before the checked-out source tree – the exact backwards priority already found and fixed twice this cycle in rc_policy_path()/rc_module_dir()/rcec_policy_path() (the release-candidate dossier test helpers). This is the function popgenvcf_software_identity() calls to locate inst/metadata/software-identity.json, which feeds fair_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 to system.file() exactly as before) and only falls back to system.file() when none of them exist. New test-software-identity-path.R gives 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_ROOT override.

  • 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 on processx, 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) wraps run_supervised_external_command() while preserving the exact system2(..., stdout = TRUE, stderr = TRUE) return shape (output/status) both callers’ existing parsing logic was already built against, so neither admixture_output_tail()/faststructure_output_tail()/parse_admixture_cv() needed to change at all – only the subprocess-invocation internals did. New analyses.admixture.timeout_seconds/analyses.faststructure.timeout_seconds config 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-needed setwd()/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/.P output 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.R failure-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”, for run_supervised_line_command() directly and end-to-end through both run_admixture_cv() and run_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, both enabled: 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/, and inst/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 via eigen.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 own varprop[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 with requested_components / (n_samples - 1), which differs between a 30-sample synthetic test and the real 160-sample dataset. Fixed by using z$varprop directly, 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, every 13_PCA_variance.tsv, and chromosome_summary.tsv’s pc1_percent this 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. New test-pca-variance-explained.R: confirms the reported percentage matches SNPRelate’s own varprop (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 with n_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-QC context$ids (captured before harmonize_samples() runs) into run_genome_scan_fst(), which built its population factor and sample.id argument directly from ids$sample – while context$metadata (also passed in) was already filtered to the QC-retained set. Any sample present in the raw GDS but excluded by qc.max_sample_missing produced an NA population value, which SNPRelate::snpgdsFst() rejects outright. This was never caught by test-genome-scan.R’s own existing fixture, which happened to build both its ids and metadata directly from the raw GDS/TSV without ever routing through harmonize_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 in run_genome_scan_fst() (R/genome_scan.R) by deriving sample.id/population from metadata (already correctly QC-filtered, exactly like every other module) instead of ids$sample. New test case reproduces the exact crash against the real quickstart dataset with one sample manually excluded from metadata before the fix, confirms it no longer errors after the fix, and confirms the result is identical to passing an ids object whose $sample was 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 declared requires unconditionally, without checking whether that dependency was itself enabled – so leaving fst/dapc/bottleneck/genome_scan/population_tree/amova (all requires = "diversity") or tree/ibd (requires = "ibs") enabled while disabling their prerequisite silently ran the “disabled” module anyway, with no warning or indication anywhere in analysis_capabilities.tsv or the execution ledger that the user’s explicit setting was overridden. Fixed by making this configuration state a clear, immediate stop() 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 every requires chain together, and template_config() (used by --write-config) disables every dependent of diversity/ibs alongside the dependency itself wherever it disables one – confirmed by cross-referencing every requires edge 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 an enabled function matching the production registry’s own convention.

    Reference-run verified together (the same one-off ADMIXTURE/fastStructure/sNMF-enabled config docs/examples/README.md documents). PNG/table-diffed against the immediately-prior reference run: only 13_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 the 29_fastStructure_runs.tsv/27b_ADMIXTURE_K_selection_scores.tsv/28_ADMIXTURE_Q_K3.tsv diffs 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 calls hierfstat::betas(dat, nboot = 0) directly – Goudet’s own reference implementation, not a reimplementation; hierfstat is already an optional Suggests dependency used for allelic richness, and hierfstat_encode_genotype() already exists to feed it. New R/population_specific_fst.R: compute_population_specific_fst() returns $betaiovl (per-population beta) and $betaW (the overall weighted mean); a new global_beta_fst column on 17_global_FST.tsv, a new 51_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) when hierfstat is not installed or fewer than two populations are present – no fabricated numbers. fst_module_spec()’s references now also cites Weir and Goudet 2017.

    Real quickstart result: global_beta_fst = 0.09150000098 versus global_fst (Weir and Cockerham 1984) = 0.09150000098 – agreeing to 9 significant figures, a real and expected internal-consistency check (betaW is, 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 new test-population-specific-fst.R: exact hierfstat::betas() values pinned from a seed-fixed synthetic 3-population fixture (cross-checked against the real installed hierfstat, 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 roh module 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). New roh_length_class() (R/roh.R) classifies each run by its length_bp against 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 a length_class column on 37_ROH_runs.tsv and six new per-class columns (total_length_bp_short/intermediate/long, froh_short/intermediate/long) on 38_ROH_sample_summary.tsv; the three froh_* columns sum to the existing froh by construction (checked directly, and now enforced by validate_roh_result() to within 1e-6). New 24b_ROH_FROH_by_length_class figure: a stacked bar chart per sample, legend labels showing the actual configured Mb thresholds. roh_module_spec()’s references now 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 new test-roh.R cases: roh_length_class() boundary unit tests, a run_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.md documents). No new exported functions, so no public API baseline refresh was needed this round (confirmed via an empty NAMESPACE diff after roxygen2::roxygenise() + tools/reconcile-rd-aliases.py --write). PNG/table-diffed against the immediately-prior ancestry-ordering/spatial-autocorrelation reference run: exactly the expected changes – new 51_population_specific_fst.tsv/figure, 17_global_FST.tsv gaining global_beta_fst, 37_ROH_runs.tsv gaining length_class, 38_ROH_sample_summary.tsv gaining the six new per-class columns, and the new 24b_ROH_FROH_by_length_class figure; the ADMIXTURE K=3 cross-validation-error table differs by 0.00003 (5th decimal place) and the 29_fastStructure_runs.tsv diff 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, and R/dapc.R’s membership plot), so the fix lives in one place, not duplicated per backend. New population_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 .tsv output 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_pop similar, B_pop different) 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 ibd module already computes, found by the same standard-toolkit audit. New spatial_autocorrelation module (on by default via analyses.spatial_autocorrelation, needs geographic coordinates like ibd) 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). New 50_spatial_autocorrelation.tsv and 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) matched PopGenReport::spautocor()’s own source exactly (max absolute difference ~1e-16, machine precision) on the same fixture. PopGenReport itself 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 permutation vegan::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_d column on 17_global_FST.tsv, jost_d/jost_d_n_snps columns on 18_pairwise_FST.tsv, and a new 19b_pairwise_jost_d_matrix.tsv, matching the existing FST table conventions; no new figure. Reuses compute_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 declares requires = "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 matched mmod::D_Jost()’s output to floating-point precision (max absolute difference ~2e-16) in every case tested. The pairwise-D result is pinned in test-jost-d.R as a literal derived from that real cross-check, not re-installing mmod at 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 19b matrix table changed as intended; the ADMIXTURE K=3 cross-validation-error table differs by 0.00002 (5th decimal place) and the 29_fastStructure_runs.tsv diff 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 beyond fst_module_spec()’s references field gaining “Jost 2008” and its requires field now correctly declaring “diversity”.

  • Fixed a real, user-reported rendering bug in the new 48_site_frequency_spectrum figure (plot_bottleneck(), R/bottleneck.R): the subtitle text was long enough (175 characters, combining the practical description and the method citation into one paste()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’s plot_ibd() already uses for the same reason. Verified visually by re-rendering the figure from the exact already-cached analysis_results.rds of 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 computes Ae = 1 / (1 - He_unbiased) per locus (a new effective_alleles column on 10_population_locus_diversity.tsv) and its per-population mean (a new mean_effective_alleles column on 09_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 case Ae = Inf is 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 initial effective_alleles <= 2 bound (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 checks Ae >= 1 instead, the one bound that always holds. Verified with test-allelic-richness.R, reusing its existing hand-computed fixture: exact per-locus Ae values pinned by hand, a dedicated single-heterozygous-call fixture confirming the Ae = Inf edge case, and the corrected validator bound. Real quickstart result: mean_effective_alleles ranges 1.44-1.51 across the 8 populations. No public API changes.

    Site frequency spectrum and mode-shift bottleneck screen. New bottleneck module (R/bottleneck.R, on by default via analyses.bottleneck, needs population metadata like diversity/ne_ld but works with a single population – unlike population_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 new 48_site_frequency_spectrum.tsv and 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 new 49_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. Reuses compute_diversity()’s already-computed per-locus maf/polymorphic columns via context$diversity_full (requires = "diversity") – no new per-locus computation. New bottleneck_module_spec(), references = "Luikart and Cornuet 1998". Verified with test-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.05 filter (applied globally, pooled across all populations, before this or any other module runs), all 8 populations show mode_shifted = TRUE in 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 zero qc.maf to 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.md documents). Refreshed the public API baseline (636 -> 637 entries, the new bottleneck_module_spec export). PNG-diffed against the immediately-prior Nm/population-assignment reference run: only the new 48_site_frequency_spectrum figure and 09_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 the 29_fastStructure_runs.tsv diff 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 computes Nm = (1 - FST) / (4 * FST) (a new fst_to_nm() helper) alongside every FST estimate it already produces: a new global_nm column on 17_global_FST.tsv and a new nm column on 18_pairwise_FST.tsv, no new table files, no new figure (the existing 10_pairwise_FST heatmap 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) reports Nm = Inf – no evidence of restricted gene flow, not a fabricated finite number, matching this codebase’s established convention for ne_ld()’s undetectable-drift-signal case. fst_module_spec()’s references now cites Wright 1931 alongside Weir and Cockerham 1984. Verified with test-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 that run_fst()’s global_nm/nm match fst_to_nm() applied to its own global/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_assignment module (R/population_assignment.R, on by default via analyses.population_assignment, needs population metadata and at least two populations like population_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 standard 1/(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 convention population_tree.R already uses for Nei’s distance.

    New 47_population_assignment.tsv (per-sample recorded_population/assigned_population/mismatch/log_likelihood/likelihood_ratio/posterior_probability/n_loci_used) and a 47_population_assignment confusion-matrix heatmap figure (recorded population by row, assigned population by column, matching the existing pairwise-FST heatmap’s visual style). New population_assignment_module_spec(), references = c("Paetkau et al. 1995", "Paetkau et al. 2004", "Rannala and Mountain 1997"). Verified with test-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.md documents). Refreshed the public API baseline (635 -> 636 entries, the new population_assignment_module_spec export). Running the official roxygen2::roxygenise(roclets = c("namespace","rd")) + tools/reconcile-rd-aliases.py --write sequence 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 generated man/ page committed; it does now. PNG-diffed against the immediately-prior partial-Mantel reference run: only the new 47_population_assignment figure is added; the ADMIXTURE K=3 cross-validation-error table differs by 0.00002 (5th decimal place) and the 29_fastStructure_runs.tsv diff 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): previously vapply() 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 via parallel::mclapply() (fork-based, already a hard dependency via parallel) – roughly halving report wall-clock time for the common case. Windows has no fork support, so render_report_formats() (the new small dispatch helper render_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 let rmarkdown::render() default its knit intermediates_dir to 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 own tempfile()-backed intermediates_dir, cleaned up via on.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 sequential lapply()/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 behavior render_report()’s callers (including its own tests) already depend on. Verified with a real end-to-end render_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 ibd module, 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. Uses vegan::mantel.partial() (Smouse, Long, and Sokal 1986), reusing vegan::mantel()’s already-hard dependency – no new dependency. run_mantel_ibd() builds a same/different (0/1) population dissimilarity matrix from the existing population metadata 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 no population column at all, partial_mantel/partial_mantel_r/partial_mantel_p are NULL/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()’s references now cites Smouse, Long, and Sokal 1986 alongside Mantel 1967 and Rousset 1997. Verified with new test-mantel.R cases: 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 result NA; and plot_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) and 25_Mantel_IBD_summary.tsv (the two new columns) changed; the 29_fastStructure_runs.tsv diff 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_scan module – 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() gained segregating_sites/tajima_d columns computed on the same window grid, reusing the already-computed per-locus polymorphic flag and unbiased_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 --TajimaD simplifying 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_manhattan figure; no new config keys (reuses analyses.genome_scan_window_bp/_step_bp/_min_snps). genome_scan_module_spec()’s references field now cites both Weir and Cockerham 1984 and Tajima 1989. Verified with tajima_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-computed run_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 the 40_genome_scan_diversity.tsv table 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 tree module 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_tree module (R/population_tree.R, on by default via analyses.population_tree, requires population metadata and diversity to have run) computes Nei’s D via adegenet::dist.genpop() (method 1, its documented default = Nei’s standard distance) – already a hard dependency (used elsewhere for DAPC), so no new dependency. The genpop object is built directly from compute_diversity()’s already-computed per-population allele counts (no genind/df2genind() intermediate needed), excluding any locus where a population has zero calls (allele frequency undefined there). New 46_population_genetic_distance.tsv (a symmetric population x population matrix, matching the FST/kinship/IBS matrix-table convention) and a population_Nei_neighbor_joining.nwk Newick file (via ape::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 class when the module actually ran via a properly library()-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), regenerating NAMESPACE’s importClassesFrom(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 local load_all()-based test suite – this is the second time this exact session has caught something the faster dev-loop missed (the first being the snpgdsGetGeno()/snpgdsSampMissRate() ordering findings, caught by deliberate empirical probing rather than incidentally like this one).

    population_tree was added to metadata_capabilities.R’s population_modules and multi_population_modules (needs population metadata and at least two populations, like FST/AMOVA/genome_scan) and is explicitly disabled in template_config() alongside them. New test-population-tree.R covers the hand-verified distance value, the monotonic-ordering recovery, the zero-call-locus exclusion, the fewer-than-two-populations empty result, the ape::nj() >=3-tip guard, the validator, and registry wiring. Refreshed the public API baseline again (634 -> 635 entries, the new population_tree_module_spec export). 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_ld module (R/ne_ld.R, on by default via analyses.ne_ld, per population like diversity) 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 from ld_decay, which specifically wants physically close pairs), bias-corrected for sampling noise, then converted to Ne via Ne = (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^2 for S <= 30, 1/S for 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 redundant Pcrit parameter. r-squared attributable to drift at or below the sampling-noise floor is reported as Ne = 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 as NA with an explicit below_formula_domain status, never a bogus complex/negative root.

    New 45_Ne_LD.tsv (per-population n_samples/n_snps/n_chromosomes/n_pairs/harmonic_mean_n/mean_r2/mean_r2_drift/ne/ne_status) and a 45_Ne_LD figure (log-scale points by population, omitting non-finite estimates). New analyses.ne_ld_max_snps config 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_ld requires population metadata like diversity/allelic_richness (added to metadata_capabilities.R’s population_modules) and is explicitly disabled in template_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.R uses 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_check rather than pooled into other modules), so every population correctly reports ne_status = "fewer_than_two_chromosomes" and ne = NA on 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 new ne_ld_module_spec export). PNG-diffed against the immediately-prior LD-decay/allelic-richness reference run: zero figure differences beyond the expected empty ne_ld result (no 45_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_decay module (R/ld_decay.R, on by default via analyses.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. Uses SNPRelate::snpgdsLDMat(), already a hard Imports dependency (method = "r", squared to r-squared) – no new dependency. Computed on the QC-passing, unpruned SNP set (context$qc_snps, the same set genome_scan/diversity/fst already 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()’s slide parameter 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-index slide window are never computed even if within analyses.ld_decay_max_distance_bp, documented rather than silently truncated (new analyses.ld_decay_max_distance_bp (default 500000), ld_decay_bin_bp (default 5000), and ld_decay_slide (default 100) config keys, all validated). New 43_LD_decay.tsv and 43_LD_decay figure. 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, plus max_distance_bp truncation, 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 a WARNING analysis message rather than erroring when hierfstat is not installed) is now computed inside compute_diversity() and added as a new allelic_richness column on the existing 10_population_locus_diversity.tsv and a new mean_allelic_richness summary column on 09_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 new hierfstat_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 exact colnames() passed in as Ar’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). New 44_allelic_richness figure (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, NA when the population has no calls at all), the hierfstat-unavailable skip path (mocked via local_mocked_bindings(requireNamespace = ...)), and a new validate_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 requested sample.id/snp.id order entirely (same behavior class as snpgdsSampMissRate()’s ordering, found during the chromosome Y work below) – verified empirically with an out-of-order synthetic snp.id sequence. compute_diversity()’s existing positional use of its geno matrix against snp_ids turns out to be safe only because every caller (harmonize_samples(), compute_diversity()) always passes order-preserving subsequences of ids$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.yml was missing the sex_check_y_* keys from the chromosome Y work below entirely (now added alongside the new ld_decay_* keys); and the report skeleton.Rmd’s sex-check section still said “from X-chromosome heterozygosity” only, and its row-priority sort omitted discordant from the priority list entirely (silently sorting discordant rows after plain match rows, 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 before kinship_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 new ld_decay_module_spec export.

    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.pdf is 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 reusing snpgdsIndInb() 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 is discordant – real, informative evidence of a data problem, deliberately not averaged away. New analyses.sex_check_y_chromosome_names (default c("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.tsv gained x_inferred_sex/n_y_snps/y_call_rate/y_inferred_sex columns alongside the renamed combined inferred_sex; 27_sex_check_F_by_sample becomes 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 extended test-sex-check.R (X+Y combined recovery, the discordant combination 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 new test-config.R case. 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.R extracts the 77 selected males’ real genotypes, then pads in the 83 selected females as explicit, correctly-missing genotype columns at the same real sites via bcftools merge -m none against 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’s genome_scan module 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 optional snp_ids parameter on harmonize_samples(), restricting its missingness calculation to autosomal markers by default, computed early in run_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 new sex_limited_chromosome_names parameter on variant_qc() (populated from analyses.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. New test-autosome-filtering.R coverage: a synthetic sex-limited-chromosome fixture unit-testing both variant_qc()’s exemption and harmonize_samples()’s restriction directly, plus extended real-pipeline assertions (all 160 samples retained, real chromosome Y evidence, discordant present and ambiguous absent 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 match and 5 as discordant – zero ambiguous, zero mismatch. Combining resolved every one of the 40 samples that were ambiguous on chromosome X alone (a single bounded 1Mb region is a noisier marker set than chromosome Y’s near-binary call rate). The 5 discordant samples are the same 5 that showed as spurious chromosome-X-alone mismatches 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 spurious male reading 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.md documents) 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; updated inst/extdata/quickstart/README.md with 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 VCF GT fields (a single allele, e.g. 0 or 1), confirmed by direct inspection; SNPRelate::snpgdsVCF2GDS() does not parse a haploid GT field 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 with bcftools +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 in run_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 (default TRUE) and qc.non_autosomal_chromosome_names (default c("X", "Y", "MT", "M", "chrX", "chrY", "chrM", "chrMT")) config keys. run_pipeline() now computes qc_snps_all (every QC-passing SNP, any chromosome) and a separately-restricted qc_snps (autosomal only, when autosome_only is on) that feeds LD pruning and every ploidy-sensitive module; sex_check is the sole, deliberate exception, receiving the unrestricted qc_snps_all and doing its own chromosome-name-based restriction to X internally, exactly as before. ld_prune_exact() gained an optional snp_ids candidate-restriction parameter (SNPRelate::snpgdsLDpruning()’s own snp.id argument, not previously used) to implement this without relying on SNPRelate’s own autosome.only heuristic, keeping the same explicit, user-configurable chromosome-name-list convention sex_check_x_chromosome_names already established. run_roh() (which filters its own VCF subset directly via bcftools, not through qc_snps) gained the same exclusion via bcftools 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) while sex_check still 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’s non_autosomal_chromosome_names list silently corrupted an unquoted Y into the boolean TRUE (YAML 1.1’s expanded boolean-literal set, parsed by the yaml package) until Y was explicitly quoted – pinned with a new regression test that parses the shipped file and asserts no boolean corruption. New test-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 metadata sex labels) show HG03873 is genuinely female (real heterozygous chrX calls) and HG03998 is genuinely male (real hemizygous calls); MZ twins/duplicates share genetic sex by definition, so despite their real, high chr22-only kinship (0.4525, correctly classified duplicate/MZ twin by 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 throughout wiki/Results-and-Interpretation.md, vignettes/interpreting-results.Rmd, vignettes/quickstart.Rmd, inst/extdata/quickstart/README.md, scripts/derive-quickstart-dataset.R, and test-quickstart-dataset.R’s comments (the test assertions themselves were already correct – KING really does classify this pair duplicate/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 treating NEWS.md as 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.md already 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_MAF and 13b_ADMIXTURE_cluster_number_selection now reflect the larger combined raw input, and the new 27_sex_check_F_by_sample figure). 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 confident match, 40 as ambiguous, and 5 as mismatch. 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 committed docs/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 sex metadata 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). Uses SNPRelate::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-sex defaults, 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. New R/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), classifies inferred_sex, and – when a sex metadata column is present – compares against it (recognizing male/m/1 and female/f/2 synonyms, matching PLINK’s own .fam-file convention) to produce a match/mismatch/ambiguous/not_reported status. 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 module sex_check_module_spec() (VCF-only, on by default via analyses.sex_check, resource_class = "standard") writes 42_sex_check.tsv and a 27_sex_check_F_by_sample figure (styled like ROH’s 24_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 via analyses.sex_check_x_chromosome_names, default c("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 real sex metadata column added earlier – documented honestly in the wiki and vignette rather than fabricating a demonstration figure. Verified with a new test-sex-check.R (synthetic-fixture recovery of known male/female genotypes, mismatch-flagging against deliberately mislabelled metadata, both skip paths, and the figure), an extended test-config.R case, 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 the pca_metadata_color* keys from the per-metadata-column PCA feature above – fixed alongside the new sex-check keys. No public API changes beyond the new sex_check_module_spec() export, matching every other *_module_spec().

  • Propagated the new sex metadata 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 config docs/examples/README.md already documents; VCF hash unchanged, confirming this is purely additive) and confirmed every figure unaffected by the new sex column is byte-identical to the previous run. Added the new 07b_PCA_PC1_PC2_by_sex figure and real interpretive prose to the wiki and interpreting-results vignette’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 committed docs/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’s gender column, MD5-verified against canonical_1000g_chr22_source()’s declared checksum before use) – not a separate lookup, an inference, or a fabricated value. scripts/derive-quickstart-dataset.R now carries this column through as sex alongside population; 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: sex clears the default minimum-group-size threshold in every population, so 07b_PCA_PC1_PC2_by_sex now actually renders for this dataset (previously nothing beyond population qualified). 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” (HG03873 filed as ITU, HG03998 filed 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. Extended test-quickstart-dataset.R with real-value assertions for the new column (including the duplicate-pair sex values above) and a new real-pipeline integration test confirming 07b_PCA_PC1_PC2_by_sex actually renders, and that population/latitude/longitude correctly 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 hardcoded colour = 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 least analyses.pca_metadata_color_min_group times (default 3), and no more than analyses.pca_metadata_color_max_levels distinct values (default 12, avoiding an unreadable legend/palette for high-cardinality columns like a collection-date field). population itself, latitude/longitude (continuous, not categorical), sample/alias (identifiers), and columns with only one qualifying level (e.g. a species column 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 exceeds max_levels. New analyses.pca_metadata_color config key (default TRUE, cheap to compute since it reuses the existing PCA scores) to disable the extra panels entirely. New pca_metadata_colour_columns()/plot_pca_by_metadata() (R/ordination.R), reusing the existing generic population_palette()/population_shapes() helpers (they already operate on any group-label vector, not specifically population). plot_pca() gained an optional metadata parameter (default NULL, so every existing 3-argument caller/test is unaffected); run_module_pca() now passes context$metadata. The bundled quickstart dataset has no metadata column beyond population/latitude/longitude that currently qualifies, so this does not change the committed example report yet. Verified with a new test-pca-metadata-color.R and an extended test-config.R case. 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 that vignette("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. New manhattan_bp_breaks() (R/utils.R) computes pretty()-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-chromosome offset vector 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-results vignette 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, with replicate_max_rmse effectively 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 its replicate_max_rmse is at or below analyses.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-results vignette. Both sections’ prose already referenced “the Manhattan/ranked loading figures” but never embedded them – only the top-level PCA/DAPC scatter plots were shown. Added 17_PCA_loadings_manhattan, 18_PCA_loadings_ranked, and the K=8 15_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 --push had 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: PopgenVCFAnalysis objects already recorded package_version (used for run_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.Rmd now carries package_version into 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 new test-report-figures.R case 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) using ggplot2::facet_wrap(~axis), which silently alphabetizes any character/default-factor column, so PC10/LD10 appeared right after PC1/LD1, before PC2-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’s chromosome_summary.tsv ordering, R/genome_scan.R’s windowed-output tables, and R/roh.R’s runs table all sorted chromosome labels with a plain sort()/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 shared natural_sort_key()/natural_sort_levels() helper (R/utils.R, zero-pads embedded digit runs so lexicographic order becomes numeric order; non-numeric strings like X/Y correctly 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() and plot_dapc_loading_manhattan()/_ranked() now also return their ggplot object (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 across test-utils-natural-sort.R, test-dapc-loadings.R, test-pca-loadings.R, and test-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 lexicographic order() on filename, so e.g. the DAPC per-K figures (11_DAPC_K2 … 11_DAPC_K10) placed K10 immediately after K1-prefixed names, before K2-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 a natural_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 new test-report-figures.R case (K2/K9/K10) and by re-rendering the real committed quickstart report from its already-computed analysis_results.rds – confirmed corrected order (K3, K4, …, K9, K10) with no other content change (same 76 pages, same Mantel section); the regenerated docs/examples/chr22-quickstart-report.pdf replaces the previously-shipped, incorrectly-ordered one.

  • Renamed the 35 R/ source files that used an escalating z-/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: DESCRIPTION has no Collate field, so R sources R/ 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 (holds utils::globalVariables(), conventionally named to sort last) and was left alone. Checked all 35 new names for collisions against the rest of R/ (none) and fixed the handful of real cross-references found by a repo-wide search: prose comments in R/canonical_drift_detection.R and R/fair_export.R, a hardcoded path in test-package-check-hygiene.R, a doc reference, a code comment, and – the one reference that would have silently broken CI triggering – release-metadata.yml’s paths: filters, which named the old R/zzz_software_identity_integration.R path twice. Regenerated NAMESPACE and man/*.Rd with roxygen2 8.1.0 (upgraded locally to match CI’s unpinned any::roxygen2 install) via the exact sequence generated-documentation.yml uses, including the reconcile-rd-aliases.py --write grouping step; NAMESPACE is byte-identical (only file renames, no content change) and only the 7 man/*.Rd pages whose owning file was actually renamed changed (just their “Please edit documentation in” comment). DESCRIPTION’s roxygen stamp updated to Config/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’s Description field and README.md’s opening paragraph never mentioned any of the three; wiki/User-Guide.md and vignettes/getting-started.Rmd’s analysis-mode guides listed kinship and runs of homozygosity as requiring population metadata when both actually need only genotypes (verified against template_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 against run_module_kinship()’s exact call), genome scans (QC-passing set, verified against run_module_genome_scan()), and runs of homozygosity (its own independent VCF-level filtering, using neither set – verified against run_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’s README_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.R gained a small, cited population_coordinates table 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 explicit header = TRUE. Verified end to end with a real run_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, and vignettes/interpreting-results.Rmd all now show the real 12_isolation_by_distance figure 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.Rmd updated to describe the module as running rather than skipping. Extended test-quickstart-dataset.R with 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-window SNPRelate::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. New R/genome_scan.R: run_genome_scan_fst() computes only the global (all-population) FST per window via the same SNPRelate::snpgdsFst() call run_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 new analyses.genome_scan_min_snps (default 5) get NA, not a misleading value from an underpowered estimate. New module genome_scan_module_spec() (requires = "diversity" to reuse its output) writes 39_genome_scan_fst.tsv, 40_genome_scan_diversity.tsv, and a filtered 41_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-generic manhattan_layout() helper already used by the PCA/DAPC loading plots. New config keys analyses.genome_scan (default TRUE, gated the same way fst already is), analyses.genome_scan_window_bp (default 50000), analyses.genome_scan_step_bp (default 50000, i.e. non-overlapping; set below window_bp for a real overlapping/sliding scan), analyses.genome_scan_min_snps (default 5). Module availability required a real, necessary edit to the centralized R/metadata_capabilities.R capability-gating lists (population_modules/multi_population_modules) – module availability isn’t decided by the module spec’s own enabled field 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-window data.table j expression (the windows table’s own chromosome column shadowing the per-SNP chromosome vector, breaking the window-filtering logic silently), and a spurious warning from an unnecessary cleanup line after := with by= (which never adds the grouping key as a column, unlike aggregating j). Report gained a NULL-safe capped “Genome scan FST outliers” section. Verified with a new test-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 new genome_scan_module_spec() export.

  • Added a bundled real quickstart example dataset, real example figures in the wiki and interpreting-results vignette, a committed example PDF report, and a new quickstart vignette – 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 exported quickstart_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’s production_baseline gate, continuous-benchmark canonical tier, 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 reference run_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 \newpage calls already intended. (2) Wide result tables (population diversity, PCA scores) overflowing the page width into illegible overlapping text; fixed by having report_table_section() (skeleton.Rmd) explicitly request LaTeX-format kable() 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 flagged duplicate/MZ twin at kinship ~0.45-0.46).

    scripts/publish-wiki.sh now also syncs wiki/figures/ (previously copied exactly one hardcoded logo image) so the new figures actually reach the published GitHub wiki. New quickstart.Rmd vignette added to _pkgdown.yml’s Guides menu. Verified with a new test-quickstart-dataset.R (dataset/metadata shape, both duplicate pairs present) and the full local suite. No public API changes beyond the new quickstart_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_coefficient is 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). Uses bcftools roh (Narasimhan et al. 2016, HMM-based, the modern standard method) – bcftools is already a required SystemRequirements dependency (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. New R/roh.R: run_roh() derives the exact analyzed sample/site subset via one bcftools view call, 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 calls bcftools roh, producing a full per-run table and a per-sample summary where a sample with zero detected runs still appears with n_runs=0/froh=0 (real information, not silently omitted). New module roh_module_spec() writes 37_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) and 24_ROH_FROH_by_sample (always drawn, coloured by population when available). New analyses.roh config key (default TRUE, like ibs/kinship – bcftools being mandatory rather than optional is exactly why) and analyses.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 new test-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: only roh_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. New R/kinship.R: run_kinship() (mirrors run_ibs()’s exact filtering defaults) returns the kinship matrix, IBS0 matrix, and a long-form pairs table joined to each member’s population, classified into relationship_degree ("duplicate/MZ twin", "1st-degree", "2nd-degree", "3rd-degree", "unrelated") using the standard KING thresholds (Manichaikul et al. 2010, not invented). plot_kinship() draws 21_kinship_heatmap (gated at <=300 samples, matching plot_ibs()’s existing gate; clusters on a kinship-derived pseudo-distance via the already-generic hclust_dendrogram_segments() helper, but displays the real kinship coefficient as fill) and 22_kinship_IBS0_vs_kinship (the standard KING diagnostic scatter, coloured by relationship degree). New module kinship_module_spec() writes 33_kinship_matrix.tsv, 34_kinship_IBS0_matrix.tsv, 35_kinship_pairs.tsv (every pair), and a filtered 36_close_relatives.tsv (only pairs above the new analyses.kinship_close_relative_threshold config 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). New analyses.kinship config key (default TRUE, like ibs – 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 new test-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), extended test-config.R and test-pipeline.R coverage. No public API changes: run_ibs()/plot_ibs()/ibs_heatmap_plot() are all internal, so run_kinship()/plot_kinship() stay internal too.

  • Added the canonical, medium, and large continuous-benchmark dataset tiers. docs/CONTINUOUS_RELEASE_BENCHMARKING.md declared 4 dataset tiers (synthetic, canonical, medium, large), but scripts/build_release_benchmark_archive.R only ever produced synthetic – flagged as deferred when closing issue #43. canonical reuses the real, already-approved chr22 1000 Genomes source (popgenVCF::canonical_1000g_chr22_source()) and the exact same bounded 1Mb region (22:20000000-21000000) production_baseline already uses – the same acquisition path scripts/run-approved-canonical-validation.R uses, not a new data subset; found and fixed a real bug during verification, where the default run_canonical_production_execution() inspection function is chrY-oriented and misclassifies chr22’s index, requiring the same canonical_production_inspect_bcftools_compatible override run-approved-canonical-validation.R already uses. medium (300 samples x 20,000 SNPs) and large (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 actual run_pca/run_ibs/compute_diversity/run_fst pipeline 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 existing warmup=1, iterations=5 schedule. All 4 tiers are opt-in via a new POPGENVCF_BENCHMARK_TIERS env var (comma-separated, default synthetic) and, in release-benchmark-archive.yml, a new benchmark_tiers workflow_dispatch input 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 multiple dataset_tier values sharing one benchmark_id round-trip through continuous_benchmarks.json with no schema change, verified with a new test covering exactly that shape. Verified with a new test-benchmark-tier-datasets.R (synthetic/medium/large shape checks unconditionally; the real chr22 acquisition path opt-in via POPGENVCF_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 tiers passed). No public API changes.

  • Fixed plot_diversity() unconditionally referencing hwe_pvalue on div$locus, which broke two pre-existing tests (test-plot-palettes.R, test-publication-plot-quality.R) that build a minimal div fixture with no locus element 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 checked sum(df$failed), never sum(df$error) – an uncaught R error inside a test_that() block shows up as error = TRUE, failed = 0, and only checking failed makes 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 when div$locus is absent or lacks hwe_pvalue, matching how the rest of the function already treats other missing optional data.

  • Fixed the codecov badge 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 no CODECOV_TOKEN existed anywhere in the workflows. Switched test-coverage.yaml to covr::to_cobertura() followed by codecov/codecov-action (pinned to v5.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.xml found and uploaded, and the badge itself confirmed to show a real percentage afterward. fail_ci_if_error was initially left false as a safety net for the first rollout, then tightened to true once 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 existing diversity$population table the report already renders. What was actually missing: (1) run_module_pca() stored the analysis result as pca[c("scores", "variance")], silently dropping loadings before it ever reached analysis_results.rds – a real loss of already-computed data, not just a report gap, now fixed to pca[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 diversity analysis, reusing per-locus-per-population data (x, p, n_called) compute_diversity() already computed rather than adding a parallel computation. HWE uses SNPRelate::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 as NA rather than SNPRelate’s trivial p = 1, since piling spurious p = 1 values 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’s private.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; the private_allele column 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 via SNPRelate::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.tsv gains hwe_tested_loci/hwe_significant_loci/hwe_significant_loci_fdr (Benjamini-Hochberg via stats::p.adjust)/private_allele_loci; 10_population_locus_diversity.tsv gains hwe_pvalue/reference_allele_count/private_allele; a new filtered 32_private_alleles.tsv lists just the loci with a private allele; two new figures, 19_HWE_pvalues (p-value histogram faceted by population, styled like 01_MAF’s threshold-line histogram) and 20_private_alleles (bar chart by population), both skipped when there is nothing to show. New analyses.hwe_alpha config key (default 0.05). Verified with a new test-diversity-hwe-private-alleles.R, extended test-config.R and test-pipeline.R coverage, 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 through snpgdsPCASNPLoading() meaningfully, so loadings are honestly NULL there rather than computed from a substituted eigendecomposition. Unlike DAPC’s var.contr (always non-negative), PCA SNP loadings are signed correlations between each SNP and each component, so the new pca_loading_table() keeps the signed contribution for both plots (showing direction) alongside a magnitude column 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 via match() against snpgdsPCASNPLoading()’s own returned snp.id (which can be a strict subset of the requested SNPs, e.g. via remove.monosnp), not by position – confirmed empirically against the real validation fixture, where one of nine SNPs was dropped this way. run_pca() gained an optional ids parameter (the same get_gds_ids()-shaped lookup already threaded everywhere else in the pipeline); loadings are remapped from raw SNPRelate component indices to the pipeline’s final retained PC1..PCn numbering, since some requested components can be dropped for non-positive variance. plot_pca() draws 17_PCA_loadings_manhattan and 18_PCA_loadings_ranked (reusing the DAPC feature’s manhattan_layout() helper) when loadings are present; run_module_pca() writes the top-N-per-axis table as 31_PCA_loadings.tsv, where N is the new analyses.pca_loading_top_n config key (default 20). Verified with a new test-pca-loadings.R (the snploading join/rank/magnitude logic, the real-fixture end-to-end path, and the ids-supplied vs. omitted paths), extended test-config.R and test-pipeline.R coverage, 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 computes var.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 optional snp_ids argument to set locNames() on the genlight object; the new dapc_loading_table() reads model$var.contr and 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 optional snp_ids/chromosome/position parameters (default NULL, so the unrelated unsupervised-clustering caller in population_structure.R is 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 new manhattan_layout() helper in R/utils.R, alternating chromosome colours, faceted by discriminant function) and 16_DAPC_loadings_ranked_K<k> (x = SNP rank by descending contribution, adegenet’s classic loadingplot() style). run_module_dapc() writes the top-N-per-axis table as 22f_DAPC_loadings_K<k>.tsv, where N is the new analyses.dapc_loading_top_n config 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 new test-dapc-loadings.R (layout offsets, the var.contr join/sort, and the snp_ids-supplied vs. omitted paths), extended test-dapc-plots.R and test-config.R coverage, and a real end-to-end run_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, optional approval) into a correctly-shaped, checksum-verified <gate_id>-gate-record.json fragment, reusing the shared write_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) – unlike scientific_approval, release_authorization, and archival_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, retained v0.10.0 Release evidence for all 4 gates, using the exact review determinations already recorded in docs/developer/canonical-autosomal-baseline-proposal.md, docs/SCIENTIFIC_CONCORDANCE.md, docs/user/ancestry-backends.md, and docs/CONTINUOUS_RELEASE_BENCHMARKING.md: merging the resulting 4 fragments with the 8 CI-auto-collected fragments from a real release-candidate-collector.yml run reproduced 12 / 15 gates passed, matching what was independently proven true for the real v0.10.0 release. Verified locally with a new test-scientific-review-gate-record.R covering 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 through build_release_candidate_evidence_index.R. See docs/developer/release-candidate-closure.md.

  • Fixed oci_distribution silently staying not_run in release-candidate-collector.yml even when container.yml succeeded and wrote its fragment correctly: container.yml also produces a *.dockerbuild Docker Buildx cache artifact (via cache-to: type=gha) that gh run download cannot extract as a normal zip, and downloading every artifact in one call aborted entirely on that one broken artifact – silently discarding the real container-release-metadata artifact 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 with publish_container=true for real and noticing oci_distribution was still not_run despite 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 dispatch matrix job in release-candidate-collector.yml failing on every gate (“fatal: not a git repository”): removing its unused actions/checkout step also removed the local git context gh CLI uses to auto-detect which repository to target. Set GH_REPO explicitly 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 a plan job that computes a dispatch matrix, a dispatch matrix 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 an assemble job 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 missing data.table, which read_release_candidate_policy() requires.

  • Added the per-gate CI-fragment evidence collector for future releases. scripts/build_release_candidate_evidence_index.R no 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.json fragments (via the new inst/scripts/release_candidate_gate_record.R helper), re-verifies each artifact’s checksum, flattens nested artifact paths (GitHub Release assets cannot contain directory structure), and errors closed on duplicate or unknown gate_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, and container.yml each write their own fragment as a normal part of their run; canonical-real-data.yml already did (it was the prototype for this pattern). A new .github/workflows/release-candidate-collector.yml dispatches 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 left not_run by this collector; a human supplies their fragments and re-runs the same script locally to produce the final index. See docs/developer/release-candidate-closure.md for the fragment contract and the full gate-to-workflow mapping. Verified locally with a new test-release-candidate-evidence-collector.R covering fragment merging, path flattening, duplicate/unknown-gate-id rejection, checksum-mismatch rejection, and a synthetic all-15-gates-supplied case reaching release_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_approval and release_authorization into scripts/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 on archival_assets completing 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 real build_release_candidate_dossier.R: 14 / 15 required gates now pass – only archival_assets remains, pending the real v0.10.0 release and Zenodo deposition.

  • Fixed the same missing-GH_TOKEN bug found in release-benchmark-archive.yml in tagged-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 set GITHUB_PAT, never GH_TOKEN. Found by auditing every workflow that calls the gh CLI before cutting the real v0.10.0 release, rather than discovering it mid-release.

  • Wired oci_distribution into scripts/build_release_candidate_evidence_index.R using a real container push: container.yml dispatched with publish=true built and pushed an image to ghcr.io/duceppemo/popgenvcf (tagged only sha-<commit> – no latest or 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 real build_release_candidate_dossier.R: 12 / 15 required gates now pass, up from 11 / 15 – only archival_assets, scientific_approval, and release_authorization remain, all tied to the still-deferred Zenodo deposition decision.

  • Transitioned benchmark_history from blocked to passed in scripts/build_release_candidate_evidence_index.R, now that the GH_TOKEN fix produced a real, reviewable comparison: release_ready: true, both validation-suite digests unchanged, and all three performance budget checks passed without regression against the 0.10.0-rc1 baseline. 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 real build_release_candidate_dossier.R: 11 / 15 required gates now pass, up from 10 / 15 – only oci_distribution, archival_assets, scientific_approval, and release_authorization remain, 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 the gh CLI directly, but the job only ever set GITHUB_PAT (used by R’s package installers), never GH_TOKEN/GITHUB_TOKEN, which gh itself requires – every gh release list/gh release download/gh release upload call 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 real scientific-benchmark-archive.tar.gz baseline to it, discovery still returned no-baseline until 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_validation into scripts/build_release_candidate_evidence_index.R using a fresh canonical-real-data.yml production run (candidate_id 0.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 real build_release_candidate_dossier.R: 10 / 15 required gates now pass, up from 9 / 15 – only benchmark_history (genuinely blocked), oci_distribution, archival_assets, scientific_approval, and release_authorization remain.

  • Wired public_api_contract into scripts/build_release_candidate_evidence_index.R using public-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 real build_release_candidate_dossier.R: 9 / 15 required gates now pass, up from 8 / 15.

  • Wired metadata_consistency into scripts/build_release_candidate_evidence_index.R using release-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), and validate_zenodo_metadata.R (.zenodo.json consistency) – all passed with no failing checks. Verified end to end through the real build_release_candidate_dossier.R: 8 / 15 required gates now pass, up from 7 / 15.

  • Wired source_package_check into scripts/build_release_candidate_evidence_index.R using tagged-source-release.yml’s real R CMD check --as-cran log 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 in population_structure.R), disclosed rather than glossed over. Verified end to end through the real build_release_candidate_dossier.R: 7 / 15 required gates now pass, up from 6 / 15.

  • Wired scientific_validation into scripts/build_release_candidate_evidence_index.R using run_scientific_validation()’s and run_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 real build_release_candidate_dossier.R: 6 / 15 required gates now pass, up from 5 / 15.

  • Wired source_distribution into scripts/build_release_candidate_evidence_index.R using 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 real build_release_candidate_dossier.R: 5 / 15 required gates now pass, up from 4 / 15.

  • Wired apptainer_distribution into scripts/build_release_candidate_evidence_index.R using apptainer.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 (unlike oci_distribution, which still needs a real registry push). Verified end to end against a fresh run at the exact evaluated commit through the real build_release_candidate_dossier.R: 4 / 15 required gates now pass, up from 3 / 15.

  • Added a build-metadata step to apptainer.yml: it previously uploaded only the raw .sif image itself, with no checksum, digest, or provenance record – unlike container.yml, which writes structured metadata for every build. It now also writes and uploads a small apptainer-metadata.json (SIF checksum and size, Apptainer version, Apptainer.def checksum, package version, git commit) plus its own checksum file, giving the apptainer_distribution release-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 unpublished workflow_dispatch rehearsal; only a non-prerelease published Release still triggers a real publish. See docs/developer/release-archival-readiness.md.

  • Added scripts/build_release_candidate_evidence_index.R, the production counterpart to scripts/build_release_candidate_rehearsal.R (which only ever emits synthetic blocked placeholders derived from policy alone). It assembles a real, checksum-verified release-candidate-evidence-index.json from retained gate evidence – without fabricating evidence for gates that don’t have any. Run against the real, formally approved production_baseline, external_concordance, and ancestry_three_backend evidence from this release cycle: those three gates are passed with named approved review records; benchmark_history is honestly blocked (not passed) on the documented zero-published-GitHub-Releases gap; the remaining 11 gates are not_run because no real evidence source is wired in for them yet. Verified end to end through the real build_release_candidate_dossier.R: all checksums verified, release_ready: false, 3 / 15 required 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 in docs/); a release-ready production dossier still needs the remaining gates wired up and, separately, this index published as GitHub Release assets. See docs/developer/release-candidate-closure.md.

  • Added read_canonical_ancestry_three_backend_evidence() and, to the scientific-concordance contract, approve_scientific_concordance_record() and read_scientific_concordance_suite(). Both canonical evidence contracts had a write_ function but no matching read_/approve_ counterpart, so retained evidence could never actually be reloaded and formally transitioned from proposed to approved – only recorded as prose in docs/. The readers rebuild validated objects directly from JSON (raw sample identities are never persisted for the ancestry contract, only sample_order_sha256, so reconstruction goes through the retained fields rather than the new_* constructors that require them) using the same pattern as the existing read_canonical_real_data_baseline_snapshot(). Used to formally approve the real production_baseline, external_concordance, and ancestry_three_backend evidence produced earlier this release cycle against their actual reviewed determinations already recorded in docs/.

  • Added scripts/run-ancestry-three-backend-proposal.R, the first execution driver for the ancestry_three_backend release gate (#24): derives a wider genomic interval than production_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 via run_ancestry(), computes per-backend stability and the cross-backend select_ancestry_k() consensus, computes cross-backend Q-matrix alignment at the selected K, and assembles a new_canonical_ancestry_three_backend_evidence() proposal with a checksum inventory. role/minimum_alignment_score for 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 via new_release_benchmark_record()/register_release_benchmark() (a historical trend archive), while inst/scripts/scientific_review_packet.R – the actual release-review packet builder – looks specifically for continuous_benchmarks.json, the artifact write_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 a PopgenVCFContinuousBenchmarkObservation, compares it against a prior observation extracted from the downloaded baseline archive when available, and writes continuous_benchmarks.{tsv,json,md} alongside the existing archive (round-tripping through the existing archive.tar.gz packaging 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 functions run_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), confirming scientific_review_packet.R now finds and parses real, passing evidence. The canonical/medium/large benchmark-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>.log file, not to the structure.py process’s stdout/stderr that run_faststructure() was already capturing (which is effectively empty). run_faststructure() now parses that native log for Marginal Likelihood = X and records it per K; faststructure_backend() passes it through as the replicate’s marginal_likelihood metric. This was silently breaking select_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 and select_ancestry_k() errors closed rather than silently skipping a backend. Found and fixed while starting the canonical ancestry_three_backend evidence-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 an ADMIXTURE_SEED environment variable that the ADMIXTURE binary does not read (confirmed empirically – strings on the installed 1.3.0 binary shows it documents a --seed=X command-line flag, not an environment variable; repeated runs and runs with different ADMIXTURE_SEED values produced byte-identical Q matrices and log-likelihoods). admixture_command_arguments() now takes an explicit seed parameter and passes --seed=X on 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 of run_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 called normalizePath()/hash_file() on cfg$input$metadata, crashing run_pipeline() at its final step whenever no metadata file was supplied – a configuration this package explicitly supports elsewhere (metadata_from_samples()). The manifest’s metadata/metadata_sha256 fields are now NA when metadata is absent instead of the pipeline failing outright.

  • Removed DESCRIPTION’s redundant Author: field (R derives it automatically from Authors@R, and the two had drifted, producing an R CMD check NOTE); updated scripts/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 direct run_fst()/run_pca() calls on the same real fixture data, its SNP-count and LD-pruned-count thresholds are exercised independently, and write_chromosome_results()’s file output, empty-input, and chromosome-name-sanitization paths are verified.

  • Retired tools/reconcile-roxygen-owners.py’s and tools/reconcile-legacy-public-exports.py’s stale file/symbol mappings left behind by the duplicate-definition cleanup below, restoring the Generated documentation CI gate; verified by rerunning roxygen2::roxygenise() and every step of that workflow locally before pushing.

  • Hardened test-package-check-hygiene.R’s and test-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 plain R CMD check on a built tarball, devtools::check()), while continuing to run for real under this repository’s own CI and in-tree devtools::test(); confirmed against an actual R CMD check run’s 00_pkg_src/<pkg> copy and reproduced covr’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 check NOTEs: a stray .claude directory pulled into the built source package, and an unused viridisLite Import (ggplot2::scale_fill_viridis_c already bundles its own color maps).

  • Removed 49 duplicate function and S3-method definitions across R/, left over from a load-order-dependent pattern with no Collate: 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 null values, 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-linked canonical_validation gate evidence while the production baseline and external concordance remain not_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 check notes: 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, and insufficient-evidence states.

  • 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.0 as 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_out ledger 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.