Core engine and clock registry
2. Core engine
2.1 Device and dtype
falconage/core/device.py
@dataclass(frozen=True)
class DeviceSpec:
device: torch.device # cpu | cuda:N | mps
dtype: torch.dtype # float64 | float32
batch_size: int
backend: Literal["torch", "numpy"]
def resolve_device(
device: str = "auto", # "auto" | "cpu" | "cuda" | "cuda:1" | "mps"
dtype: str = "float64", # "float64" | "float32"
batch_size: int | None = None,
) -> DeviceSpecauto picks CUDA when torch.cuda.is_available(), then MPS, then CPU, and logs the choice, mirroring pyaging’s set_torch_device (pyaging/predict/_pred_utils.py:398). batch_size defaults to 1024 on CPU and is raised to fill available VRAM on CUDA, capped so that the largest PC rotation (125,175 × k for SystemsAge) fits.
FALCONAge diverges here in four ways, three of them deliberate. The module is
core/backend.py, the function isresolve(), andDeviceSpeccarries plain strings with nobatch_sizefield: nothing shipping is large enough to need batching, and a field that is always its default is a field that is wrong the first time it matters.The substantive difference is
auto. It resolves to CPU even when a CUDA device is present, because on the clocks that ship the card loses: a 2,340-feature dot product is a rounding error against the pandas alignment that feeds it, and the transfer is pure cost. Measured at 4.6× slower on an RTX 4060 at 16,384 samples. Picking CUDA because one exists would make the common case several times worse on every machine that has one, silently, so the GPU is opt-in viadevice="cuda"orFALCONAGE_DEVICE=cuda. The numbers are in GPU support.
resolve()also refuses a device it cannot provide rather than falling back. A run asked for a GPU and quietly given a CPU looks like a very slow success, and the user finds out hours later.
FP64 is the default because that is what pyaging uses and what the gold-standard vectors are generated at. FP32 roughly doubles throughput on consumer GPUs, which have poor FP64 rates, and is opt-in. Clocks whose registry entry sets requires_fp64: true are promoted back to FP64 for their own forward pass and a warning is emitted; this covers the PC clocks, whose 78,464- and 125,175-dimensional centring and rotation is ill-conditioned (the science notes, §14.3, §16).
def check_dtype_safety(clock: ClockMeta, spec: DeviceSpec) -> DeviceSpec:
"""Promote to FP64 for clocks that need it. Returns a possibly-modified spec."""Memory guard: before a clock runs, estimate n_samples × n_features × itemsize plus the rotation matrix, and either lower the batch size or raise InsufficientMemoryError with the required figure, rather than letting CUDA OOM mid-run.
2.2 The data container
falconage/core/container.py
FalconData wraps anndata.AnnData rather than replacing it, so the on-disk artefact is a plain .h5ad that both languages and every scverse tool can read.
class FalconData:
adata: AnnData # X = primary modality, obs = samples, var = features
modality: str # "methylation" | "clinical" | "expression" | "proteomics"
platform: str | None # "illumina_450k" | "illumina_epic_v2" | ...
species: str # "homo_sapiens" | ...
units: dict[str, str] # feature → unit, for clinical chemistryConventions, matching pyaging’s AnnData slot contract and extending it:
| Slot | Content |
|---|---|
X |
samples × features, the primary modality |
obs |
age, sex, tissue, condition, subject_id, timepoint, time, status, plus predictions after scoring |
var |
feature_id, id_type, platform, chromosome, position |
obsm["X_<clock>"] |
transient per-clock feature slice, deleted after scoring unless keep=True |
layers["<modality>"] |
a second modality on the same features |
uns["falconage"] |
the run manifest |
uns["<clock>_percent_na"], uns["<clock>_missing_features"], uns["<clock>_metadata"] |
per-clock diagnostics |
Multi-modality with different feature spaces (850K methylation probes alongside 3,000 proteins) uses MuData, with FalconData.from_mudata() / .to_mudata(). A single FalconData always has one primary feature space.
Constructors:
FalconData.from_dataframe(df, modality, platform=None, metadata_cols=(), units=None)
FalconData.from_anndata(adata, modality, ...)
FalconData.read_h5ad(path)
FalconData.write_h5ad(path)from_dataframe takes samples in rows and features in columns, splits metadata_cols into obs, and preserves df.index as sample identifiers. This is the same signature as pyaging’s df_to_adata minus the imputation, which moves to the preprocess module where it belongs.
2.3 Run manifest
falconage/core/manifest.py
Everything needed to reproduce a run, written into uns["falconage"] and into a sidecar run_manifest.json next to the results.
@dataclass
class RunManifest:
falconage_version: str
registry_version: str
registry_schema: str
python_version: str
r_version: str | None # set when the call came through reticulate
platform: str # OS, arch
device: str
dtype: str
torch_version: str
cuda_version: str | None
timestamp: str # ISO 8601, UTC
command: str # the resolved call
config: dict # fully resolved, defaults expanded
inputs: list[InputRecord] # path, sha256, n_samples, n_features
clocks: list[ClockRun] # name, version, weights_sha256, percent_na, warnings
warnings: list[str]ClockRun.weights_sha256 is what makes a result auditable: two runs that report the same score for the same clock either used the same weights or the manifest says they did not.
2.4 Logging and verbosity
falconage/core/logging.py
Three levels, matching cyRAVEN’s cyRAVEN.verbose option so the two front ends behave the same:
| Level | Python | R |
|---|---|---|
none |
logging.CRITICAL |
options(FALCONAge.verbose = "none") |
inform (default) |
logging.INFO, indented progress |
"inform" |
debug |
logging.DEBUG, per-op timings and shapes |
"debug" |
Progress bars via rich on a TTY, plain line output otherwise, suppressed entirely under none. Every message goes through the logger, so logging.disable() in Python and suppressMessages() in R both work.
2.5 Configuration
falconage/core/config.py
Precedence, lowest to highest: packaged defaults → user config file (~/.config/falconage/config.yaml) → environment (FALCONAGE_*) → explicit function arguments.
# ~/.config/falconage/config.yaml
device: auto
dtype: float64
batch_size: 1024
verbose: inform
cache_dir: ~/.cache/falconage
registry_version: "1.1.0" # pin to make a project reproducible
hf_token: null
max_download_gb: 502.6 Units
falconage/core/units.py
The single most common source of wrong answers in this field is a clinical biomarker in the wrong unit (the science notes, §16). The unit registry makes that a hard error.
class UnitRegistry:
def convert(self, value: ArrayLike, marker: str, frm: str, to: str) -> ArrayLike
def canonical(self, marker: str, variant: str) -> str
def require(self, markers: Sequence[str], units: dict[str, str] | None) -> dict[str, str]require raises MissingUnitsError naming every marker whose unit was not declared. There is no default and no inference from magnitude, a creatinine of 80 is plausible as both µmol/L and an absurd mg/dL, and guessing is how the 88× error happens. Conversions carried:
| Marker | Units | Factor |
|---|---|---|
| albumin | g/L ↔︎ g/dL | ×10 |
| creatinine | µmol/L ↔︎ mg/dL | ×88.42 |
| glucose | mmol/L ↔︎ mg/dL | ×18.016 |
| CRP | mg/L ↔︎ mg/dL | ×10 |
| total/HDL/LDL cholesterol | mmol/L ↔︎ mg/dL | ×38.67 |
| triglycerides | mmol/L ↔︎ mg/dL | ×88.57 |
| urea/BUN | mmol/L ↔︎ mg/dL | ×2.801 |
| uric acid | µmol/L ↔︎ mg/dL | ×59.48 |
| bilirubin | µmol/L ↔︎ mg/dL | ×17.1 |
2.7 Error hierarchy
falconage/core/errors.py
FalconError
├── ConfigurationError
│ ├── MissingUnitsError
│ └── InvalidDeviceError
├── DataError
│ ├── FeatureMismatchError # 100% of a clock's features absent
│ ├── ExcessiveMissingnessError # above the configured threshold
│ ├── PlatformMismatchError # EPIC v2 probes not aggregated, etc.
│ ├── MissingCovariateError # clock needs age/sex and it is not there
│ └── OutOfRangeError # beta outside [0,1], negative counts
├── RegistryError
│ ├── UnknownClockError
│ ├── WeightsUnavailableError
│ └── SchemaValidationError
├── DownloadError
│ ├── AccessionNotFoundError
│ ├── ChecksumMismatchError
│ └── CredentialsRequiredError
└── ComputeError
├── InsufficientMemoryError
└── NumericalInstabilityError
Every error carries .remedy, a one-sentence statement of what to do about it, which the R bridge surfaces as the condition message and the CLI prints after the traceback.
3. Clock registry
3.1 Why the registry is a separate artefact
The registry is versioned independently of the package, packaged into both language distributions, and validated in its own CI job. That separation exists because adding a clock and releasing software are different events with different risk profiles: a user pinning registry_version: "1.0.0" gets the same coefficients in a year regardless of package upgrades, and a registry fix does not require a PyPI release.
3.2 Schema
v1.0 stores this differently. All 178 entries live in one file,
python/src/falconage/registry/data/clocks.yaml, loaded into frozen dataclasses byregistry/registry.py. There is noregistry/directory at the repository root, no file per clock, and no pydantic.The per-clock tree below is the right shape for a registry that outside contributors edit concurrently, one file per pull request, no merge conflicts. At 178 entries maintained by one author it bought nothing and cost a directory walk on every import, plus 178 files to keep a schema version in step across. The single file is 178 entries the loader validates in one pass, and it travels inside the wheel so a clock catalogue is never a download. Pydantic went the same way: the fields are flat, the loader coerces and defaults them in about forty lines, and a runtime dependency that exists to check a file the package itself ships is a dependency earning nothing.
The field vocabulary below is otherwise accurate: it is what the loader reads.
# one entry in falconage/registry/data/clocks.yaml
id: grimage2
display_name: GrimAge2
schema_version: "1.0"
clock_version: "1.0.0"
provenance:
year: 2022
citation: "Lu AT, Binder AM, Zhang J, et al. DNA methylation GrimAge version 2. Aging. 2022;14(23):9484-9549."
doi: "10.18632/aging.204434"
last_author: "Steve Horvath"
journal: "Aging"
approved_by_author: false
evidence: paper-confirmed # author | code | paper | supplement-confirmed
license:
spdx: null
research_only: true
redistributable: true
notes: "Research use only; commercial use requires a licence from UCLA."
biology:
data_type: dna_methylation
species: homo_sapiens
tissue: [whole_blood]
platform: [illumina_450k]
population: older_adults
generation: second # first|second|pace|causal|mitotic|system|foundation
predicts: [mortality_risk]
training_target: [mortality]
output:
unit: years
scale_type: age_years # governs which downstream operations are legal
plausible_range: [0, 150] # a value outside this is flagged, not clipped
features:
n_features: 1032
id_type: cpg_illumina
requires_covariates: [age, sex]
reference_values: true
architecture:
kind: multi_stage_cox
sub_models: # order is load-bearing, see §4.3
- {name: GDF15, n_features: 138}
- {name: B2M, n_features: 92}
- {name: CystatinC, n_features: 88}
- {name: TIMP1, n_features: 43}
- {name: ADM, n_features: 187}
- {name: PAI1, n_features: 211}
- {name: Leptin, n_features: 187}
- {name: PACKYRS, n_features: 173}
- {name: LogCRP, n_features: 132}
- {name: A1C, n_features: 87}
covariate_order: [age, female]
requires_fp64: false
preprocess:
- op: fill_nan_with_reference
postprocess:
- op: cox_to_years
params:
cox_mean: 15.370829484122
cox_std: 1.09534876966487
age_mean: 66.0943807965085
age_std: 9.05974444998421
weights:
uri: "hf://bhagesh-h/falconage-registry/clocks/grimage2/weights.safetensors"
sha256: "..."
size_bytes: 8421376
reference_uri: "hf://bhagesh-h/falconage-registry/clocks/grimage2/reference.safetensors"
reference_sha256: "..."
reliability:
technical_icc: {value: 0.83, source: "Higgins-Chen 2022", n: null}
biological_icc: {value: 0.61, source: "TranslAGE 2025", n: null}
benchmark:
computage:
aa2_passed: null
aa1_passed: null
medae: null
median_bias: null
total_score: null
gold_standard:
input: "tests/vectors/grimage2/input.parquet"
expected: "tests/vectors/grimage2/expected.parquet"
tolerance: 1.0e-6
source: "pyaging 0.3.0, FP64, CPU"
known_discrepancies: []3.3 The discrepancy field is load-bearing
The science, §15.2 documents the eleven clocks where the published paper and the coefficient set in circulation disagree. Every implementation in the field silently inherits one side of each disagreement. FALCONAge records both and warns at score time:
known_discrepancies:
- field: n_features
paper_says: 96
implementation_has: 251
source: "pyaging audit_report.md"
resolution: implementation
note: >
The paper and supplement report a 96-CpG primary ultrasound model. The
third-party coefficient file everyone uses carries 251 CpGs and cannot be
mapped to any of the paper's six LASSO variants. No author-hosted 251-CpG
model was located.The nine to encode at minimum: bohlin (96 vs 251), cvdwesterman (1,305 vs 235), zhangmortality (integer quartile count vs continuous weighted sum), yingcausage (586 vs 585), yingdamage (1,090 vs 1,089), yingadaptage (1,000 vs 999), senchronoage (188 vs 187), sencultureage (141 vs 142), senmortalityage (89 vs 91), plus bocklandt (2 CpGs vs 1) and downsyndrome (EWAS estimates repurposed as a zero-intercept score).
Warning behaviour is a config knob: discrepancy_warnings: once | always | never, defaulting to once per clock per session.
3.4 Controlled vocabulary
registry/vocabulary.yaml fixes the allowed values for data_type, species, tissue, platform, population, generation, predicts, training_target, unit, scale_type and architecture.kind. This is the mechanism that kept pyaging’s 173 entries coherent through an audit that collapsed 94 free-text tissue strings to 30 controlled terms (pyaging/clocks/metadata/audit_report.md). registry/validate.py fails CI on any value outside the vocabulary, any missing required field, any weight URI without a checksum, and any clock without a gold-standard vector.
3.5 Registry API
falconage/registry/registry.py
class ClockRegistry:
@classmethod
def load(cls, version: str | None = None) -> ClockRegistry
def get(self, clock_id: str) -> ClockMeta
def list(self) -> list[str]
def search(self, query: str) -> list[ClockMeta] # free text over name, citation, notes
def filter(self, **criteria) -> list[ClockMeta] # data_type=, species=, generation=, ...
def by_doi(self, doi: str) -> list[ClockMeta]
def compatible_with(self, data: FalconData,
min_coverage: float = 0.8) -> list[ClockMeta]
def cite(self, clock_id: str, style: str = "plain") -> strcompatible_with is the function that makes a 173-clock registry usable: hand it a loaded dataset and it returns the clocks whose species, platform and feature coverage actually match, sorted by coverage. This replaces the current field practice of running everything and reading the missingness column afterwards.
3.6 Coefficient provenance and the three availability tiers
A clock is two things: an architecture, which is a procedure described in a paper, and a coefficient set, which is data produced by fitting that procedure to a cohort. FALCONAge writes every architecture from scratch. It cannot write a coefficient set from scratch, because there is no procedure that regenerates Horvath’s 353 weights without Horvath’s training data.
So the question for each clock is only ever where do its numbers come from and may we ship them. Surveying pyaging’s 173 notebooks for the URL each one fetches gives the answer for the 159 methylation and clinical clocks in v1:
| Provenance | n | Traceable to the authors |
|---|---|---|
| Publisher supplementary file | 31 | yes, the paper’s own Additional file |
| Author’s own code repository | 48 | yes |
| Author-hosted file share (Box, Google Drive) | 24 | yes, on fragile links |
| Copied package-to-package, primary source unrecorded | 37 | not without tracing |
| No public primary source | 13 | no |
| Embedded inline, small enough to read off the paper | 6 | yes |
The 37 in the fourth row are the reason the eleven discrepancies in §3.3 exist. epiTOC2’s coefficients in pyaging are a file lifted from biolearn’s repository; the McCartney scores and most of the deconvolution panels are the same story. Copying a copy is how a transcription error becomes a field-wide convention, and tracing each back to its supplement is the only way to find out which reading is right.
3.7 The three tiers
Every registry entry carries availability, one of three values. This governs what ships, what is fetched, and what the user has to do.
| Tier | n | Architecture | Coefficients | User action |
|---|---|---|---|---|
| A - open | 38 | ships | ship, extracted from the primary source and redistributed | none |
| B - fetch on first use | 95 | ships | fetched from the author’s URL at first use, cached, licence printed once | none, but needs network once |
| C - permission required | 28 | ships | never fetched; the user obtains them and points FALCONAge at a local file | obtain a licence or file |
As shipped in v1.0.0. The registry names the tiers by what they mean: bundled (A), untraced (B) and licensed (C). The letters are still accepted by filter, list_clocks and falconage clocks list --tier. The counts are 69 bundled, 52 untraced and 57 licensed. Fetching on first use was not built: an untraced clock carries its metadata and no coefficients, and scoring it raises with the reason.
Tier A is where the coefficient set came from a publisher supplement or a permissively licensed author repository and may be redistributed. Includes PhenoAge, KDM and homeostatic dysregulation, which need no network at all.
Tier B is where the coefficients are public but FALCONAge has no clear right to re-host them, or where the primary source has not yet been traced. The scaffold ships; the numbers arrive on first use. Forty-eight of these are author repositories whose licences have not been individually reviewed and will move to A as each is checked.
Tier C is the subject of §4.7.
# in every registry entry
availability: B
coefficient_source:
tier: T2
url: "https://github.com/rsinghlab/AltumAge/raw/main/example_dependencies/..."
format: csv
sha256: "..."
extractor: extractors/altumage.py
licence: "MIT (repository)"
redistributable: true
retrieved: "2026-08-07"
primary_source_traced: trueprimary_source_traced: false is a visible debt, counted in the registry validation report, and falconage clocks list --untraced prints them.
3.8 Coefficient extraction
registry/extractors/. One small script per clock, or per family where the format is shared. Each reads the primary source in whatever shape it was published, xlsx, csv, a zip of csvs, an RData object, a supplementary PDF table, and emits a normalised two-column file plus a provenance record.
registry/extractors/
├── _base.py # fetch, checksum, normalise, write, record
├── horvath2013.py # Springer MOESM3 csv
├── hannum.py # Elsevier mmc2.xlsx, sheet 2, cols B:C
├── ying.py # Nature Aging MOESM6 zip, three clocks in one archive
└── ...
class Extractor(ABC):
clock_id: str
source_url: str
expected_sha256: str
expected_n_features: int
@abstractmethod
def parse(self, raw: bytes) -> pd.DataFrame: # feature_id, coefficient
...
def run(self) -> ExtractionResult:
# fetch -> verify checksum -> parse -> assert n_features -> assert no
# duplicate feature ids -> compare against any prior extraction -> writeTwo assertions do most of the work. expected_n_features catches a supplement whose layout changed or a sheet index that was off by one. The comparison against the prior extraction turns an upstream file being silently revised into a failed CI run rather than a changed result.
Diffing against the field. For every clock where pyaging, biolearn, computage or methylCIPHER also carries coefficients, extraction runs a comparison and writes the result to registry/diffs/<clock>.md: features present in one and not the other, coefficients differing beyond 1e-9, and a verdict. Disagreements are published rather than resolved silently. This is what turns the eleven known discrepancies from inherited folklore into a documented finding, and it is expected to surface more.
3.9 Weight loading
falconage/registry/weights.py
def resolve_weights(meta: ClockMeta, cache_dir: Path | None = None) -> Path
def load_weights(meta: ClockMeta, spec: DeviceSpec) -> dict[str, torch.Tensor]
def register_local_weights(clock_id: str, path: Path, sha256: str | None = None) -> NoneRules:
- safetensors only.
torch.load(weights_only=False), which pyaging uses (pyaging/predict/_pred_utils.py:90), executes arbitrary code from a downloaded file. Architecture lives in the registry YAML and inmodels/; tensors live in safetensors; nothing pickled is ever loaded. - Checksum verified against the registry before use; a mismatch raises rather than warns.
- Hugging Face cache for tier A, the same cache for tier B, so weights are shared with other tools and survive a reinstall.
- Offline mode:
FALCONAGE_OFFLINE=1uses only what is cached and raisesWeightsUnavailableErrornaming the missing clock rather than attempting a network call. - Bundling: nothing over 1 MB ships inside the PyPI or CRAN artefact. The clinical-chemistry sets are the exception. PhenoAge is ten numbers, so that path works with no network at all.
- Licence surfacing: tier B prints the source licence once per clock per session on first fetch. Tier C prints the obtain-it-yourself instructions.
As shipped. Bundled coefficients ship inside the package and are checked against the registry’s SHA-256 before use; untraced clocks are not fetched (§3.7), so the cache and the licence line for tier B were not built. Licensed clocks print the obtain-it-yourself instructions, as above.