Skip to content

v0.4.0

Choose a tag to compare

@mathieuouillon mathieuouillon released this 26 Jul 12:56

Install: pip install oxihipo==0.4.0

Changed

  • create / recreate / update follow uproot. They were inverted: nothing
    refused to overwrite, create clobbered, and recreate(source, dst) meant
    "decorate". Now create(path) raises FileExistsError, recreate(path)
    replaces, and update(source, dst=None) decorates.

    Migration is guarded rather than silent. recreate(source, dst) still works
    with a DeprecationWarning and behaves as update. recreate(path) on a file
    that already exists raises for one release — the old meaning decorated that
    file and the new one destroys it, so acting on either guess could lose data.
    Pass overwrite=True when you mean the new behaviour.

  • The sdist no longer advertises the wrong README. readme resolves relative
    to the manifest directory, and maturin's sdist re-roots py/pyproject.toml to
    the tarball root — next to the Rust README. A wheel built directly carried
    the Python description while one built from the sdist carried the Rust one.
    Both now resolve through README-pypi.md, a symlink present at each root.

  • The Python floor is back to 3.10, reversing the 0.2.1 raise to 3.13. The
    floor is a support decision, not a syntax one: the package needs only
    from __future__ import annotations plus PEP 604 unions in annotations, so
    3.13 excluded interpreters that run it fine — most of the installed base, for
    no benefit. abi3-py310 (wheels are cp310-abi3), requires-python >=3.10,
    the 3.10–3.12 classifiers are back, and every python-X.Y+ badge follows.
    The mypy python_version is now 3.10 and CI's test job runs on 3.10, so a
    3.11+ construct fails in review rather than at a user's import.

  • A chain no longer requires every file to carry the same dictionary. Opening
    one refused unless each file's Dict compared equal to file 0's, and that
    comparison was order-sensitive — Dict derives PartialEq over a positional
    Vec<Schema> plus index tables whose values are insertion indices — so files
    describing exactly the same banks in a different order were rejected, though
    nothing about the format makes that order meaningful.

    Real run periods are not dictionary-uniform either: a pass-2 cook adds a bank,
    an MC file carries MC::Lund. ox.open("…/pass2/*/dst/*.hipo") died on that,
    with no escape hatch. The chain now takes the union: keys() reports every
    bank any file declares, and a bank absent from a file yields empty entries for
    that file's events — which the read path already did for an absent bank.

    Two conflicts are still hard errors, because a reader cannot survive them: one
    name describing two layouts (columns would be decoded against the wrong
    schema), and one (group, item) used for two banks. The second is the
    dangerous one — the columnar path locates banks by id, so a collision would
    decode one file's bytes with another file's schema and return wrong numbers
    rather than fail. Both errors name the files and banks involved.

  • The read path goes through a ReadAt seam instead of holding an
    Arc<File> directly, so a source that is not a local file — an in-memory
    image, eventually HTTP range requests — only has to supply bytes at an offset.
    Entirely internal: SharedFile never left src/read/inner.rs, every caller
    already went through FileInner's two methods, and the in-place tag patch
    opens its own handle, so nothing outside that file changed.

    The trait fills a caller-owned buffer rather than returning one (the
    zero-allocation scan loop is pinned by a test) and has no len() — the file
    length is captured once at open, so no bounds check asks the source its size.
    Measured against the pre-change baseline, one virtual call per multi-MB record
    is not detectable: scan −1.2%/−2.2%/−6.0%, columns +1.5%/+1.2%/+3.5%,
    open −5.9%/0.0%/−0.5%, all inside this machine's run-to-run spread.

  • record_decompressed_sizes() reads its record headers in parallel. It runs
    before iterate(step_size="200 MB") can plan a single batch, so on a
    many-record chain the serial version was a visible stall before any data moved.

  • The version badges on the GitHub README, which showed v0.1.1 and
    python 3.10 | … | 3.14 while 0.3.0 was current — stale across four releases.

    They were dynamic, on the reasoning that a live page wants "latest". That
    reasoning was wrong: a shields.io badge sits behind its own Cloudflare edge
    (max-age=10800) and, on GitHub, behind camo.githubusercontent.com, and a
    dynamic URL never changes, so neither cache ever refetches. A static
    pypi-vX.Y.Z badge puts the version in the URL, so each release mints a URL no
    cache has seen and the correct image appears immediately.

    All four badge sites are now static. scripts/release.py prepare rewrites
    three of them and the generated docs page reads the version from
    py/pyproject.toml, so none can be forgotten. check also now asserts the
    python-3.13+ badges match requires-python — the mismatch that shipped in
    0.2.0.

  • A release no longer publishes without reaching the docs site. The
    release-notes page is generated from CHANGELOG.md at build time, but the docs
    workflow was path-filtered to website/** — and a release commit touches the
    changelog and manifests, not website/. So 0.3.0 shipped to PyPI while the
    site still showed 0.2.2. CHANGELOG.md is now in the filter.

Fixed

  • to_dask no longer builds a degenerate partition for an empty range.
    entry_start == entry_stop landing inside a record produced a zero-width
    batch rather than none, so the array got a partition spanning no events and
    two equal divisions — which dask requires to be increasing. The same range
    past the end of the chain already raised; both now do. Found while giving
    map_reduce the same batch logic.

  • read_columns_at no longer re-reads a record per index. Every lookup went
    through Chain::event and its single-slot record cache, so the cost depended
    entirely on the order of the list: 256 indices that happened to ascend cost
    13 µs, the same 256 scattered cost 7 ms, because each one decompressed a
    whole record that the next call threw away.

    Entries are now resolved up front and grouped by the record holding them, so
    each record is read once whatever the order, and the groups run in parallel
    (threads, matching read_columns). Scattered reads are ~26× faster
    (7.09 ms → 271 µs, lz4); ascending reads, which the cache already handled,
    stay put. This is what arrays(entries=[...]) runs on, and a list of
    interesting events found by an earlier pass is rarely sorted.

    Chain::read_columns_at takes a trailing threads argument to match its
    siblings — a breaking change, called out here because the crate is pre-1.0.

  • A failed with block no longer produces output. Writer.__exit__ called
    close() unconditionally, so an exception inside the block still finalised the
    file — a failed run left one that opens cleanly. For the in-place
    recreate(dst=None) it also ran os.replace(temp, final), overwriting the
    source with a partial result
    . And when close() itself raised, that
    exception replaced the user's. It now aborts, removes the partial output, and
    never masks the original error.

  • filtered() composes. Each call built its filter from its own arguments
    alone, so f.filtered(require=…).filtered(event_tag=…) silently dropped the
    require — and the record-tag clause was widened rather than dropped,
    because the core unioned record tags while replacing every other clause.
    Chaining now narrows: require unions, record_tag and event_tag
    intersect, and two event_tag_any clauses raise, because "any of A" and "any
    of B" is not expressible as one bitmask.

  • entries= no longer answers wrongly on a filtered chain. It resolves each
    index through the random-access path, which addresses the file's event stream
    and never consulted the filter — so it returned events the filter excludes,
    and the indices did not mean what a range read means. It now raises; making
    the two agree needs a decision about which index space entries= speaks.

  • The key namespace no longer depends on how many banks matched. single
    did the caller name one bank as a bare string? — reached only the Awkward
    assembler, so arrays(["REC::Particle"], library="np") returned bare
    pid/px keys while the same call with library="ak" returned a record
    namespaced by bank. A loop keyed on "BANK/col" worked until it met a file
    where the glob matched a single bank. All four backends now key off the
    request.

  • banks= together with filter_name= is refused. filter_name replaces
    the bank selection outright, so arrays("REC::Particle", filter_name="REC::Event*") silently returned REC::Event. Now a TypeError.

  • skim(tags=…) no longer leaves a mis-tagged file behind. The length check
    ran after the skim had finished, and the short tags was padded with zero per
    event — so the caller got an exception and a complete, silently mis-tagged
    file that opened cleanly. The partial output is removed.

  • The Arrow schema is declared non-nullable. It was inferred, leaving every
    field nullable, so a Parquet round-trip returned option[var * ?float32]
    instead of var * float32. The docs blamed Arrow for this; the schema was
    ours. Values were never affected.

  • composite(library="np") keeps a consistent shape. np.array(…, dtype=object) over equal-length slices collapses to a rank-2 array of boxed
    scalars, so the result's shape depended on whether the file happened to have a
    constant number of rows per event.

  • A URL passed to open() now reports that remote sources are unsupported,
    instead of falling through to the glob branch and reporting "no such file or
    directory".

Added

  • Chain.map_reduce(fn, ...) — run the analysis in the workers. workers=
    on arrays/iterate parallelises only the read: workers hand raw buffers
    back and the parent does the physics serially, which for a CLAS12 selection is
    where the time goes. map_reduce runs fn on each chunk in the worker and
    sends back only its return value — a filled hist.Hist pickles to a few
    hundred bytes against the hundreds of megabytes it was filled from.

    reduce= defaults to operator.add, which hist.Hist, boost_histogram,
    np.ndarray and numbers already implement. Results are folded in event
    order
    rather than completion order, so a non-commutative reduce is safe,
    and the parent holds one accumulator rather than every chunk's result.
    initial= seeds it and defines the empty-selection answer; without one an
    empty range raises instead of returning None.

  • ox.link(banks)pindex cross-references across a whole read. Wires
    both directions at once, so ev["REC::Calorimeter"].particle.px and
    ev["REC::Particle"]["REC::Calorimeter"] both work and the join becomes
    something you follow rather than something you write. Banks with no pindex
    pass through untouched.

    directions= exists because the two sides do not cost the same: the
    detector→particle side copies a particle record onto every detector row, which
    on a bank with ten times the rows is ten copies of each momentum. The other
    side regroups and copies nothing.

    An out-of-range pindex is None going forward and dropped going back —
    never attached to whichever particle happens to be there.

  • ox.group_by_index(detector, counts) — the pindex join. Detector banks
    point at their particle by row number, and the project's own tutorial calls
    learning that join "the single most useful CLAS12-specific skill" — then does
    it by hand, ak.sum(cal.energy[cal.pindex == 0], axis=1), which answers for
    one hardcoded particle.

    This regroups a detector bank into one sublist per particle, in particle
    order, so ak.sum(by_particle.energy, axis=-1) is a per-particle column that
    can be attached beside px. Selections compose before the reduction.

    A pindex outside its event's particle range is dropped rather than clamped:
    it names a particle that is not there, and folding it onto particle 0 would
    put that energy on a real track. Particles with no rows get an empty sublist,
    so the result always aligns with the particle array.

  • ox.to_vector(array, mass=...) — Lorentz-vector behaviours over a
    momentum bank, via vector. v.E, v.pt,
    v.eta, (v[:, 0] + v[:, 1]).mass and deltaR on columns that were three
    flat arrays, with mass="pdg" taking each row's mass from its pid.

    Omitting mass gives a 3-vector, not a massless 4-vector: an assumed-zero
    mass wearing a four-vector's interface is how a wrong invariant mass happens.
    pid == 0 carries nan into E for the same reason. Columns not needed for
    the vector come through untouched, so cuts still work on the result.

    A function rather than a keyword on arrays(), so it composes with iterate
    chunks, to_dask partitions and post-cut= results instead of only the one
    call. vector is an optional extra (pip install oxihipo[vector]).

  • ox.pdg_mass(pid) — PDG masses in bulk, plus ox.pdg_name and the
    ox.PDG_MASS_GEV table behind them. Every tutorial hardcoded the constants it
    needed (M_PIP = 0.139570, M_P = 0.938272) because there was nothing to
    call, and the obvious substitute fails on exactly the two things a CLAS12
    REC::Particle column is full of: Particle.from_pdgid(0) raises, though
    pid == 0 is simply a track the reconstruction could not identify, and
    from_pdgid(45) raises, though CLAS12 writes Geant3 codes for light
    nuclei (45/46/47/49 = D/T/He4/He3) and the project's own PID table documents
    45 as the deuteron.

    It keeps the shape it is given — scalar, NumPy, or the jagged ak.Array a
    column read returns — so a mass column lines up with the momenta beside it.
    Unknown codes give nan rather than raising. Masses are GeV, matching CLAS12
    momenta; particle reports MeV, and mixing them is a factor of 10³.

    No new dependency: the table is baked (generated from particle, and a test
    cross-checks all 44 codes against it when it is installed), so the answer does
    not change with the environment. One searchsorted, ~37 ms over 2M particles
    against ~3 s per-row — 61×, not the 250× the design note estimated. Its
    suggested np.unique step turned out to be slower than looking up directly,
    since it sorts the column to save a lookup over 44 entries.

  • to_dask() is a real dask-awkward source. It was from_map over a plain
    function, which dask-awkward cannot introspect, so the array was lazy in name
    only: constructing it read partition 0 just to learn the type, len() and
    entry slices raised on unknown divisions, and every partition read every column
    of every selected bank however little of it the graph touched.

    It now carries the form (from a zero-event read, which decompresses no record),
    reports the batch boundaries it had already computed as divisions, and
    implements dask-awkward's ColumnProjectionMixin — so dak.sum(p.px) reads
    px alone, across banks as well as within one, and
    dak.report_necessary_columns answers instead of returning {}. Under cut=
    divisions are deliberately withheld: a per-event cut drops events, and
    boundaries that later prove wrong are worse than absent ones.

  • CI actually exercises what it builds: the test job matrixes over interpreters
    (the 3.10 floor on all three OSes, plus 3.14 on Linux) instead of pinning one;
    every wheel job installs the wheel it just built and reads a file with it; the
    sdist job rebuilds the tarball into a wheel and asserts its metadata; and the
    13 py/examples/*.py are smoke-run, as the Rust examples already were.

  • filter_name= accepts a sequence of globs as a union, on arrays,
    iterate and keys — asking for REC::* plus RUN::config needed two calls.

  • library="pd" frames carry attrs["num_entries"]. An event with no rows is
    absent from the (entry, subentry) index entirely, so a frame cannot be
    positionally joined against event_tags(). The frame is deliberately not
    reindexed to the full range — that would insert a row per empty event, i.e.
    invent a particle, and make pd disagree with ak/np on row counts — so the
    true count travels alongside instead.

  • Module-level iterate() gained cut=, which Chain.iterate already had.

  • CITATION.cff, and the wheel now ships the licence text (PEP 639
    license + license-files) rather than only naming MIT in metadata. The
    file license-files points at is py/LICENSE.txt, a symlink to the real
    LICENSE — a glob cannot escape the project directory, a copy could drift,
    and the name cannot be LICENSE because maturin already places the repo-root
    one at the sdist root.

  • scripts/release.py check also verifies CITATION.cff's version and that
    py/LICENSE has not drifted from LICENSE.