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 —
FileReader::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 —
Hist1D/Hist2D/Hist3D(every precision, uniform or irregular bins),Profile1D/Profile2D/Profile3D,Efficiency, N-dimensionalSparseHist, and polygon-binnedPolyHist— 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-inRandom(noranddependency). - 📈 Graphs —
Graph,TGraphErrors,TGraphAsymmErrors, plusGraph2DandMultiErrorGraph— read and write, including a graph's display frame (fHistogram) and attached fitted functions (fFunctions, faithfulFunc1D). - 🧮 Functions — standalone
Func1D/Func2D/Func3Dkeys 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, entry-aligned or joined on aBuildIndexkey), tree aliases (SetAlias), andEntryListselections; 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 (NtupleReader::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) withFileReader::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 withFileReader::open_ranged. - 🧵 Multithreaded fill —
ThreadedHist, the pure-std analog of ROOT'sTThreadedObject<Hist1D>; optional one-callrayonparallel fill. - ➕
hadd— a pure-Rust file merger: everything ROOT'shaddmerges merged the same way (histograms, profiles, efficiencies, poly and sparse histograms, stacks, parameters, and graphs by appending points),TTree/ RNTuple entries concatenated, the rest copied — checked 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
Hist1D/Hist2D/Graph/Profile1Dto 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).
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. Fitting, plotting, mmap and argmin are ON BY DEFAULT, so nothing
# extra to enable (add `features = ["rayon"]` for the parallel helpers):
oxiroot = "0.3"
# …leaner, just the format core (drops the fitting/plotting/mmap deps):
# oxiroot = { version = "0.3", default-features = false }
# …or depend on just one crate:
oxiroot-hist = "0.3" # histograms + graphs
oxiroot-hist-func = "0.3" # Func1D/Func2D/Func3D
oxiroot-tree = "0.3" # TTree
oxiroot-rntuple = "0.3" # 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 = Hist1D::read_root(&FileReader::open("hist.root")?, "pt")?; // any readable object
// Several objects, subdirectories, or appending — FileWriter.
let prof = Hist::reg(5, 0.0, 5.0).name("prof").title("<pt> per region").profile();
FileWriter::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 = FileReader::open("out.root")?;
let p = Profile1D::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 = FileReader::open("tree.root")?;
let t = TreeReader::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 = NtupleReader::open(&FileReader::open("data.root")?, "events")?.num_entries();Types are named as Rust, not as C++: no T prefix. Readers end in Reader
(FileReader, TreeReader, NtupleReader, ChainReader) and writers in
Writer (FileWriter, TreeWriter, NtupleWriter); the rest is in-memory
data named for what it is — Hist1D, Profile1D, Graph, Func1D, Axis,
Matrix. Every ROOT class name is a doc alias of the type that models it,
so searching the API docs for TH1, TProfile, TGraph, TF1, TFile,
TTree or TChain still lands on the right page.
None of this touches the file: a Hist1D still reads and writes a TH1D, and
class_name() reports the ROOT class it came from. ROOT's own spelling
survives in one place in the API — the dynamic Value tree, whose member keys
(fName, fBins, …) are the file's own field names, read from its
TStreamerInfo.
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
Hist1D/Hist2D/Hist3Din every precision (D/F/I/S/C/L),Profile1D/Profile2D/Profile3D,Efficiency, N-dimensionalSparseHist, and polygon-binnedPolyHist(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 withHist1D::read_root(&file, name)?(theWriteRoot/ReadRoottraits; alsoh.to_root_bytes()andHist1D::read_root_in(&file, dir, name)?for a subdirectory). AHist1D/Hist2D/Hist3D's on-disk bin content type is a typedBinContentTypechosen by the builder's storage finalizer (.float()writes aTH1F; see below), or changed on a histogram you already built or read with.with_bin_content_type(BinContentType::F32);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, including its automaticSumw2: the first weight other than 1 turns on weighted per-bin errors (bin_error).sumw2()(chains:h.sumw2().fill(x)) turns them on explicitly for a unit-weight histogram. - The one way to build a histogram is the scikit-hep
hist-styleHistbuilder, mapped onto ROOT so the result is an ordinaryTH1/TH2/TH3on disk.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()→Profile1D. Chain.reg/.varagain forHist2D/Hist3D(and.profile()on those forProfile2D/Profile3D). 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 aProfile1D(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 overHist1D/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 (Hist2D→Hist1D;Hist3D→Hist1D/Hist2D), 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/Func1D::get_random, a function's) distribution (inverse-CDF, ROOT'sGetRandom/FillRandom);smoothis ROOT's353QHsmoother. A small seedableRandommeans 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 throughAxis::labels(read and write, withset_label). - Fitting (the
fitfeature, on by default) — fit a parametric model to any 1-D data: a histogram, aGraph, 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<Hist1D>: 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 the opt-inrayonfeature 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, useFileWriter— one entry point for all file composition:Every read takes a key name, and a plain name reads the object's current (highest) cycle. Ask for an older one as ROOT does, withFileWriter::create("out.root") .add(&h) // any &dyn WriteRoot: hist, profile, graph… .add(&prof) .dir("by_region", |d| d.add(&sig)) // a TDirectory per region; `dir` nests .write(Compression::Zstd(5))?; FileWriter::open("out.root")?.add(&extra).write(Compression::None)?; // append FileWriter::open("out.root")? // update: drop and reclaim .delete("scratch") // a name, or "h;1" for a cycle .purge() // keep each name's newest cycle .compact() // rewrite without the dead space .write(Compression::Zstd(5))?;
"name;2"—FileReader::key,get_value,read_root,TreeReader::open,NtupleReader::openandoxrootall take it. Written files embed aTStreamerInfolist, 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). Hist1D and
Graph 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 Graph …
let graph = Graph::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
Graph and to raw points):
cargo run -p oxiroot --example fitA single Graph 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 Graph::read_root.
Graph2D (3-D scatter) and MultiErrorGraph (several y-error layers) are
separate types with the same read/write traits.
use oxiroot::prelude::*;
let g = Graph::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 = Graph::read_root(&FileReader::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
Func1D/TFormula) and re-evaluable in ROOT:
use oxiroot::prelude::*;
let g = Graph::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
Func1D/Func2D/Func3Dkeys — real ROOT function objects (each embedding aTFormula), built from a formula and a range and read/written like any other object.Func1D::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, matchingFunc1D::Integral/Func1D::Derivative.- The same engine powers
Model::from_formula, so any formula is fittable, andFunc1D::to_model()bridges a function to a fit. oxiroot embeds theFunc1D/Func2D/Func3D/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 (ObjString) and a named scalar (Parameter<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::*;
FileWriter::create("meta.root")
.add(&ObjString::new("v2.1").named("version"))
.add(&Parameter::f64("lumi", 137.5)) // Parameter<double>
.add(&Parameter::i64("nevents", 9_000_000_000)) // <Long64_t>
.write(Compression::None)?;
let f = FileReader::open("meta.root")?;
assert_eq!(ObjString::read_root(&f, "version")?.value(), "v2.1");
assert_eq!(Parameter::read_root(&f, "lumi")?.value().as_f64(), 137.5);Parameter 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.
HistStack (a stack of histograms) and GraphStack (several graphs in one frame)
hold their members in a TList. Build them from existing Hist1D/Graphs; 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 = HistStack::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 = GraphStack::new().named("mg")
.add(Graph::new(vec![0.0, 1.0], vec![1.0, 2.0])?.named("obs"))
.add(Graph::new(vec![0.0, 1.0], vec![2.0, 1.0])?.named("exp"));
FileWriter::create("plots.root").add(&stack).add(&graphs).write(Compression::None)?;
let f = FileReader::open("plots.root")?;
assert_eq!(HistStack::read_root(&f, "hs")?.hists().len(), 2);
assert_eq!(GraphStack::read_root(&f, "mg")?.graphs()[0].name, "obs");Vector (a vector), Matrix (a dense matrix), and SymMatrix (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; its constructor mirrors the upper triangle into the
lower one, so the matrix reads back exactly as built. The constructors return
Error::LengthMismatch if the number of elements does not match the shape.
use oxiroot::prelude::*;
FileWriter::create("fit.root")
.add(&Vector::new(vec![91.2, 2.1]).named("pars"))
.add(&SymMatrix::new(2, vec![0.04, 0.01, 0.01, 0.09])?.named("cov")) // covariance
.write(Compression::None)?;
let f = FileReader::open("fit.root")?;
assert_eq!(Vector::read_root(&f, "pars")?.elements(), &[91.2, 2.1]);
let cov = SymMatrix::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
Parameter 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(&ObjString::new("2024-data").named("tag"))
.add(&Parameter::f64("lumi", 137.5));
FileWriter::create("syst.root").add(&list).write(Compression::None)?;
let f = FileReader::open("syst.root")?;
let list = ObjList::read_root(&f, "systematics")?;
assert_eq!(list.len(), 3);
assert_eq!(list.items::<Hist1D>()?.len(), 1);
assert_eq!(list.items::<Parameter>()?[0].value().as_f64(), 137.5);ObjList::array() writes a TObjArray instead. items::<T>() covers the
histogram family, Graph, ObjString, Parameter, and the linear-algebra
types; class_names() lists every member.
ObjMap 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 = ObjMap::new().named("meta")
.insert("version", &ObjString::new("2.1"))
.insert("lumi", &Parameter::f64("lumi", 137.5));
FileWriter::create("meta.root").add(&meta).write(Compression::None)?;
let meta = ObjMap::read_root(&FileReader::open("meta.root")?, "meta")?;
assert_eq!(meta.get::<Parameter>("lumi").unwrap()?.value().as_f64(), 137.5);uproot caveat: uproot has no
TMapmodel, so anObjMapis 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 (vendored in-tree as
oxiroot-rex) 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 Graph
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 Hist2D 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(Hist1D, mplhep step/fill/band/errorbar staircase with√N/Sumw2 error bars),errorbar/errorbar_with(Graph, all three error variants),profile(Profile1D),hist2d/hist2d_with(Hist2Dcolor 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.TreeWriterstreams 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. UseTreeWriter::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;ChainReaderspans many files. With therayonfeature,read_branch_par(and the_range_par/_flat_parvariants) decompress 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. A friend built withTTree::BuildIndexis joined by key instead:index()reads the persistedfTreeIndex, andjoin_by_indexgives, for each entry of the main tree, the friend entry carrying the same(major, minor)key.aliases()/alias(name)return the(name, expression)shorthands defined withTTree::SetAlias(read fromfAliases); oxiroot reads the expression strings but does not evaluate them.EntryList::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 ofNamedreads asbranch.fName/branch.fTitle. - The reader is streamer-info-driven: it parses
TTree/TBranch/TBranchElementby walking the member list in the file's ownTStreamerInfo(TreeReader::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.FileWriter::putwrites several RNTuples per file, RNTuples inside aTDirectory, and RNTuples next to histograms and trees —FileWriter::create(path).put(events).put(runs).dir("cal", |d| d.put(pedestals)).write(…). Read a nested one withNtupleReader::open_in(file, "cal", "pedestals"). ROOT and uproot navigate the result natively.NtupleWriterstreams 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:Hist1D/Hist2D/Hist3Dand the 1-, 2- and 3-D profiles summed bin-by-bin (the exactaddreduction — contents,Sumw2, entries, moments), andTTree/ RNTuple entries concatenated. Other supported objects (graphs, 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::hadd::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 oxiroot-cliinstalls 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, aHist1D's bins + stats, aGraph'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 (FileWriter) and the RNTuple writer auto-switch to ROOT's big (64-bit) container form once a file would cross 2 GiB; the streamingTreeWriter::create_large/NtupleWriter::create_largeopt into it up front (the plaincreatestays 32-bit and errors past 2 GiB rather than truncating its seek pointers). Appending (FileWriter::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. - Histograms and profiles written by older ROOT releases read too, back to the
first class versions ROOT stores through streamer info: axes from ROOT 3.02,
and profiles from before ROOT 5.24, which have no per-bin weight sums
(
fBinSumw2). Older class versions of these are an error, not a misread. 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 Graph, and raw points — one API |
robust_fit |
Outlier-resistant losses (SoftL1/Huber/Cauchy) versus ordinary least squares |
| Histograms & profiles | |
profile |
Profile1D / Profile2D — the mean of y in slices of x |
efficiency |
Efficiency turn-on curve with a Clopper–Pearson interval |
sparse |
SparseHist — a memory-efficient N-dimensional histogram |
threaded |
Multithreaded fill with ThreadedHist (ROOT's TThreadedObject analog) |
| Graphs & functions | |
graphs |
Graph / TGraphErrors / TGraphAsymmErrors / Graph2D / GraphStack |
functions |
Func1D/Func2D/Func3D from formulas: eval / integral / derivative + round-trip |
| Objects & linear algebra | |
objects |
Store run provenance (ObjString / Parameter / TList) next to your data |
linalg |
Vector / Matrix / SymMatrix round-trip (a fit-covariance shape) |
| I/O & formats | |
tree |
TTree write + read, introspection, entry ranges, streaming TreeWriter |
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 the library crates below (not oxiroot-formula, the internal oxiroot-rex, or the CLI) |
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, Efficiency/SparseHist/PolyHist, and the Graph family |
oxiroot-hist-func |
Func1D/Func2D/Func3D parametric functions, with ROOT read/write |
oxiroot-formula |
Dependency-free TFormula expression engine behind the functions and formula fits |
oxiroot-linalg |
ROOT linear-algebra objects — Vector/Matrix/SymMatrix (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-rex |
Internal: the vendored ReX TeX math layout engine used by oxiroot-plot |
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 (FileReader::open_mmap) for large files; adds memmap2. |
rayon |
— | Adds the data-parallel histogram fill (hist::fill_par) and the parallel TTree reads (TreeReader::read_branch_par and friends); adds rayon. Opt-in, so nothing spawns threads unless you ask. |
fit |
✅ | Curve fitting (oxiroot::fit, Hist1D::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 Hist1D/Hist2D/Graph/Profile1D; adds tiny-skia, ab_glyph, and the ReX TeX engine. |
http |
— | Remote reads over HTTP(S) byte-range requests (FileReader::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 (FileReader::open_url), with unix auth for public data (e.g. root://eospublic.cern.ch). Pure std::net — adds no dependencies. |
The facade is batteries-included: everything marked ✅ is on by default. 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, Profile1D, 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.
Pre-1.0 (0.1): the API may still change between minor versions. 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.
- 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
- Remote reads — richer XRootD auth (GSI / X.509 and token / ZTN) for
access-controlled data. The
root://transport ships today (FileReader::open_url, thexrootdfeature) withunixauth for public data, alongside HTTP(S) range reads (thehttpfeature). TFilecontainerRDataFrame-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. crates/oxiroot-particle and
crates/oxiroot-rex contain third-party code under their own notices (see each
crate's LICENSE-3rdparty), and the bundled fonts carry their own licences.



