Docker, testing, documentation and CI

9. Docker

Two images from one Dockerfile family, following the cyRAVEN pattern in its inst/scripts/Dockerfile: pinned base, dependency layer copied before source, and dependencies read from the declaration rather than a second list. The images carry no build-time checks, to keep them light; the test suite runs in the image on demand (python -m pytest /opt/falconage/src/python/tests).

9.1 Images

Tag Base Contains
falconage:1.0.0-cpu python:3.12-slim-bookworm Python core, R, FALCONAge, CLI
falconage:1.0.0-cuda nvidia/cuda:12.4.1-runtime-ubuntu22.04 the same plus the CUDA torch build
falconage:1.0.0-slim python:3.12-slim-bookworm Python core and CLI only, no R

9.2 Dockerfile.cpu, annotated

# =============================================================================
# FALCONAge - reproducible CPU environment
# =============================================================================
#
# WHY A PINNED BASE. Clock coefficients are fixed, but the arithmetic around
# them is not: a different BLAS changes the last bits of a 78,464-dimensional
# PC rotation, and a different scikit-learn changes what SimpleImputer stores.
# Neither moves a published age by a year, but both make two runs of the same
# data disagree in the seventh decimal, which is exactly what the gold-standard
# tests assert on. `latest` would silently re-roll that on a rebuild.
#
# Versions this image reproduces:
#   Python 3.12.8 . torch 2.6.0 (CPU) . numpy 2.2.1 . pandas 2.2.3
#   anndata 0.11.3 . scikit-learn 1.6.1 . R 4.4.3 . reticulate 1.40.0
#
# BUILD, from the repository root:
#   docker build -f docker/Dockerfile.cpu -t falconage:1.0.0-cpu .
#
# RUN. Nothing is baked in: no data, no cohort, no credentials.
#   docker run --rm \
#     -v "$PWD/data:/data:ro" -v "$PWD/results:/results" \
#     falconage:1.0.0-cpu \
#     score --input /data/prepared.h5ad --clocks compatible --outdir /results
#
#   On Windows PowerShell replace "$PWD" with "${PWD}".
#
# ITERATING WITHOUT REBUILDING. Mount a checkout and set FALCONAGE_SOURCE; the
# entrypoint runs that tree instead of the installed copy. A rebuild is still
# needed when the source starts requiring a package the image does not have.
#     -v "$PWD:/src:ro" -e FALCONAGE_SOURCE=/src
# -----------------------------------------------------------------------------
FROM python:3.12-slim-bookworm AS base

LABEL org.opencontainers.image.title="FALCONAge" \
      org.opencontainers.image.description="Multiomic biological age and aging clock scoring" \
      org.opencontainers.image.source="https://github.com/bhagesh-h/FALCONAge" \
      org.opencontainers.image.licenses="GPL-3.0-or-later"

ENV PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    # torch and numpy are OpenMP-threaded. Unset they take every core on the
    # host, which is antisocial on a shared machine and causes thermal
    # throttling on a laptop. Override with -e OMP_NUM_THREADS=8.
    OMP_NUM_THREADS=4 \
    # Weights land here. Mount a host directory over it to keep them between
    # runs instead of re-downloading a gigabyte each time.
    HF_HOME=/opt/falconage/cache \
    FALCONAGE_CACHE=/opt/falconage/cache \
    LANG=C.UTF-8

# Each library is here because a specific wheel needs it, not as a blanket
# build-essential block.
#   libhdf5      -> h5py, which anndata writes .h5ad through
#   libcurl/ssl  -> the download module
#   libxml2      -> lxml, used by the GEO MINiML parser
#   libgomp1     -> OpenMP runtime for torch and numpy
RUN apt-get update && apt-get install -y --no-install-recommends \
        libhdf5-dev libcurl4-openssl-dev libssl-dev libxml2-dev \
        libgomp1 zlib1g-dev libbz2-dev liblzma-dev ca-certificates curl \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /opt/falconage

# ---- dependency layer ------------------------------------------------------
# Only the declaration, before any source. This layer costs several minutes and
# rebuilding it on every edit to src/ is the single biggest waste in a naive
# Dockerfile.
COPY python/pyproject.toml python/uv.lock /opt/falconage/python/
RUN pip install --no-cache-dir uv \
 && cd python && uv export --frozen --no-dev --format requirements-txt > /tmp/req.txt \
 && pip install --no-cache-dir -r /tmp/req.txt

# ---- R layer ---------------------------------------------------------------
FROM base AS with-r
RUN apt-get update && apt-get install -y --no-install-recommends \
        r-base r-base-dev libfontconfig1-dev libfreetype6-dev libpng-dev \
    && rm -rf /var/lib/apt/lists/*

# install_deps.R parses r/DESCRIPTION instead of repeating the package list, so
# the image can never install a different set than the package declares.
COPY r/DESCRIPTION docker/install_deps.R /opt/falconage/r/
RUN Rscript /opt/falconage/r/install_deps.R /opt/falconage/r/DESCRIPTION

# ---- source ----------------------------------------------------------------
FROM with-r AS final
COPY . /opt/falconage/src
RUN pip install --no-cache-dir --no-deps /opt/falconage/src/python \
 && R CMD INSTALL --no-multiarch --with-keep.source /opt/falconage/src/r \
 && cp /opt/falconage/src/docker/entrypoint.sh /opt/falconage/entrypoint.sh \
 && chmod +x /opt/falconage/entrypoint.sh

# reticulate must find the interpreter that already has the core installed,
# rather than trying to build an environment inside a read-only container.
ENV RETICULATE_PYTHON=/usr/local/bin/python

VOLUME ["/results", "/opt/falconage/cache"]
WORKDIR /work
ENTRYPOINT ["/opt/falconage/entrypoint.sh"]
CMD ["--help"]

Dockerfile.cuda differs only in the base image and the torch index URL.

9.3 entrypoint.sh

Same indirection as cyRAVEN’s: run the installed copy by default, or a mounted source tree when FALCONAGE_SOURCE is set, so routine edits need no rebuild.

#!/bin/sh
set -e

if [ -n "${FALCONAGE_SOURCE}" ]; then
  # -e is deliberately editable-install-free: prepending to PYTHONPATH is
  # instant and leaves the image's site-packages untouched, which matters
  # because most run configurations mount the filesystem read-only.
  PYTHONPATH="${FALCONAGE_SOURCE}/python/src:${PYTHONPATH}"
  export PYTHONPATH
fi

# `falconage` with no recognised verb is a help request, not an error.
case "$1" in
  ""|-h|--help) exec falconage --help ;;
esac

exec falconage "$@"

9.4 docker-compose.yml

services:
  falconage:
    image: falconage:1.0.0-cpu
    volumes:
      - ./data:/data:ro
      - ./results:/results
      - falconage-cache:/opt/falconage/cache
    environment:
      OMP_NUM_THREADS: 8

  falconage-gpu:
    image: falconage:1.0.0-cuda
    deploy:
      resources:
        reservations:
          devices: [{driver: nvidia, count: all, capabilities: [gpu]}]
    volumes:
      - ./data:/data:ro
      - ./results:/results
      - falconage-cache:/opt/falconage/cache

volumes:
  falconage-cache:

The named cache volume is not cosmetic: the PC clocks are several hundred megabytes each and re-downloading them on every container start is the difference between a two-minute run and a twenty-minute one.

10. Testing

10.1 Gold-standard vectors

The load-bearing test. For every clock: an input matrix and the output the reference implementation produces, stored under tests/vectors/<clock>/, asserted to 1e-6 in FP64, and consumed by both languages.

tests/vectors/
├── manifest.yaml            # clock -> {input, expected, tolerance, source, generated}
├── horvath2013/
│   ├── input.parquet        # 20 synthetic samples x 353 CpGs, seeded
│   ├── expected.parquet     # sample_id, value
│   └── provenance.json      # pyaging 0.3.0, FP64, CPU, commit hash, date
└── ...

Inputs are synthetic and seeded rather than real data: no consent question, no file-size problem, and a seeded generator gives coverage of the edge cases real data rarely contains, all-zero rows, a constant feature, values at exactly 0 and 1, and a controlled missingness pattern.

Generation is a one-off script per source implementation (scripts/generate_vectors_pyaging.py, scripts/generate_vectors_biolearn.py, scripts/generate_vectors_bioage.R), run in a pinned environment and committed with its provenance. Regenerating is a deliberate act recorded in the changelog, never a fix for a failing test.

registry/validate.py fails when a clock has no vector, so the registry cannot grow past the test suite.

Which oracle

Validating against pyaging makes pyaging the arbiter of correctness, and its bugs become ours. Oracles are therefore ranked, and each vector records which tier it used:

Rank Oracle Available for
1 A worked example published by the clock’s own authors, reference inputs and the age they report a minority of clocks, but the only truth that is not somebody’s code
2 The authors’ own reference implementation, run in a pinned environment most clocks with an author repository
3 Hand computation from the published equation, for clocks small enough PhenoAge, Weidner, Vidal-Bralo, Bocklandt, Garagnani, ZhangMortality
4 An independent third-party package, with at least two agreeing the remainder
5 Synthetic weights, architecture only licensed clocks with no published worked example (§4.7)

A rank-4 vector where pyaging and biolearn disagree is not a vector; it is an open question, and it goes to registry/diffs/ rather than to tests/vectors/.

10.2 Cross-implementation comparison

A separate, non-blocking job compares FALCONAge against pyaging, biolearn, computage, methylCIPHER and BioAge on shared clocks, and writes a table of agreements and disagreements to docs/comparison.md. Disagreements are published rather than hidden, the science notes §15.2 shows the field already carries several, and a user comparing a FALCONAge number to a pyaging number deserves to know which ones differ and why.

10.3 Property tests

Hypothesis in Python, testthat with generated inputs in R.

Property Holds for
deconvolution proportions sum to 1 and are non-negative all deconvolution clocks
probability outputs lie in [0, 1] scale_type: probability
permuting feature column order does not change the result every clock
duplicating a sample does not change either copy’s score every clock except per-sample-normalised ones
per-sample-normalised clocks DO change under sample duplication ZhangEN, ZhangBLUP, DunedinPACE, Pasta, REG - this is a property, not a bug, and the test pins it
a synthetic monotone age gradient produces a monotone prediction chronological clocks
score(x)[i] == score(x[i]) for a single sample every clock without per-sample normalisation
FP32 and FP64 agree within the registry’s per-clock tolerance every clock
scale-type guards reject illegal operations acceleration(absolute) on a rate clock

10.4 Unit and integration

Unit tests per op with hand-computed expected values, the 15 preprocess ops and 15 postprocess transforms each get one, because these are where an off-by-one in a quantile index or a wrong constant hides. Integration tests run the full path (download a small cached fixture, preprocess, score, analyse, report) on both a methylation and a clinical fixture.

10.5 R/Python conformance

A CI gate, not an aspiration.

# tests/conformance/test_r_python_parity.py
@pytest.mark.parametrize("clock", REGISTRY.list())
def test_identical_scores(clock, fixture_h5ad, tmp_path):
    py = falconage.score(fa.FalconData.read_h5ad(fixture_h5ad), clocks=[clock])
    r  = run_r(f'''
        library(FALCONAge)
        d <- read_falcon_data("{fixture_h5ad}")
        write.csv(as.data.frame(score(d, clocks = "{clock}")), "{tmp_path}/r.csv")
    ''')
    assert_frame_equal(py.table(), pd.read_csv(tmp_path / "r.csv"), atol=0, rtol=0)

atol=0, rtol=0 - exact equality, not approximate. The two languages run the same computation, so anything other than bit equality means the bridge is doing something it should not, such as round-tripping a float through a string.

A second conformance test asserts API symmetry: every function in the Python public namespace has an R export with the matching name and argument set, checked against the table in §8.4.

10.6 Performance regression

Benchmarks on a fixed synthetic dataset, run nightly, with results committed so a slowdown is visible in the history rather than discovered later. Tracked: per-clock scoring throughput at 1,024 / 8,192 / 32,768 samples on CPU and CUDA; KNN imputation on a 500 by 400,000 matrix; IDAT parsing throughput; peak memory for the largest PC clock.

10.7 Coverage targets

90% line coverage on models/, score/ and registry/, which are where a wrong number comes from; 75% elsewhere. Coverage on download/ is deliberately lower because most of it is network error handling exercised by mocks rather than by real transfers.

11. Documentation

11.1 One site, two languages

A single Quarto site at docs/, with the Python API rendered by quartodoc and the R API by pkgdown, sharing a theme and navigation. Section titles are identical on both sides (§8.9), so a reader moving between them sees the same structure.

docs/
├── _quarto.yml
├── index.qmd                    # what it is, in four paragraphs
├── installation.qmd             # pip, R, Docker, GPU
├── quickstart.qmd               # the same analysis in both languages, side by side
├── guide/
│   ├── concepts.qmd             # BA, AA, generations, units - from [the science notes](science.qmd) 1 and 8
│   ├── download.qmd
│   ├── preprocess.qmd
│   ├── scoring.qmd
│   ├── choosing-a-clock.qmd
│   ├── analysis.qmd
│   ├── benchmarking.qmd
│   ├── gpu.qmd
│   ├── reproducibility.qmd
│   └── caveats.qmd              # [the science notes](science.qmd) 16, compressed
├── clocks/
│   ├── index.qmd                # searchable registry table, generated from YAML
│   └── <clock>.qmd              # one page per clock, generated
├── reference/                   # quartodoc, Python
├── r/                           # pkgdown output, mounted into the site
└── _extensions/

11.2 _quarto.yml

project:
  type: website
  output-dir: _site
  pre-render:
    - python scripts/build_clock_pages.py     # registry YAML -> clocks/*.qmd
    - Rscript scripts/build_pkgdown.R         # pkgdown -> docs/r/

website:
  title: "FALCONAge"
  favicon: assets/logo.png
  navbar:
    left:
      - text: Get started
        href: quickstart.qmd
      - text: Guide
        menu: [guide/concepts.qmd, guide/download.qmd, guide/preprocess.qmd,
               guide/scoring.qmd, guide/choosing-a-clock.qmd, guide/analysis.qmd]
      - text: Clocks
        href: clocks/index.qmd
      - text: Reference
        menu:
          - {text: "Python API", href: reference/index.qmd}
          - {text: "R API",      href: r/reference/index.html}
    right:
      - icon: github
        href: https://github.com/bhagesh-h/FALCONAge

format:
  html:
    theme: {light: cosmo, dark: darkly}
    toc: true
    code-copy: true
    code-annotations: hover

quartodoc:
  package: falconage
  dir: reference
  style: pkgdown                     # section-grouped, matching _pkgdown.yml
  sections:
    - title: Download
      desc: >
        Public data by accession. One verb covers GEO, ArrayExpress, SRA,
        Zenodo, Figshare, Hugging Face and GDC, and returns files alongside a
        sample table with age and sex extracted from whatever free-text field
        held them.
      contents: [download, probe, cache.ls, cache.rm, cache.verify]
    # ... the remaining sections carry the same titles and desc text as
    # r/_pkgdown.yml, which is what makes the two sites read as one.

quartodoc’s style: pkgdown produces a grouped reference index with the same shape pkgdown produces, which is why the two sides can share section titles and desc paragraphs verbatim. The Quarto version is pinned in CI and in the Docker image, because Quarto 2 is a full rewrite due late 2026 and a documentation build should not break on a toolchain upgrade nobody asked for.

11.3 Bilingual examples

Every guide page shows the same operation in both languages with Quarto tabsets:

::: {.panel-tabset}

## Python

```python
import falconage as fa

data = fa.prepare(fa.read_betas("betas.csv"))
res  = fa.score(data, clocks=["horvath2013", "phenoage"])
res.table().head()
```

## R

```r
library(FALCONAge)

data <- preprocess_methylation("betas.csv", platform = "illumina_450k")
res  <- score(data, clocks = c("horvath2013", "phenoage"))
head(as.data.frame(res))
```

:::

A CI job extracts both blocks from every page and runs them, so an example that has drifted from the API fails the docs build rather than misleading a reader.

11.4 Generated clock pages

scripts/build_clock_pages.py turns each registry YAML into a page carrying: what it predicts and in what unit, the platform and species it applies to, the feature count, the architecture, the preprocess and postprocess chain, the reliability ICCs, the benchmark scores, the citation and DOI, any known discrepancies, and a runnable example. The index is a searchable, filterable table over all of them. This is generated, never hand-written, so the docs cannot drift from the registry.

11.5 Docstring conventions

numpydoc in Python, roxygen2 in R, deliberately parallel:

Concept numpydoc roxygen2
Summary first line first line
Rationale Notes section body paragraph after the title
Argument Parameters @param
Return Returns @return
Example Examples @examples
Cross-reference See Also @seealso
Citation References @references

Both carry the same rationale text where a function exists in both languages, so a reader gets the same explanation whichever site they land on.

12. CI/CD and publishing

12.1 Workflows

FALCONAge runs four workflows, not nine, and the difference is a decision rather than a backlog. The table below is the specification. What .github/workflows/ actually holds:

Shipped Covers Specified rows it replaces
python-test.yaml tests, lint, nine guards, the unpinned-resolution job python-test.yaml, registry-validate.yaml
R-CMD-check.yaml R CMD check --as-cran with the Python core, plus a no-Python job r-cmd-check.yaml, conformance.yaml
docs.yaml Quarto site with the embedded pkgdown tree, deployed to gh-pages docs.yaml
release.yaml one workflow, a python job and an r job, both behind the version guard release-pypi.yaml, release-r.yaml

Registry validation and R/Python conformance are jobs inside the two check workflows, not workflows of their own: they share a checkout and an install with the suite they belong to, and splitting them would pay for that twice to gain a second badge saying the same thing.

docker.yaml and benchmark.yaml do not exist. Nothing publishes an image to a registry; README.md tells a user to build docker/Dockerfile.cpu locally, and the falconage:1.0.0-cpu tag it names is the one that build produces. The nightly benchmark would commit timings from a shared runner whose neighbours decide the numbers, which is a regression detector that cries wolf; the measurements that exist were taken on known hardware and are in GPU support.

The python-test.yaml row below is the one to disregard outright. There is no matrix and no Codecov upload, and the workflow’s own header explains at length why: the lock file pins one resolution, uv export --frozen reproduces it, and on any other interpreter the pinned wheels either do not exist or do not match, so a matrix job fails before reaching a test. Three red badges where one would do teaches people to ignore the badge. Coverage was dropped after printing sixty lines of site-packages paths that nobody read.

Workflow Trigger Does
python-test.yaml push, PR pytest on 3.10–3.13 × {ubuntu, macos, windows}; gold-standard vectors; property tests; coverage to Codecov
r-cmd-check.yaml push, PR R CMD check --as-cran on ubuntu-release with the Python core installed; a second job with no Python, asserting the package still loads and checks
conformance.yaml push, PR R/Python parity across every clock; API symmetry
registry-validate.yaml push to registry/** schema, vocabulary, checksums, gold-standard presence
docs.yaml push to main Quarto site including pkgdown, examples executed, deployed to gh-pages
docker.yaml push to main, tags build and push cpu/cuda/slim to GHCR, multi-arch on cpu
release-pypi.yaml tag v* version guard, build sdist and wheel, publish via trusted publishing
release-r.yaml tag v* version guard, R CMD build, --as-cran, attach tarball to the release
benchmark.yaml nightly performance regression, results committed

12.2 The version guard

Borrowed directly from cyRAVEN’s release.yaml, extended to four files. A tag and a version that disagree cannot be repaired afterwards, because the tag is what a citation points at.

- name: Versions must agree
  run: |
    TAG="${GITHUB_REF#refs/tags/v}"
    PY=$(grep -m1 '^version' python/pyproject.toml | sed 's/.*"\(.*\)"/\1/')
    R=$(grep -m1 '^Version:' r/DESCRIPTION | awk '{print $2}')
    PYREQ=$(grep -m1 'Config/falconage/python-version:' r/DESCRIPTION | awk '{print $2}')
    for pair in "python:$PY" "R:$R" "R-python-pin:$PYREQ"; do
      name="${pair%%:*}"; val="${pair#*:}"
      [ "$val" = "$TAG" ] || { echo "::error::$name is $val, tag is $TAG"; exit 1; }
    done
    echo "all versions agree at $TAG"

12.3 R CMD check without Python

The second job in r-cmd-check.yaml is the one that matters for CRAN. It runs on a machine with no Python core at all and asserts that library(FALCONAge) succeeds, falconage_available() returns FALSE, every example is skipped rather than erroring, and the check reports no ERROR or WARNING. The delay_load in .onLoad (§8.3) is what makes this possible, and this job is what stops it from silently regressing.

12.4 Publishing

PUBLISHING.md carries the same honest gap table cyRAVEN uses, per target.

PyPI - trusted publishing, no long-lived token. sdist plus a pure-Python wheel; no compiled extensions, so no cibuildwheel. Test on TestPyPI first for the initial release.

r-universe - the first R route, no review. Add the repository to a packages.json in bhagesh-h.r-universe.dev, and binaries for Windows, macOS and Linux appear within minutes.

CRAN - needs, beyond the above: cran-comments.md naming platforms checked and explaining every NOTE; \value on every exported function; runnable examples; total runtime under 10 minutes; no writing outside tempdir(); win-builder and macOS results. The reticulate dependency is the reviewable point, and the answer is the no-Python check job in §12.3 plus SystemRequirements.

Bioconductor - the better long-term home given the biocViews and the audience. Needs version 0.99.x at submission, BiocCheck clean, a BiocStyle vignette, package under 5 MB (satisfied, since no weights ship), no sapply or 1:n in package code, and a support-site subscription.

Docker - GHCR on every main push and tag; Docker Hub mirror for the release tags.

Zenodo - DOI minted from each GitHub release, which is what a methods section cites.