Skip to content
mathieuouillonPublic

About

Pure-Rust reader and writer for CERN ROOT files (RNTuple and classic histograms). Interoperable with official ROOT and uproot. No libROOT.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

357 Commits

Folders and files

Repository files navigation

oxiroot

License: MIT Rust 1.95+ No libROOT

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.

Highlights

  • 🦀 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_value decodes any object from its TStreamerInfo into a dynamic Value tree (rootls / rootprint-style), even classes with no typed model — and oxroot dump prints it. Undecodable members degrade to Unsupported, never a crash.
  • 📊 Histograms & profiles — Hist1D/Hist2D/Hist3D (every precision, uniform or irregular bins), Profile1D/Profile2D/Profile3D, Efficiency, N-dimensional SparseHist, and polygon-binned PolyHist — all read and write.
  • 🎲 Sampling & smoothing — draw from a histogram's or function's distribution (get_random/fill_random, ROOT's GetRandom/FillRandom) and smooth with ROOT's 353QH (smooth), via a small seedable built-in Random (no rand dependency).
  • 📈 Graphs — Graph, TGraphErrors, TGraphAsymmErrors, plus Graph2D and MultiErrorGraph — read and write, including a graph's display frame (fHistogram) and attached fitted functions (fFunctions, faithful Func1D).
  • 🧮 Functions — standalone Func1D/Func2D/Func3D keys backed by a real TFormula expression engine (arbitrary formulas, ROOT gaus/expo/pol shortcuts), with pure-Rust eval/integral/derivative; any formula is also fittable via Model::from_formula.
  • 📐 Statistics (oxiroot::stat) — a dependency-free, scipy.stats-verified core: special functions (erf, gammainc, betainc, ndtri), the Normal/StudentT/ChiSquared/FisherF distributions (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 seeded bootstrap_ci. A physics module 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. A lineshapes module 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-fit Model (Model::crystal_ball, voigtian, …).
  • 🌳 TTree — read and write scalar, fixed/variable-length array, string, std::vector<T>, and split std::vector<MyStruct> branches; read nested structs, std::vector<std::vector<T>>, TClonesArray, split single objects, old unsplit object branches (TBranchObject), std::set/std::map branches, TNtuple/TNtupleD, friend trees (AddFriend, entry-aligned or joined on a BuildIndex key), tree aliases (SetAlias), and EntryList selections; 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 nullable std::optional/std::unique_ptr and std::atomic), read unsplit streamer fields, compressed, multi-cluster via a streaming writer, several RNTuples per file / inside a TDirectory, 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) (http feature) or CERN's XRootD root:// (xrootd feature) with FileReader::open_url and 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 on root://eospublic.cern.ch. A large local file reads the same lazy way with FileReader::open_ranged.
  • 🧵 Multithreaded fill — ThreadedHist, the pure-std analog of ROOT's TThreadedObject<Hist1D>; optional one-call rayon parallel fill.
  • ➕ hadd — a pure-Rust file merger: everything ROOT's hadd merges 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 own hadd (oxiroot::hadd::merge_files).
  • 🔎 Command-line inspector — oxroot ls / show / dump / stat looks into any ROOT file (keys, TTree/RNTuple structure, entries, histogram bins) from the shell — no ROOT, no Python (the oxiroot-cli crate).
  • 🎨 Plotting (optional) — render Hist1D/Hist2D/Graph/Profile1D to 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).

Quick start

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"  # RNTuple
use 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

Features

Histograms & profiles (oxiroot::hist)

  • Read & write Hist1D/Hist2D/Hist3D in every precision (D/F/I/S/C/L), Profile1D/Profile2D/Profile3D, Efficiency, N-dimensional SparseHist, and polygon-binned PolyHist (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 with Hist1D::read_root(&file, name)? (the WriteRoot/ReadRoot traits; also h.to_root_bytes() and Hist1D::read_root_in(&file, dir, name)? for a subdirectory). A Hist1D/Hist2D/Hist3D's on-disk bin content type is a typed BinContentType chosen by the builder's storage finalizer (.float() writes a TH1F; 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 typed ErrorMode.
  • No forced names, no global registry. A histogram is just data: construct it with the Hist builder (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 no gROOT/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 loud DuplicateName error, never ROOT's silent shadow-on-read.
  • Then fill/fill_weight with ROOT's exact Fill semantics, including its automatic Sumw2: 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-style Hist builder, mapped onto ROOT so the result is an ordinary TH1/TH2/TH3 on 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 with var — Hist::var(&[0.0, 1.0, 2.0, 5.0, 10.0, 100.0]).double() (a real ROOT variable-binned TH1D). 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/.var again for Hist2D/Hist3D (and .profile() on those for Profile2D/Profile3D). Axis labels (label() / with_x_label() / x_label()) live in ROOT's fXaxis.fTitle and round-trip. The hist accessor family is there too: values(), variances() (Sumw2), errors() (√variance), counts() (effective entries), density(), and at(x) (content at a coordinate); plus batch fill_many(), UHI-style integral_range(a, b) / slice(a, b), and a Hist::reg(...).profile() finalizer for a Profile1D (hist's Mean storage).
  • Arithmetic with Sumw2 error propagation: scale (also h *= 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 shared Histogram trait (contents/entries/sum) abstracts over Hist1D/2/3, and every type implements Display for 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 histograms rebin/rebin2d/rebin3d, cumulative, projections (Hist2D→Hist1D; Hist3D→Hist1D/Hist2D), and profile_x/profile_y — all carrying the statistical moment sums so the results' mean/std_dev stay correct.
  • Sampling & smoothing: get_random / fill_random draw from a histogram's (or, via fill_random_fn / Func1D::get_random, a function's) distribution (inverse-CDF, ROOT's GetRandom/FillRandom); smooth is ROOT's 353QH smoother. A small seedable Random means no rand dependency and reproducible draws.
  • Compatibility tests: chi2_test/chi2_test_with (Pearson χ², all three UU/UW/WW weighting schemes) and kolmogorov_test, returning ROOT-matched p-values. Alphanumeric (labelled) axes round-trip through Axis::labels (read and write, with set_label).
  • Fitting (the fit feature, on by default) — fit a parametric model to any 1-D data: a histogram, a Graph, or your own (x, y, σ) points. The standalone oxiroot::fit crate provides the Model (built-in gaussian/exponential/polynomial, the HEP peaks crystal_ball/voigtian/…, an arbitrary formula, or a custom closure) and a pure-Rust Minuit2 minimizer; anything implementing the FitData trait gets .fit(&model) (Neyman/Pearson χ² or binned Poisson likelihood), returning parameters, parabolic + MINOS errors, covariance, and chi2/ndf. A robust Loss (Huber/SoftL1/Cauchy/Arctan — scipy's least_squares loss) down-weights outliers.
  • Multithreaded fill — ThreadedHist, the pure-Rust analog of ROOT's TThreadedObject<Hist1D>: share &hist, call hist.fill(x) from any thread — each thread transparently gets its own copy — then hist.merge() combines them exactly (contents + Sumw2 + every moment sum), identical to a serial fill:
    let 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()?;
    No Arc, no manual slots; with_local(|h| …) batches fills or reaches any method, and the opt-in rayon feature adds a one-call fill_par(&template, &data, |h, &x| h.fill(x)). See examples/threaded.rs.
  • Write one object with h.write_root(path, compression). For several objects, subdirectories, or appending, use FileWriter — one entry point for all file composition:
    FileWriter::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))?;
    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, with "name;2" — FileReader::key, get_value, read_root, TreeReader::open, NtupleReader::open and oxroot all take it. Written files embed a TStreamerInfo list, so they are self-describing for any ROOT reader.

Fitting (oxiroot::fit, fit feature)

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 fit

Graphs (oxiroot::hist)

A 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)?;

Functions (oxiroot::hist)

  • Standalone Func1D/Func2D/Func3D keys — real ROOT function objects (each embedding a TFormula), 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 TFormula expression 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 a TMath:: prefix), comparisons/?:, and ROOT's gaus/expo/ pol0..N shortcuts.
  • eval / integral / derivative in pure Rust — adaptive Gauss–Kronrod quadrature and a Richardson central derivative, matching Func1D::Integral / Func1D::Derivative.
  • The same engine powers Model::from_formula, so any formula is fittable, and Func1D::to_model() bridges a function to a fit. oxiroot embeds the Func1D/Func2D/Func3D/TFormula TStreamerInfo, so ROOT C++ and uproot both read and re-evaluate all three.
  • See the functions example.

Persistable objects (oxiroot::hist)

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.

Collections (oxiroot::hist)

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");

Linear algebra (oxiroot::linalg)

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)); // symmetric

Particle data (oxiroot::particle)

A 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().

Object collections (oxiroot::hist)

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 TMap model, so an ObjMap is not readable there — a limitation that ROOT's own TMaps share. ROOT C++ reads what oxiroot writes, and oxiroot reads ROOT's TMaps.

Plotting (oxiroot::plot, plot feature)

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.

Z to mu mu candidates: filled MC template with data points overlaid, matplotlib look The same histogram in the mplhep style with a bold CMS Preliminary label and luminosity/energy

2-D Hist2D rendered as a viridis heatmap with a colorbar A main panel over a data/MC ratio panel sharing the x-axis

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>(())
  • Axes mirrors 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 (Hist2D color mesh with a colorbar and the real matplotlib viridis/plasma colormaps; Hist2dOpts::log()/.norm(Norm::…) switches to a log / symlog color scale with a decade colorbar, like matplotlib's LogNorm/SymLogNorm), plot, function (overlay any analytic or fitted curve), grid, xlabel/ylabel/title, xlim/ylim (taking a Range, e.g. ax.xlim(0.0..100.0)), and legend. The *_with methods 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, and Style::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, and ax.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 the fit feature, ax.model(&model, x0..x1) overlays a fitted oxiroot::fit Model directly on a histogram, and ax.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 custom GridSpec (height/width ratios, spacing) with fig.sharex()/sharey() and a suptitle, plus a one-call ratio_subplots() for the HEP main-over-ratio plot (shared x-axis, the upper panel's x labels hidden).
  • Output — save/save_with pick the format from the extension: .png, .svg, and .pdf (a hand-written vector PDF). SaveOpts sets the DPI for a sharper PNG (save_with(path, SaveOpts::new().dpi(300.0))) or a transparent background; to_png_bytes/to_svg_string render in memory. PdfPages collects several figures into one multi-page vector PDF (matplotlib's PdfPages).
  • 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, and FontSet::from_path("MyFont.otf") / from_paths(text, math) plug in any TrueType/OpenType font (the math font needs a MATH table).
  • The default look otherwise reproduces a plain matplotlib figure (640×480, the tab10 color 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

TTree (oxiroot::tree)

  • Read & write scalar, fixed-size array (x[N], incl. multidimensional x[N][M]), variable-length / jagged (x[n]), string, multi-leaf (leaflist a/F:b/I), and std::vector<T> branches; read std::vector<std::string>, std::set<T> (an unsplit object-wise collection) and std::map<K,V> (split into parallel .first/.second collections), and TNtuple / TNtupleD (the all-float/double TTree subclasses) as ordinary trees.
  • Split std::vector<MyStruct> branches written as per-member sub-branches (TBranchElement), with a generated TStreamerInfo for 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_* for std::vector<T>, and Branch::split_vector for split structs.
  • Tree::new(name, branches).write_root(path, compression) is the method form (mirroring hist.write_root); .write_root_baskets(…, entries_per_basket) writes several baskets per branch, and .to_root_bytes(…) returns the file bytes. The free write_tree_file/write_tree_file_baskets functions remain.
  • TreeWriter streams a tree in batches (write_batch emits one basket per branch straight to disk, then finish), so only the current batch is held in memory — the way ROOT's TTree::Fill flushes baskets. ROOT-C++- and uproot-verified across many baskets, compressed and not. Use TreeWriter::create_large for a tree that will exceed 2 GiB — it writes the 64-bit container form (again ROOT-C++/uproot-verified).
  • read_branch reads a whole branch, read_branch_range(start, stop) only the baskets covering a window, and read_branch_flat an offsets+flat (no Vec<Vec>) view; ChainReader spans many files. With the rayon feature, read_branch_par (and the _range_par / _flat_par variants) decompress baskets in parallel. Introspect with branch_type/branch_shape/branch_title, and see what was skipped via unsupported_branches(). Worked example: cargo run -p oxiroot --example tree.
  • friends() returns the friend trees attached with TTree::AddFriend (read from the persisted fFriends list). 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 with TTree::BuildIndex is joined by key instead: index() reads the persisted fTreeIndex, and join_by_index gives, 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 with TTree::SetAlias (read from fAliases); 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, with entries() / contains(entry).
  • Old unsplit object branches (TBranchObject, the pre-TBranchElement way of storing a whole object per entry) are read by synthesizing one branch.member column per basic/string member of the object class — e.g. a branch of Named reads as branch.fName / branch.fTitle.
  • The reader is streamer-info-driven: it parses TTree/TBranch/ TBranchElement by walking the member list in the file's own TStreamerInfo (TreeReader::streamer_classes exposes 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 TStreamerInfo from a declarative class table (the whole TTree/TBranch/TLeaf*/TBranchElement hierarchy, 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.

RNTuple (oxiroot::ntuple)

  • 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 Switch column — 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" fields std::optional<T> / std::unique_ptr<T> (read as a [FieldValues::Opt] you can zip with opt_f32() etc.), std::atomic<T>, fixed-size std::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 a Vec<Option<T>>, Field::atomic_*) — optionally Zstd-compressed.
  • Ntuple::write_root_extended writes 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 entries first_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 (mirroring hist.write_root), with .to_root_bytes(…) for the file bytes; the free write_rntuple_file remains.
  • FileWriter::put writes several RNTuples per file, RNTuples inside a TDirectory, 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 with NtupleReader::open_in(file, "cal", "pedestals"). ROOT and uproot navigate the result natively.
  • NtupleWriter streams one cluster per write_batch, so a large dataset is never fully held in memory.

Merging files — hadd (oxiroot::hadd)

  • 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/Hist3D and the 1-, 2- and 3-D profiles summed bin-by-bin (the exact add reduction — contents, Sumw2, entries, moments), and TTree / 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], jagged x[n], std::vector<T>, string). The standalone oxiroot_tree::concat_trees, oxiroot_rntuple::concat_ntuples, and oxiroot::hadd::merge_histogram_files do the per-format work and can be called directly.
  • Merger::new().inputs(paths).compression(c).merge("all.root")? is the composable builder; merge_files returns a MergeReport (what was summed / copied / skipped, and the total entries) with a one-line Display.
  • 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 single TTree, or a single RNTuple — anything else is refused with a message naming the keys. Verified against ROOT 6.40's hadd (identical histogram sums, entry-for-entry tree concat) and read back by uproot and ROOT C++. See the merge example.

Command-line inspector — oxroot (oxiroot-cli)

  • Look into a ROOT file from the shell, no ROOT or Python: cargo install oxiroot-cli installs the oxroot binary. Objects are addressed file.root:name (uproot-style), with a /-path for nested TDirectorys (file.root:cal/run2/Events).
  • oxroot ls [-l] [-r] — list objects (name, class, title; -l adds cycle and TTree/RNTuple entry count; -r recurses into every TDirectory, by full dir/sub/name path).
  • oxroot show file.root:Events — a TTree'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 N TTree/RNTuple entries as a column table, a Hist1D's bins + stats, a Graph'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.

Compression

  • 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), or Compression::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.

Robustness & large files

  • 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-shot TFile object writers (FileWriter) and the RNTuple writer auto-switch to ROOT's big (64-bit) container form once a file would cross 2 GiB; the streaming TreeWriter::create_large / NtupleWriter::create_large opt into it up front (the plain create stays 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.
  • Error is #[non_exhaustive] and preserves the underlying io::ErrorKind.

Examples

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)

Workspace layout

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).

Optional features

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.

Build & test

cargo build  --workspace
cargo test   --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt    --all --check

The 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.

Full local interop check

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 path

It 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.

Roadmap

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::map write — oxiroot already writes a spec-compliant std::map RNTuple that uproot reads and oxiroot round-trips (Field::map; see the assoc tests). Confirming a ROOT-C++ round-trip is blocked because ROOT 6.40's std::map collection proxy is non-functional in the test build — it can neither create nor read a std::map RNTuple field — so this needs a ROOT install with the std::map dictionary loaded to verify.
  • Remote reads — richer XRootD auth (GSI / X.509 and token / ZTN) for access-controlled data. The root:// transport ships today (FileReader::open_url, the xrootd feature) with unix auth for public data, alongside HTTP(S) range reads (the http feature).
  • TFile container
  • RDataFrame-style analysis (far future) — a lazy, columnar analysis front-end (Define / Filter / Histo1D / Sum, executed in one pass over a TTree or 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.

License

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.

About

Pure-Rust reader and writer for CERN ROOT files (RNTuple and classic histograms). Interoperable with official ROOT and uproot. No libROOT.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages