Pure-Rust IO for the CERN ROOT file format — read and
write RNTuple, TTree, the classic histogram family, and graphs in the ROOT
(TFile) container, with no C++/libROOT or Python dependency. Files written
by oxiroot open in official ROOT and uproot, and oxiroot reads files they write.
The name is ROOT + oxide — Rust is oxidized iron.
- 🦀 Pure Rust — the on-disk format reimplemented from the official specs. No libROOT, no Python; builds and runs anywhere Rust does, depending only on a handful of small pure-Rust crates (compression codecs and a hasher).
- 🔄 Two-way interop — every reader and writer is validated against both official ROOT (C++) and uproot, in both directions.
- 🔍 Inspect any class —
RFile::get_valuedecodes any object from itsTStreamerInfointo a dynamicValuetree (rootls / rootprint-style), even classes with no typed model — andoxroot dumpprints it. Undecodable members degrade toUnsupported, never a crash. - 📊 Histograms & profiles —
TH1/TH2/TH3(every precision, uniform or irregular bins),TProfile/TProfile2D/TProfile3D,TEfficiency, N-dimensionalTHnSparse, and polygon-binnedTH2Poly— all read and write. - 🎲 Sampling & smoothing — draw from a histogram's or function's
distribution (
get_random/fill_random, ROOT'sGetRandom/FillRandom) and smooth with ROOT's353QH(smooth), via a small seedable built-inRng(noranddependency). - 📈 Graphs —
TGraph,TGraphErrors,TGraphAsymmErrors, plusTGraph2DandTGraphMultiErrors— read and write, including a graph's display frame (fHistogram) and attached fitted functions (fFunctions, faithfulTF1). - 🧮 Functions — standalone
TF1/TF2/TF3keys backed by a realTFormulaexpression engine (arbitrary formulas, ROOTgaus/expo/polshortcuts), with pure-Rusteval/integral/derivative; any formula is also fittable viaModel::from_formula. - 📐 Statistics (
oxiroot::stat) — a dependency-free,scipy.stats-verified core: special functions (erf,gammainc,betainc,ndtri), theNormal/StudentT/ChiSquared/FisherFdistributions (pdf/cdf/sf/ppf) plus Poisson/Binomial, descriptive stats (describe,skew,kurtosis,sem,iqr,median_abs_deviation,entropy, …),pearsonr/spearmanr, and tests (chisquare,ks_1samp/ks_2samp,ttest_ind,normaltest,mannwhitneyu/wilcoxon) plus a seededbootstrap_ci. Aphysicsmodule adds HEP conventions: significance ↔ p-value ("nσ"), inverse-variance measurement combination, Clopper–Pearson / Garwood / Wilson / Agresti–Coull efficiency intervals, and the Feldman–Cousins unified interval. Alineshapesmodule has the HEP fit shapes — Crystal Ball (and double-sided), Breit–Wigner / relativistic BW, the Voigt profile, Novosibirsk, ARGUS, bifurcated Gaussian, Moyal, and Landau — each also a ready-to-fitModel(Model::crystal_ball,voigtian, …). - 🌳
TTree— read and write scalar, fixed/variable-length array, string,std::vector<T>, and splitstd::vector<MyStruct>branches; read nested structs,std::vector<std::vector<T>>,TClonesArray, split single objects, old unsplit object branches (TBranchObject),std::set/std::mapbranches,TNtuple/TNtupleD, friend trees (AddFriend, read entry-aligned), tree aliases (SetAlias), andTEntryListselections; multi-basket via a bounded-memory streaming writer. - 🧱 RNTuple — read and write ROOT's columnar format (scalars, strings,
vectors, nested vectors and vectors of records, fixed-size
std::array/std::bitset, user classes,std::set/std::map, the nullablestd::optional/std::unique_ptrandstd::atomic), read unsplit streamer fields, compressed, multi-cluster via a streaming writer, several RNTuples per file / inside aTDirectory, and schema late extensions (read and write fields added via the footer's schema-extension record). A bounded prefix read (RNTuple::read_field_prefix(name, n)) decodes only the clusters covering the first n entries, so previewing a huge file (oxroot dump -n 10) never touches the whole field. - 🗜 Compression — decode and encode Zstd / zlib / LZ4 / LZMA — all pure Rust, all read back by ROOT and uproot.
- 🌐 Remote reads — open a file over HTTP(S) (
httpfeature) or CERN's XRootDroot://(xrootdfeature) withRFile::open_urland read only the byte ranges each object touches, never downloading the file whole — the way ROOT and uproot read remote data. Verified against real public data onroot://eospublic.cern.ch. A large local file reads the same lazy way withRFile::open_ranged. - 🧵 Multithreaded fill —
ThreadedHist, the pure-std analog of ROOT'sTThreadedObject<TH1>; optional one-callrayonparallel fill. - ➕
hadd— a pure-Rust file merger: histograms summed,TTree/ RNTuple entries concatenated, other objects copied — verified against ROOT's ownhadd(oxiroot::hadd::merge_files). - 🔎 Command-line inspector —
oxroot ls/show/dump/statlooks into any ROOT file (keys,TTree/RNTuple structure, entries, histogram bins) from the shell — no ROOT, no Python (theoxiroot-clicrate). - 🎨 Plotting (optional) — render
TH1/TH2/TGraph/TProfileto SVG, PNG, and PDF with a matplotlib-like API and an mplhep histogram style — grids, ratio plots, LaTeX ($…$) math labels — all pure Rust, no matplotlib, no system fonts. - 🛡 Robust by construction — readers never panic on malformed input (fuzz-tested); writers cross the 2 GiB mark by switching to ROOT's 64-bit container form (never silently truncating a 32-bit seek pointer).
Not yet on crates.io — depend on it via git. Pull in everything through the facade, or just the part you need: the histogram, tree, and RNTuple crates are independent, so a histogram-only project never compiles the others.
[dependencies]
# Everything — histograms, graphs, TTree, RNTuple, fitting, plotting — through
# the facade. The optional capabilities (fit, plot, rayon, mmap, argmin) are ON
# BY DEFAULT, so nothing extra to enable:
oxiroot = { git = "https://github.com/mathieuouillon/oxiroot" }
# …leaner, just the format core (drops the fitting/plotting/rayon/mmap deps):
# oxiroot = { git = "https://github.com/mathieuouillon/oxiroot", default-features = false }
# …or depend on just one crate from the same repo:
oxiroot-hist = { git = "https://github.com/mathieuouillon/oxiroot" } # histograms + graphs
oxiroot-tree = { git = "https://github.com/mathieuouillon/oxiroot" } # TTree
oxiroot-rntuple = { git = "https://github.com/mathieuouillon/oxiroot" } # RNTupleuse oxiroot::prelude::*;
// Fill and save one histogram — the Hist builder + WriteRoot / ReadRoot traits.
let mut h = Hist::reg(50, 0.0, 100.0).name("pt").title("p_{T}").weight();
h.fill_weight(42.0, 1.5);
h.write_root("hist.root", Compression::Zstd(5))?; // any single writable object
let same = TH1::read_root(&RFile::open("hist.root")?, "pt")?; // any readable object
// Several objects, subdirectories, or appending — the RootFile builder.
let prof = Hist::reg(5, 0.0, 5.0).name("prof").title("<pt> per region").profile();
RootFile::create("out.root")
.add(&h) // any &dyn WriteRoot: hist, profile, graph…
.dir("by_region", |d| d.add(&prof)) // a TDirectory
.write(Compression::Zstd(5))?;
let g = RFile::open("out.root")?;
let p = TProfile::read_root_in(&g, "by_region", "prof")?; // read from a subdirectory
// Write a TTree, then read a branch back.
let branches = vec![
Branch::i32("n", vec![1, 2, 3]),
Branch::f64("pt", vec![10.5, 20.1, 33.7]),
];
Tree::new("Events", branches).write_root("tree.root", Compression::None)?;
let f = RFile::open("tree.root")?;
let t = TTree::open(&f, "Events")?;
let BranchValues::F64(pt) = t.read_branch(&f, "pt")? else { panic!() };
// Write a columnar RNTuple, then read it back.
let fields = vec![Field::f64("mass", vec![91.2, 125.0])];
Ntuple::new("events", fields).write_root("data.root", Compression::None)?;
let n = RNTuple::open(&RFile::open("data.root")?, "events")?.num_entries();The analysis example is an end-to-end
mini analysis — weighted/variable-bin histograms → scale/merge/normalize →
per-region subdirectories → a columnar event dataset → read-back:
cargo run -p oxiroot --example analysis- Read & write
TH1/TH2/TH3in every precision (D/F/I/S/C/L),TProfile/TProfile2D/TProfile3D,TEfficiency, N-dimensionalTHnSparse, and polygon-binnedTH2Poly(arbitrary-shape bins, with a builder API). - One way per operation, not a function per type. Write one object with
h.write_root(path, compression)?and read one withTH1::read_root(&file, name)?(theWriteRoot/ReadRoottraits; alsoh.to_root_bytes()andTH1::read_root_in(&file, dir, name)?for a subdirectory). ATH1/TH2/TH3's on-disk precision is a typedPrecisionchosen by the builder's storage finalizer (.float()writes aTH1F; see below), or changed on a histogram you already built or read with.with_precision(Precision::Float);h.class_name()reconstructs the ROOT class. Profiles carry a typedErrorMode. - No forced names, no global registry. A histogram is just data: construct
it with the
Histbuilder (Hist::reg(nbins, lo, hi).double()) and name it only when you persist it —.name("pt")sets the file key (.title(...)the title). Unlike ROOT there is nogROOT/gDirectory, so any number of same-named histograms coexist in memory; and writing two objects under the same key name in one directory is a loudDuplicateNameerror, never ROOT's silent shadow-on-read. - Then
fill/fill_weightwith ROOT's exactFillsemantics;sumw2()(chains:h.sumw2().fill(x)) enables weighted per-bin errors (bin_error) on a histogram not already built with.weight(). - The one way to build a histogram is the scikit-hep
hist-styleHistbuilder, mapped onto ROOT so the result is an ordinaryTH1/TH2/TH3.Hist::reg(50, 0.0, 100.0).name("pt").label("$p_T$ [GeV]").weight()chains a regular (reg) axis; for irregular bin edges give them explicitly withvar—Hist::var(&[0.0, 1.0, 2.0, 5.0, 10.0, 100.0]).double()(a real ROOT variable-binnedTH1D). Either kind chains a storage finalizer picks the ROOT class —double()→TH1D,float()→TH1F,int64()→TH1L,int32()→TH1I,int16()→TH1S,int8()→TH1C,weight()→TH1D+Sumw2,profile()→TProfile. Chain.reg/.varagain forTH2/TH3(and.profile()on those forTProfile2D/TProfile3D). Axis labels (label()/with_x_label()/x_label()) live in ROOT'sfXaxis.fTitleand round-trip. Thehistaccessor family is there too:values(),variances()(Sumw2),errors()(√variance),counts()(effective entries),density(), andat(x)(content at a coordinate); plus batchfill_many(), UHI-styleintegral_range(a, b)/slice(a, b), and aHist::reg(...).profile()finalizer for aTProfile(hist'sMeanstorage). - Arithmetic with
Sumw2error propagation:scale(alsoh *= c/h * c),add(the bin-by-bin merge used to combine job outputs),multiply,divide,integral. Bins read by cell index (h[bin]) or iterator (for &c in &h); a sharedHistogramtrait (contents/entries/sum) abstracts overTH1/2/3, and every type implementsDisplayfor a one-line summary. - Statistics & shape accessors:
mean/std_dev,maximum/minimum/maximum_bin,find_bin,bin_center/bin_width/bin_low_edge,effective_entries,reset,interpolate,quantiles; derived histogramsrebin/rebin2d/rebin3d,cumulative, projections (TH2→TH1;TH3→TH1/TH2), andprofile_x/profile_y— all carrying the statistical moment sums so the results'mean/std_devstay correct. - Sampling & smoothing:
get_random/fill_randomdraw from a histogram's (or, viafill_random_fn/TF1::get_random, a function's) distribution (inverse-CDF, ROOT'sGetRandom/FillRandom);smoothis ROOT's353QHsmoother. A small seedableRngmeans noranddependency and reproducible draws. - Compatibility tests:
chi2_test/chi2_test_with(Pearson χ², all threeUU/UW/WWweighting schemes) andkolmogorov_test, returning ROOT-matched p-values. Alphanumeric (labelled) axes round-trip throughTAxis::labels(read and write, withset_label). - Fitting (the
fitfeature, on by default) — fit a parametric model to any 1-D data: a histogram, aTGraph, or your own(x, y, σ)points. The standaloneoxiroot::fitcrate provides theModel(built-ingaussian/exponential/polynomial, the HEP peakscrystal_ball/voigtian/…, an arbitrary formula, or a custom closure) and a pure-Rust Minuit2 minimizer; anything implementing theFitDatatrait gets.fit(&model)(Neyman/Pearson χ² or binned Poisson likelihood), returning parameters, parabolic + MINOS errors, covariance, andchi2/ndf. A robustLoss(Huber/SoftL1/Cauchy/Arctan— scipy'sleast_squaresloss) down-weights outliers. - Multithreaded fill —
ThreadedHist, the pure-Rust analog of ROOT'sTThreadedObject<TH1>: share&hist, callhist.fill(x)from any thread — each thread transparently gets its own copy — thenhist.merge()combines them exactly (contents +Sumw2+ every moment sum), identical to a serial fill:Nolet hist = ThreadedHist::new(Hist::reg(100, 0.0, 100.0).double().named("h")); std::thread::scope(|s| for chunk in data.chunks(n) { let hist = &hist; s.spawn(move || for &x in chunk { hist.fill(x); }); }); let merged = hist.merge()?;
Arc, no manual slots;with_local(|h| …)batches fills or reaches any method, and therayonfeature (on by default) adds a one-callfill_par(&template, &data, |h, &x| h.fill(x)). Seeexamples/threaded.rs. - Write one object with
h.write_root(path, compression). For several objects, subdirectories, or appending, use theRootFilebuilder — one entry point for all file composition:Written files embed aRootFile::create("out.root") .add(&h) // any &dyn WriteRoot: hist, profile, graph… .add(&prof) .dir("by_region", |d| d.add(&sig)) // a TDirectory per region .write(Compression::Zstd(5))?; RootFile::open("out.root")?.add(&extra).write(Compression::None)?; // append
TStreamerInfolist, so they are self-describing for any ROOT reader.
Fitting lives in its own crate (oxiroot-fit), so it works on any 1-D
data, not just histograms. A dataset implements the FitData trait (yielding
(x, y, σ) points) and the blanket FitExt gives it .fit(&model). TH1 and
TGraph implement FitData out of the box, and Points (or your own FitData
impl) covers everything else. fit is χ² by default; fit_opts picks the cost
(Neyman or Pearson chi-square, or a binned Poisson likelihood), a fit range, a
robust loss, and opt-in MINOS errors. A Model is a built-in shape
(gaussian, exponential, polynomial), any formula string
(Model::from_formula), or a closure f(x, params), with per-parameter limits,
fixing, and a data-driven seed. The fit returns the
parameters, parabolic errors, optional asymmetric MINOS errors and covariance
matrix, and chi2/ndf (with chi2_per_ndf() and a goodness-of-fit
p_value()). The χ² survival function it shares with the comparison tests lives
in the dependency-free oxiroot-stat crate.
Two minimizer backends are selectable via FitOptions::minimizer(...): the
default Minimizer::Minuit2 (the pure-Rust Minuit2 MIGRAD — ROOT's algorithm,
with parabolic + MINOS errors and the covariance), and, behind the argmin
feature, Minimizer::NelderMead — a gradient-free simplex from the
argmin crate (parameter errors from a
numerical Hessian; no MINOS), a handy independent cross-check.
let fit = data.fit_opts(&model, &FitOptions::new().minimizer(Minimizer::NelderMead));use oxiroot::prelude::*; // the `fit` feature is on by default
let mut h = Hist::reg(60, 80.0, 100.0).double().named("mass").titled("di-muon mass");
h.sumw2();
// … fill h with events …
// Gaussian peak fit (chi-square). `estimate_from` seeds (constant, mean, sigma)
// from the bins — no manual moment loop, and it works for set_bin_content too.
let model = Model::gaussian("z").estimate_from(&h);
let fit = h.fit(&model);
println!("mean = {:.3} ± {:.3}", fit.params[1], fit.errors[1]);
println!("chi2/ndf = {:.2}, p = {:.3}", fit.chi2_per_ndf(), fit.p_value());
// binned Poisson likelihood, via FitOptions:
let ml = h.fit_opts(&model, &FitOptions::new().method(FitMethod::Likelihood));
// Full control: fit the core ±window, keep sigma positive, and ask for MINOS.
let opts = FitOptions::new().range(85.0, 97.0).with_minos(true);
let mut peak = Model::gaussian("z").estimate_from(&h).lower_limit("sigma", 0.0);
let r = h.fit_into(&mut peak, &opts); // writes the fit back into `peak`
if let Some(minos) = &r.minos {
println!("mean +{:.3} {:.3}", minos[1].1, minos[1].0); // asymmetric errors
}
// A custom model — Gaussian signal on a flat background — as a closure.
let sig_bkg = Model::new(
"s+b", &["norm", "mean", "sigma", "bkg"], vec![h.maximum(), 91.0, 2.0, 0.0],
|x, q| q[0] * (-0.5 * ((x - q[1]) / q[2]).powi(2)).exp() + q[3],
);
let r = h.fit(&sig_bkg);
// The SAME `Model` + `.fit()` works on a TGraph …
let graph = TGraph::with_errors(x.clone(), y.clone(), ex, ey).named("g");
let line = graph.fit(&Model::polynomial("line", 1).with_params(vec![0.0, 0.0]));
// … and on raw points (a `Vec`/slice of `Point`, or your own `FitData` impl).
let data = Points::new(&x, &y, &sigma);
let peak = data.fit(&Model::gaussian("g").estimate_from(&data));A runnable worked example (fits a Z → μμ peak, then the same models to a
TGraph and to raw points):
cargo run -p oxiroot --example fitA single TGraph type covers all three ROOT classes, selected by its errors
field: plain (TGraph), symmetric (TGraphErrors), or asymmetric
(TGraphAsymmErrors); the class is detected on read by TGraph::read_root.
TGraph2D (3-D scatter) and TGraphMultiErrors (several y-error layers) are
separate types with the same read/write traits.
use oxiroot::prelude::*;
let g = TGraph::with_errors(
vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0], // x, y
vec![0.1, 0.1, 0.1], vec![1.0, 2.0, 1.5], // ex, ey
).named("res").titled("resolution");
g.write_root("graph.root", Compression::None)?; // WriteRoot, like any object
let same = TGraph::read_root(&RFile::open("graph.root")?, "res")?;A graph also round-trips ROOT's display frame (fHistogram) and the fitted
functions ROOT stores in fFunctions — read back as GraphFunctions (faithful
TF1/TFormula) and re-evaluable in ROOT:
use oxiroot::prelude::*;
let g = TGraph::new(vec![0.0, 1.0, 2.0], vec![1.0, 3.0, 5.0])
.named("gfit")
.with_function(GraphFunction::new("line", "[0]+[1]*x", vec![1.0, 2.0], 0.0, 2.0));
g.write_root("gfit.root", Compression::None)?;- Standalone
TF1/TF2/TF3keys — real ROOT function objects (each embedding aTFormula), built from a formula and a range and read/written like any other object.TF1::new("f", "[0]*sin([1]*x)", 0.0, 6.3)?.with_params(...). - A real
TFormulaexpression engine (oxiroot-formula, a dependency-free crate): a tokenizer + Pratt parser + AST evaluator for arbitrary formulas — operators+ - * / ^, the usual functions (sin/exp/log/sqrt/pow/…, with or without aTMath::prefix), comparisons/?:, and ROOT'sgaus/expo/pol0..Nshortcuts. eval/integral/derivativein pure Rust — adaptive Gauss–Kronrod quadrature and a Richardson central derivative, matchingTF1::Integral/TF1::Derivative.- The same engine powers
Model::from_formula, so any formula is fittable, andTF1::to_model()bridges a function to a fit. oxiroot embeds theTF1/TF2/TF3/TFormulaTStreamerInfo, so ROOT C++ and uproot both read and re-evaluate all three. - See the
functionsexample.
The two small classes ROOT constantly stashes alongside histograms — a labelled
string (TObjString) and a named scalar (TParameter<T>, for a luminosity, an
event count, a cut threshold) — read and write byte-for-byte as ROOT serializes
them, through the same WriteRoot/ReadRoot traits as everything else.
use oxiroot::prelude::*;
RootFile::create("meta.root")
.add(&TObjString::new("v2.1").named("version"))
.add(&TParameter::f64("lumi", 137.5)) // TParameter<double>
.add(&TParameter::i64("nevents", 9_000_000_000)) // <Long64_t>
.write(Compression::None)?;
let f = RFile::open("meta.root")?;
assert_eq!(TObjString::read_root(&f, "version")?.value(), "v2.1");
assert_eq!(TParameter::read_root(&f, "lumi")?.value().as_f64(), 137.5);TParameter comes in f64/f32/i32/i64 flavours (the double/float/
int/long long instantiations). Both ROOT C++ and uproot read them from
oxiroot's files: the writer embeds the TStreamerInfo for whichever
TParameter<…>/TObjString classes a file contains (merged into the histogram
streamers), so uproot can model the templated TParameter class.
THStack (a stack of histograms) and TMultiGraph (several graphs in one frame)
hold their members in a TList. Build them from existing TH1/TGraphs; on
read they hand the members back. The members are serialized through ROOT's
object protocol, so ROOT C++ and uproot read what oxiroot writes, and oxiroot
reads ROOT's files (including the class back-references ROOT emits for repeated
member types).
use oxiroot::prelude::*;
let stack = THStack::new().named("hs").titled("backgrounds")
.add(Hist::reg(50, 0.0, 100.0).double().named("zjets"))
.add(Hist::reg(50, 0.0, 100.0).double().named("ttbar"));
let graphs = TMultiGraph::new().named("mg")
.add(TGraph::new(vec![0.0, 1.0], vec![1.0, 2.0]).named("obs"))
.add(TGraph::new(vec![0.0, 1.0], vec![2.0, 1.0]).named("exp"));
RootFile::create("plots.root").add(&stack).add(&graphs).write(Compression::None)?;
let f = RFile::open("plots.root")?;
assert_eq!(THStack::read_root(&f, "hs")?.hists().len(), 2);
assert_eq!(TMultiGraph::read_root(&f, "mg")?.graphs()[0].name, "obs");TVectorD (a vector), TMatrixD (a dense matrix), and TMatrixDSym (a symmetric
matrix — the shape a fit's covariance takes) read and write byte-for-byte as
ROOT's TVectorT<double> / TMatrixT<double> / TMatrixTSym<double>. The
symmetric matrix is stored as the full matrix in memory but, like ROOT, written
as just its upper triangle.
use oxiroot::prelude::*;
RootFile::create("fit.root")
.add(&TVectorD::new(vec![91.2, 2.1]).named("pars"))
.add(&TMatrixDSym::new(2, vec![0.04, 0.01, 0.01, 0.09]).named("cov")) // covariance
.write(Compression::None)?;
let f = RFile::open("fit.root")?;
assert_eq!(TVectorD::read_root(&f, "pars")?.elements(), &[91.2, 2.1]);
let cov = TMatrixDSym::read_root(&f, "cov")?;
assert_eq!(cov.get(0, 1), cov.get(1, 0)); // symmetricA Rust take on scikit-hep particle:
decode a PDG Monte Carlo ID straight from its digits (PdgId), and look particles
up in a bundled ~600-particle PDG table (Particle) for their mass, width,
lifetime, and quantum numbers. Dependency-free and verified against the
particle package the way oxiroot::stat is verified against scipy.stats.
use oxiroot::particle::{Particle, PdgId};
// No table needed — the ID's digits say what it is.
assert!(PdgId::new(2212).is_baryon()); // proton
assert_eq!(PdgId::new(-11).charge(), Some(1.0)); // positron
let u235 = PdgId::new(1000922350);
assert_eq!((u235.a(), u235.z()), (Some(235), Some(92))); // uranium-235
// Physical properties from the bundled table (MeV, ns, mm), by name, id, or a
// `literals` named constant.
let muon = oxiroot::particle::literals::muon();
assert!((muon.mass().unwrap() - 105.6583755).abs() < 1e-4);
assert!((muon.lifetime().unwrap() - 2196.98).abs() < 0.1); // τ = ħ/Γ
assert_eq!(Particle::from_name("pi+").unwrap().invert().unwrap().name(), "pi-");particle::literals mirrors scikit-hep's particle.literals — friendly named
accessors like literals::proton(), literals::electron(), literals::jpsi().
ObjList stores a bare TList or TObjArray of mixed objects under one key —
the way ROOT groups, say, a set of systematic-variation histograms. Add any
writable objects; on read, pull the members back out by type with
items::<T>(). The members' streamer info is embedded automatically, so a
TParameter or matrix inside the list is readable by uproot too.
use oxiroot::prelude::*;
let list = ObjList::list().named("systematics")
.add(&Hist::reg(50, 0.0, 100.0).double().named("nominal"))
.add(&TObjString::new("2024-data").named("tag"))
.add(&TParameter::f64("lumi", 137.5));
RootFile::create("syst.root").add(&list).write(Compression::None)?;
let f = RFile::open("syst.root")?;
let list = ObjList::read_root(&f, "systematics")?;
assert_eq!(list.len(), 3);
assert_eq!(list.items::<TH1>()?.len(), 1);
assert_eq!(list.items::<TParameter>()?[0].value().as_f64(), 137.5);ObjList::array() writes a TObjArray instead. items::<T>() covers the
histogram family, TGraph, TObjString, TParameter, and the linear-algebra
types; class_names() lists every member.
TMap is the keyed variant — ROOT's object → object map, the way ROOT stores
string-keyed metadata. insert(key, value) takes a string key; look values up by
key with get::<T>(key):
use oxiroot::prelude::*;
let meta = TMap::new().named("meta")
.insert("version", &TObjString::new("2.1"))
.insert("lumi", &TParameter::f64("lumi", 137.5));
RootFile::create("meta.root").add(&meta).write(Compression::None)?;
let meta = TMap::read_root(&RFile::open("meta.root")?, "meta")?;
assert_eq!(meta.get::<TParameter>("lumi").unwrap()?.value().as_f64(), 137.5);uproot caveat: uproot has no
TMapmodel, so aTMapis not readable there — a limitation that ROOT's ownTMaps share. ROOT C++ reads what oxiroot writes, and oxiroot reads ROOT'sTMaps.
Render histograms and graphs to SVG, PNG, and PDF with a matplotlib-like API and an
mplhep histogram style — pure Rust, no matplotlib, no system fonts. One
backend-independent draw IR fans out to a tiny-skia
raster (PNG) and a hand-written SVG, so the two outputs share identical geometry.
The default font is the bundled STIX Two (a LaTeX-like serif), and $…$
labels are typeset as real LaTeX math by the pure-Rust
ReX TeX engine into the same IR.
All figures are produced by cargo run -p oxiroot --example plot (PNG, SVG and PDF). Left: the plain matplotlib look (fancybox legend, STIX serif, LaTeX labels). Right: the HEP-publication look (Style::mplhep() + ax.hep_label("CMS", "Preliminary")).
use oxiroot::plot::{Axes, HistType, HistOpts, ErrorbarOpts, Hist2dOpts, Color};
use oxiroot::prelude::*; // the `plot` feature is on by default
// A filled MC histogram with "data" points overlaid + a legend.
let mut ax = Axes::new();
ax.hist_with(&mc, HistOpts::new().histtype(HistType::Fill).label("MC")); // mplhep staircase
ax.errorbar_with(&data, ErrorbarOpts::new().color(Color::BLACK).label("data")); // a TGraph
ax.xlabel("$m_{\\mu\\mu}$ [GeV]"); // LaTeX math axis label
ax.ylabel("Events / 2 GeV");
ax.legend();
ax.save("mass.png")?; // format chosen by extension
ax.save("mass.svg")?;
// A TH2 as a viridis heatmap with a colorbar.
let mut ax2 = Axes::new();
ax2.hist2d_with(&th2, Hist2dOpts::new().label("entries"));
ax2.save("heatmap.svg")?;
# Ok::<(), oxiroot::plot::Error>(())Axesmirrors matplotlib (with Rust-idiomatic names):hist/hist_with(TH1, mplhep step/fill/band/errorbar staircase with√N/Sumw2 error bars),errorbar/errorbar_with(TGraph, all three error variants),profile(TProfile),hist2d/hist2d_with(TH2color mesh with a colorbar and the real matplotlibviridis/plasmacolormaps;Hist2dOpts::log()/.norm(Norm::…)switches to a log / symlog color scale with a decade colorbar, like matplotlib'sLogNorm/SymLogNorm),plot,function(overlay any analytic or fitted curve),grid,xlabel/ylabel/title,xlim/ylim(taking aRange, e.g.ax.xlim(0.0..100.0)), andlegend. The*_withmethods take an options builder; the bare ones use defaults.- Publication-ready out of the box — a matplotlib
fancybox(rounded) legend, the STIX serif used by LaTeX, andStyle::mplhep()for the HEP look (in-pointing minor ticks on all four sides, a heavier frame).ax.hep_label("CMS", "Preliminary")adds the bold experiment label, andax.hep_rhs("138 fb$^{-1}$ (13 TeV)")the luminosity/energy above the frame. - Overlay a fit —
ax.function(|x| …, x0..x1)draws any closure as a smooth curve; with thefitfeature,ax.model(&model, x0..x1)overlays a fittedoxiroot::fitModeldirectly on a histogram, andax.fit_stats(&model, &result)adds a ROOT-style stat box (TPaveStats) with the function name, χ²/ndf, and each fitted parameter ± its error. - Multi-panel layouts —
subplots_grid(rows, cols)and a customGridSpec(height/width ratios, spacing) withfig.sharex()/sharey()and asuptitle, plus a one-callratio_subplots()for the HEP main-over-ratio plot (shared x-axis, the upper panel's x labels hidden). - Output —
save/save_withpick the format from the extension:.png,.svg, and.pdf(a hand-written vector PDF).SaveOptssets the DPI for a sharper PNG (save_with(path, SaveOpts::new().dpi(300.0))) or a transparent background;to_png_bytes/to_svg_stringrender in memory.PdfPagescollects several figures into one multi-page vector PDF (matplotlib'sPdfPages). - Fonts — the default is STIX Two (a LaTeX-like serif: STIX Two Text +
STIX Two Math) for a publication look with text and math in one typeface.
ax.fonts(FontSet::dejavu())switches to the matplotlib sans-serif look, andFontSet::from_path("MyFont.otf")/from_paths(text, math)plug in any TrueType/OpenType font (the math font needs aMATHtable). - The default look otherwise reproduces a plain matplotlib figure (640×480, the
tab10color cycle, out-pointing ticks, 5 % margins, light grey grid);Style::mplhep()switches to in-pointing ticks on all four sides with minor ticks. - A worked example renders a Z → μμ overlay and a 2-D heatmap to PNG and SVG:
cargo run -p oxiroot --example plot
- Read & write scalar, fixed-size array (
x[N], incl. multidimensionalx[N][M]), variable-length / jagged (x[n]), string, multi-leaf (leaflista/F:b/I), andstd::vector<T>branches; readstd::vector<std::string>,std::set<T>(an unsplit object-wise collection) andstd::map<K,V>(split into parallel.first/.secondcollections), andTNtuple/TNtupleD(the all-float/doubleTTreesubclasses) as ordinary trees. - Split
std::vector<MyStruct>branches written as per-member sub-branches (TBranchElement), with a generatedTStreamerInfofor the element class — ROOT-C++- and uproot-verified, both directions. Branch::{i32, f64, bools, strings, …}for scalars,Branch::vec_*for fixed arrays,Branch::jagged_*for variable arrays,Branch::vector_*forstd::vector<T>, andBranch::split_vectorfor split structs.Tree::new(name, branches).write_root(path, compression)is the method form (mirroringhist.write_root);.write_root_baskets(…, entries_per_basket)writes several baskets per branch, and.to_root_bytes(…)returns the file bytes. The freewrite_tree_file/write_tree_file_basketsfunctions remain.TTreeWriterstreams a tree in batches (write_batchemits one basket per branch straight to disk, thenfinish), so only the current batch is held in memory — the way ROOT'sTTree::Fillflushes baskets. ROOT-C++- and uproot-verified across many baskets, compressed and not. UseTTreeWriter::create_largefor a tree that will exceed 2 GiB — it writes the 64-bit container form (again ROOT-C++/uproot-verified).read_branchreads a whole branch,read_branch_range(start, stop)only the baskets covering a window, andread_branch_flatan offsets+flat (noVec<Vec>) view;TChainspans many files (optionalrayondecodes baskets in parallel). Introspect withbranch_type/branch_shape/branch_title, and see what was skipped viaunsupported_branches(). Worked example:cargo run -p oxiroot --example tree.friends()returns the friend trees attached withTTree::AddFriend(read from the persistedfFriendslist). A friend is read positionally — entry i of the main tree pairs with entry i of the friend — so opening the friend tree and reading its branches yields columns that line up by entry.aliases()/alias(name)return the(name, expression)shorthands defined withTTree::SetAlias(read fromfAliases); oxiroot reads the expression strings but does not evaluate them.TEntryList::open(file, name)reads a saved entry selection (a standalone key) into the ascending list of selected entry numbers, withentries()/contains(entry).- Old unsplit object branches (
TBranchObject, the pre-TBranchElementway of storing a whole object per entry) are read by synthesizing onebranch.membercolumn per basic/string member of the object class — e.g. a branch ofTNamedreads asbranch.fName/branch.fTitle. - The reader is streamer-info-driven: it parses
TTree/TBranch/TBranchElementby walking the member list in the file's ownTStreamerInfo(TTree::streamer_classesexposes it), so a schema change is absorbed instead of misread; an unknown member type is reported, never parsed at a guessed offset. - The writer generates that
TStreamerInfofrom a declarative class table (the wholeTTree/TBranch/TLeaf*/TBranchElementhierarchy, with ROOT's own versions and checksums) rather than shipping baked blobs — every written file is self-describing and reads back in ROOT, uproot, and this crate.
- Read the binary spec v1.0.0.0: anchor → envelopes → schema → clusters → pages.
Every physical column encoding decodes — split/zigzag/delta, all integer widths
(8/16/32/64-bit, signed & unsigned), reduced-precision reals (half, truncated,
quantized), and the
Switchcolumn — on Zstd-compressed pages. - Typed field API (
read_field) for scalars,std::string,std::vector<T>, nested collections —std::vector<std::string>,std::vector<std::vector<T>>, and vectors of records (std::vector<MyStruct>/std::pair) —std::variant, the nullable / "late" fieldsstd::optional<T>/std::unique_ptr<T>(read as a [FieldValues::Opt] you can zip withopt_f32()etc.),std::atomic<T>, fixed-sizestd::array/std::bitset, and user-defined classes (split into a record of their members), across multiple clusters. Schema-extended RNTuples (fields added late via the footer's schema-extension record) are read too — the late fields merge into the schema and their deferred columns back-fill the entries written before the field existed. - Write the same surface it reads:
bool, every integer width (8/16/32/64-bit, signed & unsigned),f32/f64, reduced-precision reals (Field::half/truncated/quantized),std::string,std::vector<T>, the nested collections (Field::vec_str/vec_vec_*,Column::Record/Nested),std::variant(Field::variant), and the nullable / atomic fields (Field::optional_*/unique_ptr_*from aVec<Option<T>>,Field::atomic_*) — optionally Zstd-compressed. Ntuple::write_root_extendedwrites a schema late extension: this RNTuple's fields go in the header, and each late(first_entry, field)is added through the footer's schema-extension record as a deferred column (its data covers entriesfirst_entry..N; earlier ones default). ROOT reads the result as if the field had been added mid-writing with a model updater.Ntuple::new(name, fields).write_root(path, compression)is the method form (mirroringhist.write_root), with.to_root_bytes(…)for the file bytes; the freewrite_rntuple_fileremains.NtupleFilewrites several RNTuples per file and RNTuples inside aTDirectory—NtupleFile::new().add(events).add(runs).dir("cal", |d| d.add(pedestals)).write_root(…). Read a nested one withRNTuple::open_in(file, "cal", "pedestals"). ROOT and uproot navigate the result natively.RNTupleWriterstreams one cluster perwrite_batch, so a large dataset is never fully held in memory.
- A pure-Rust
hadd—merge_files("all.root", &["run1.root", "run2.root"], Compression::Zstd(5))?combines several ROOT files the way ROOT's most-used command-line tool does:TH1/TH2/TH3/TProfilesummed bin-by-bin (the exactaddreduction — contents,Sumw2, entries, moments), andTTree/ RNTuple entries concatenated. Other supported objects (graphs, 2D/3D profiles, efficiencies, functions, strings, matrices, …) are copied from the first file; unknown classes are skipped and listed in the report, never silently dropped. - Each concatenated branch keeps its original kind (scalar,
x[N], jaggedx[n],std::vector<T>, string). The standaloneoxiroot_tree::concat_trees,oxiroot_rntuple::concat_ntuples, andoxiroot_hist::merge_histogram_filesdo the per-format work and can be called directly. Merger::new().inputs(paths).compression(c).merge("all.root")?is the composable builder;merge_filesreturns aMergeReport(what was summed / copied / skipped, and the total entries) with a one-lineDisplay.- One call writes one file and does not yet mix histograms with a
TTree/RNTuple in a single output (each owns auxiliary basket/page keys): a fileset is an all-histogram set, a singleTTree, or a single RNTuple — anything else is refused with a message naming the keys. Verified against ROOT 6.40'shadd(identical histogram sums, entry-for-entry tree concat) and read back by uproot and ROOT C++. See themergeexample.
- Look into a ROOT file from the shell, no ROOT or Python:
cargo install --path crates/oxiroot-clibuilds theoxrootbinary. Objects are addressedfile.root:name(uproot-style), with a/-path for nestedTDirectorys (file.root:cal/run2/Events). oxroot ls [-l] [-r]— list objects (name, class, title;-ladds cycle andTTree/RNTuple entry count;-rrecurses into everyTDirectory, by fulldir/sub/namepath).oxroot show file.root:Events— aTTree's branches with their types (double,double[3],double[],char*; unreadable branches flagged), or an RNTuple's fields with C++ type names.oxroot dump file.root:obj [-n N] [-b a,b]— the first NTTree/RNTuple entries as a column table, aTH1's bins + stats, aTGraph's points, or a scalar value.oxroot stat file.root— size, ROOT version, compression, key count, and the embedded streamer classes.--json(global) emits machine-readable JSON for any command — dump rows are typed (numbers, booleans, arrays) — via a dependency-free writer.- See the command-line inspector guide.
- Read: Zstd, zlib, LZ4, and LZMA (XZ) decode — every codec ROOT writes
except the legacy
CS. Uncompressed objects pass through directly. - Write: Zstd, zlib, LZ4, and LZMA via
Compression::{Zstd, Zlib, Lz4, Lzma}(level), orCompression::None. Files written with any of them match ROOT's own block framing and read back in ROOT and uproot.
All four codecs are pure Rust (ruzstd, miniz_oxide, lz4_flex,
lzma-rust2), so the no-libROOT promise holds. LZ4 blocks carry ROOT's XXH64
integrity check, verified on read.
- Parsers are hardened against malformed input: every read path is bounds- and
overflow-checked, capacity reservations are bounded by the remaining buffer,
and histogram array lengths are validated against the axis geometry — so a
crafted or truncated file yields an
Err, never a panic. Byte-flip and truncation fuzz tests cover the container, RNTuple,TTree, and every histogram/graph reader. - 64-bit (
> 2 GiB) files are supported on read and write. The one-shotTFileobject writers (RootFile) and the RNTuple writer auto-switch to ROOT's big (64-bit) container form once a file would cross 2 GiB; the streamingTTreeWriter::create_large/RNTupleWriter::create_largeopt into it up front (the plaincreatestays 32-bit and errors past 2 GiB rather than truncating its seek pointers). Appending (RootFile::open(...).add(...).write()) also crosses into the 64-bit form: the existing bytes stay put — preserving subdirectories and any RNTuple at their original offsets — while the header and root directory record are widened in place (every oxiroot/ROOT file reserves the 64-bit directory width up front, as ROOT does). All big-format writes are verified against ROOT C++ and uproot. Erroris#[non_exhaustive]and preserves the underlyingio::ErrorKind.
Every example under crates/oxiroot/examples/ is a
self-contained, runnable file. Run any of them with cargo run -p oxiroot --example <name>; the default build already has the fit and plot features
on, so nothing extra is needed.
| Example | What it shows |
|---|---|
| Start here | |
analysis |
End-to-end mini analysis: weighted/variable-bin histograms → scale/merge/normalize → subdirectories → a columnar dataset → read-back |
inspect |
Introspect any ROOT file you did not write — a programmatic oxroot ls |
Statistics — oxiroot::stat |
|
stat_intro |
Descriptive stats + the Normal/StudentT/ChiSquared/FisherF distributions and confidence intervals |
stat_tests |
Hypothesis tests (t-test, Mann–Whitney, KS), correlation (Pearson/Spearman), goodness-of-fit, normality |
stat_physics |
A HEP counting experiment: discovery significance, Feldman–Cousins, Garwood/Clopper–Pearson intervals, combining measurements |
lineshapes |
HEP peak shapes (Crystal Ball, Voigt, Breit–Wigner, Novosibirsk, ARGUS…) + a Crystal Ball fit |
particles |
PDG particle data — decode IDs (PdgId) and look up masses/widths/lifetimes (Particle) |
Fitting — oxiroot::fit |
|
fit |
Fit a Gaussian peak (χ² and likelihood), a peak-on-background, a TGraph, and raw points — one API |
robust_fit |
Outlier-resistant losses (SoftL1/Huber/Cauchy) versus ordinary least squares |
| Histograms & profiles | |
profile |
TProfile / TProfile2D — the mean of y in slices of x |
efficiency |
TEfficiency turn-on curve with a Clopper–Pearson interval |
sparse |
THnSparse — a memory-efficient N-dimensional histogram |
threaded |
Multithreaded fill with ThreadedHist (ROOT's TThreadedObject analog) |
| Graphs & functions | |
graphs |
TGraph / TGraphErrors / TGraphAsymmErrors / TGraph2D / TMultiGraph |
functions |
TF1/TF2/TF3 from formulas: eval / integral / derivative + round-trip |
| Objects & linear algebra | |
objects |
Store run provenance (TObjString / TParameter / TList) next to your data |
linalg |
TVectorD / TMatrixD / TMatrixDSym round-trip (a fit-covariance shape) |
| I/O & formats | |
tree |
TTree write + read, introspection, entry ranges, streaming TTreeWriter |
rntuple |
A basic flat RNTuple write/read, plus several RNTuples in one file |
rntuple_nested |
Nested RNTuple fields: vector-of-string, vector-of-vector, vector-of-record |
compression |
The None / Zstd / Zlib / Lz4 size trade-off — every codec lossless |
merge |
A pure-Rust hadd: sum histograms, concatenate trees / RNTuples |
plot |
Render histograms and graphs to SVG / PNG / PDF (matplotlib + mplhep look; needs plot) |
| Crate | Purpose |
|---|---|
oxiroot |
Facade: prelude + re-exports of everything below |
oxiroot-io-core |
TFile container, buffer primitives, streamer + object-reference engine, the WriteRoot/ReadRoot object framework, Error |
oxiroot-compress |
ROOT 9-byte block framing + Zstd/zlib/LZ4/LZMA codecs |
oxiroot-rntuple |
RNTuple reader/writer (spec v1.0.0.0) |
oxiroot-hist |
Histograms, profiles, TEfficiency/THnSparse/TH2Poly, and the TGraph family |
oxiroot-linalg |
ROOT linear-algebra objects — TVectorD/TMatrixD/TMatrixDSym |
oxiroot-tree |
Classic TTree read/write |
oxiroot-fit |
Minuit2 curve fitting for any 1-D data (FitData/Model); fit feature |
oxiroot-stat |
Dependency-free statistics — special functions, distributions, descriptive stats, correlation & tests (verified vs scipy.stats) |
oxiroot-particle |
PDG particle data — the numbering-scheme decoder + a bundled particle table (verified vs scikit-hep particle) |
oxiroot-plot |
Matplotlib-style SVG/PNG plotting for histograms and graphs; plot feature |
oxiroot-cli |
oxroot: a command-line inspector (ls/show/dump/stat) |
Dependencies are pure Rust: ruzstd (Zstd),
miniz_oxide (zlib),
lz4_flex (LZ4),
lzma-rust2 (LZMA/XZ), and
xxhash-rust (RNTuple XXH3 + LZ4 XXH64).
| Feature | Default | Effect |
|---|---|---|
mmap |
✅ | Memory-mapped read path (RFile::open_mmap) for large files; adds memmap2. |
rayon |
✅ | Data-parallel histogram fill (hist::fill_par) and TTree basket decode; adds rayon. |
fit |
✅ | Curve fitting (oxiroot::fit, TH1::fit) via the pure-Rust Minuit2 port; adds minuit2. |
argmin |
✅ | Adds the gradient-free Nelder–Mead minimizer backend (Minimizer::NelderMead); implies fit, adds argmin. |
plot |
✅ | Plotting (oxiroot::plot): SVG/PNG/PDF rendering of TH1/TH2/TGraph/TProfile; adds tiny-skia, ab_glyph, and the ReX TeX engine. |
http |
— | Remote reads over HTTP(S) byte-range requests (RFile::open_url); adds the pure-Rust ureq (rustls) client. Off by default so the standard build needs no TLS/networking stack. |
xrootd |
— | Remote reads over the XRootD root:// protocol (RFile::open_url), with unix auth for public data (e.g. root://eospublic.cern.ch). Pure std::net — adds no dependencies. |
All are on by default — the facade is batteries-included. For a lean,
pure-Rust format core with a minimal dependency set, opt out with
default-features = false and re-enable what you need.
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --checkThe committed tests are pure Rust (no ROOT or Python needed): they check self-round-trips, byte-level agreement against committed reference files, and malformed-input hardening. CI additionally round-trips every type both ways against official ROOT (C++) and uproot — Rust writes files they read, and reads files they write.
For a comprehensive cross-language check on your own machine (not CI), run:
bash scripts/interop_local.sh # full run; prints a PASS/FAIL matrix
bash scripts/interop_local.sh --no-fixtures # skip the fixture-regen step (fast)
bash scripts/interop_local.sh --big # also exercise the >2 GiB (64-bit) read pathIt exercises oxiroot's full read+write surface against both ROOT C++ and
uproot, in both directions: the lean canonical round-trip, a manifest-driven
matrix (every histogram precision×dimension, TProfile, Sumw2, variable
bins, multi-object/subdirs/append, every RNTuple scalar+vector type +
multi-cluster, every TTree branch kind + scalar width + split
std::vector<Struct>), cargo test --workspace, and a drift check that
regenerates the committed fixtures from your local ROOT/uproot and re-tests.
Missing tools (no ROOT, or no uproot venv) degrade to SKIP, never FAIL, so a
machine with neither still gets a meaningful green from cargo test alone.
Needs a Python venv at .venv with uproot numpy awkward, and root-config
(+rootcling) on PATH for the ROOT-C++ side.
Experimental (0.0.x). On the list — each item targets the same bar as what
already ships: byte-level round-trips verified against both ROOT and uproot.
Grouped by the ROOT feature each fills.
TTree- Index-based friend join (
TTree::BuildIndex) — read the persistedfTreeIndex(TTreeIndex) so friends can be joined on a(major, minor)key instead of by entry. (Positional friends —AddFriend, read entry-aligned — already work.)
- Index-based friend join (
- RNTuple
- A ROOT-C++-confirmed
std::mapwrite — oxiroot already writes a spec-compliantstd::mapRNTuple that uproot reads and oxiroot round-trips (Field::map; see theassoctests). Confirming a ROOT-C++ round-trip is blocked because ROOT 6.40'sstd::mapcollection proxy is non-functional in the test build — it can neither create nor read astd::mapRNTuple field — so this needs a ROOT install with thestd::mapdictionary loaded to verify.
- A ROOT-C++-confirmed
- Generic object reader — read any class in a file driven by its
TStreamerInfointo a dynamic value tree (the streamer-info member walker already powers theTTreereader), so oxiroot can inspect arbitrary ROOT files (rootls/rootprint-style), not only the typed hist/graph/tree/RNTuple models. - Remote reads — richer XRootD auth (GSI / X.509 and token / ZTN) for
access-controlled data. The
root://transport ships today (RFile::open_url, thexrootdfeature) withunixauth for public data, alongside HTTP(S) range reads (thehttpfeature). TFilecontainer- Arbitrary-depth
TDirectorywrite — the builder nests one level today. - Delete / compact in update mode — append mode ships; rewriting a key at
a new cycle and purging old cycles (
TFile::Purge) does not. - Read an object at an explicit cycle (
name;N).
- Arbitrary-depth
- Axes — time axes (
TAxisfTimeDisplay/fTimeFormat) on histograms and graphs, for monitoring-style time series. RDataFrame-style analysis (far future) — a lazy, columnar analysis front-end (Define/Filter/Histo1D/Sum, executed in one pass over aTTreeor RNTuple, parallelised across clusters) built on the existing readers. This is a compute engine, not IO — a large, separate undertaking — so it sits well beyond the current milestones, but it is the natural cap on the stack once the IO layer is complete. (RNTupleProcessor, ROOT's RNTuple-native iteration, is the same shape and would share the machinery.)
Out of scope: ROOT 7 RHist (no persistable on-disk format — its Streamer
throws); and reading/writing ROOT's own graphics objects (TCanvas, TPad, …) —
the plot feature renders the data, it does not (de)serialize ROOT graphics.
Licensed under the MIT License.



