The .fitrx Fit Bundle
A .fitrx file is a single, portable, self-describing container for a fit result. It is designed to be readable from any language with stdlib-level support for zip, JSON, and CSV — Rust, R, Python, Julia.
A .fitrx is just a zip archive. You can inspect it without any tooling:
unzip -l run1.fitrx
unzip -p run1.fitrx manifest.json
unzip -p run1.fitrx fit.json | jq .thetaWhen to use it
Use --output run1.fitrx (CLI) or save_fit() (Rust API) when you want a single artifact that can be shipped between machines and languages — for example, fitting in Rust and post-processing in R/Python. The legacy *-sdtab.csv and *-fit.yaml files are still written; --output is additive.
Producing a bundle
From the CLI:
ferx model.ferx --data data.csv --output run1.fitrx
ferx model.ferx --data data.csv --output run1.fitrx --include-data--include-data embeds the input NONMEM CSV verbatim inside the bundle. Off by default — without it, downstream tools must re-supply the data when they want to call predict() or recompute diagnostics.
From the Rust API:
use ferx_core::io::fitrx::{save_fit, SaveFitOptions};
use std::path::{Path, PathBuf};
save_fit(
&fit_result,
&population,
&std::fs::read_to_string("model.ferx").unwrap(),
Path::new("run1.fitrx"),
SaveFitOptions { include_data: Some(PathBuf::from("data.csv")) },
)?;Loading a bundle
use ferx_core::io::fitrx::load_fit;
let loaded = load_fit(Path::new("run1.fitrx"))?;
println!("OFV: {}", loaded.fit.ofv);loaded.population is Some(...) only when data.csv was bundled. The model source is always available as loaded.model_source and can be re-parsed via parse_model_string to reconstruct a CompiledModel (e.g. to run predict()).
Archive layout
| Entry | Format | Required | Contents |
|---|---|---|---|
manifest.json |
JSON | yes | Format version, ferx version, timestamp, entry index |
fit.json |
JSON | yes | All scalars, vectors, and matrices on FitResult |
ebes.csv |
CSV | yes | One row per subject: ID, <eta names...>, ofv_contribution, n_obs |
ebes_kappa.csv |
CSV | only when n_kappa > 0 |
One row per (subject, occasion): ID, OCC, <kappa names...> |
predictions.csv |
CSV | yes | One row per observation: ID, TIME, DV, PRED, IPRED, CWRES, IWRES, EBE_OFV, N_OBS plus optional CENS, OCC |
model.ferx |
UTF-8 text | yes | Verbatim model source |
warnings.txt |
UTF-8 text | no | One warning per line. Mirrors fit.json:warnings for grep-friendliness; loaders read warnings from fit.json and ignore this entry. Writers should still produce it. |
data.csv |
CSV | only with --include-data |
Copy of the input NONMEM data |
Entries are deflate-compressed inside the zip archive.
manifest.json
{
"format_version": "1",
"ferx_version": "0.1.0",
"model_name": "warfarin",
"created_at": "2026-05-15T18:06:56Z",
"entries": ["manifest.json", "fit.json", "ebes.csv", "..."]
}format_version— currently"1". Loaders should refuse unknown values rather than try to parse them. New keys may be added within a version; removals or semantic changes bump the version.created_at— ISO-8601 UTC timestamp (seconds resolution).
fit.json
Top-level keys mirror FitResult fields. Enums are encoded as snake_case strings:
| Enum | Values |
|---|---|
method / method_chain |
"foce", "focei", "foce_gn", "foce_gn_hybrid", "saem" |
covariance_status |
"not_requested", "computed", "failed" |
error_model |
"additive", "proportional", "combined" |
theta.transform[i] |
"identity", "log", "logit", "logit_probability" |
sigma.types[i] |
"proportional", "additive" |
eta_param_info[i].param_type |
"log_normal", "additive", "logit", "logit_probability", "custom" |
Matrices use a row-major dense representation:
{ "rows": 2, "cols": 2, "data": [0.1, 0.0, 0.0, 0.2] }Option<T> fields use JSON null when absent (e.g. covariance_matrix is null when the covariance step did not run).
Non-finite floats. JSON has no representation for NaN or ±Inf. The format encodes any non-finite f64 as JSON null, both for scalars (e.g. shrinkage_eps, cov_condition_number) and for elements of numeric arrays including matrix data vectors. Loaders convert null back to NaN. This keeps the format robust for legitimate fits — shrinkage_eps is NaN when the model has fewer than two valid residuals, and cov_condition_number is +Inf when the smallest eigenvalue is non-positive.
Layout
{
"method": "focei",
"method_chain": ["focei"],
"converged": true,
"ofv": -280.18,
"aic": -266.18,
"bic": -247.28,
"n_obs": 110,
"n_subjects": 10,
"n_parameters": 7,
"n_iterations": 42,
"interaction": true,
"wall_time_secs": 1.234,
"n_threads_used": 4,
"uses_ode_solver": false,
"gradient_method_inner": "analytic (Dual2)",
"gradient_method_outer": "finite differences",
"nlopt_missing_algorithms": [],
"covariance_status": "computed",
"covariance_n_evals_estimated": null,
"trace_path": null,
"ebe_convergence_warnings": 0,
"max_unconverged_subjects": 0,
"total_ebe_fallbacks": 0,
"warnings": [],
"saem_mu_ref_m_step_evals_saved": null,
"theta": {
"names": ["TVCL", "TVV", "TVKA"],
"estimates": [0.13, 7.7, 0.76],
"se": [0.014, 0.29, 0.035],
"fixed": [false, false, false],
"transform": ["log", "log", "log"]
},
"omega": {
"names": ["eta_CL", "eta_V", "eta_KA"],
"matrix": { "rows": 3, "cols": 3, "data": [/* row-major */] },
"se": [/* SEs of the free entries, or null */],
"fixed": [false, false, false],
"log_transformed": [true, true, true],
"param_corr": null,
"shrinkage": [0.05, 0.10, 0.15]
},
"sigma": {
"names": ["prop"],
"estimates": [0.05],
"se": [0.001],
"fixed": [false],
"types": ["proportional"]
},
"error_model": "proportional",
"shrinkage_eps": 0.05,
"covariance_matrix": { "rows": 7, "cols": 7, "data": [/* ... */] },
"cov_eigenvalues": [1.0, 0.5, 0.2],
"cov_condition_number": 5.0,
"sir": null,
"iov": null,
"eta_param_info": [
{
"eta_name": "eta_CL",
"param_type": "log_normal",
"linked_theta": "TVCL",
"individual_param_name": "CL"
}
],
"model_name": "warfarin",
"ferx_version": "0.1.0",
"model_path": "examples/warfarin.ferx",
"data_path": "data/warfarin.csv",
"model_hash": "e3b0c4...",
"data_hash": "ba7816..."
}sir is non-null only when [fit_options].sir = true; iov is non-null only when the model uses kappa (block IOV).
model_path / data_path / model_hash / data_hash are populated when the fit was produced via fit_from_files (or run_model_with_data); they are absent (the keys are omitted) for in-memory fit() calls. Paths are stored verbatim as the caller supplied them (no canonicalisation), and the hashes are SHA-256 hex digests of the raw file bytes. They round-trip on save/load and are used by run_sir to refuse running against modified source files.
ebes.csv
ID,ETA_CL,ETA_V,ETA_KA,ofv_contribution,n_obs
1,0.029777,0.070511,0.438001,-25.745580,11
2,0.017193,-0.058745,-0.344668,-33.692046,11
ID— subject identifier as a string (matchesPopulation.subjects[i].id).- ETA columns — named after
omega.namesfromfit.json. ofv_contribution— the subject’s contribution to the total OFV.
Subject rows appear in fit order.
ebes_kappa.csv
Present only when the model uses IOV (block kappa).
ID,OCC,kappa_CL,kappa_V
1,1,0.012,-0.004
1,2,0.020,0.008
OCC is 1-based occasion index per subject.
predictions.csv
ID,TIME,DV,PRED,IPRED,CWRES,IWRES,EBE_OFV,N_OBS[,CENS[,OCC]]
1,0.500000,5.365300,4.078143,5.351798,0.579358,0.237126,-25.745580,11
ID— subject string identifier.- One row per observation (MDV=0). Subject rows are contiguous and in the same observation order as the underlying data.
CENSandOCCcolumns are present only when at least one subject in the population has censoring or occasions, respectively.- NaN values (e.g. CWRES/IWRES for censored observations) are written as empty fields, not as
"NaN".
warnings.txt
Plain text, one warning per line. Same content as fit.json:warnings — duplicated for grep-friendliness.
data.csv
A verbatim copy of the input NONMEM CSV. Only present when the writer was asked to embed data (--include-data on the CLI, or SaveFitOptions::include_data = Some(path) via the Rust API).
Versioning
The format_version string in manifest.json is the source of truth. A loader must:
- Reject any value it does not recognise.
- Tolerate unknown JSON keys within
fit.json(forward-compatible additions within the same version).
When the on-disk format changes in a way that breaks readers, format_version is incremented.