Skip to main content

Event tagging — design & roadmap

Every HIPO event already carries a 32-bit tag (EH_TAG, the per-event header word). oxihipo turns that latent field into a first-class feature: a pushed-down filter, named flags, a self-describing on-disk registry, and a way to write freshly-tagged files — enough to close the whole select → label → write → reread loop for physics event classification (DVCS, SIDIS, elastics, trigger categories, …) without a heavyweight index.

Usage lives in the guides — Rust · Reading, Rust · Writing, and Python · Reading. This page is the design record and the roadmap.

What's shipped

The feature landed in phases, each wire-compatible (tags are just bits of a word that has always been there):

PhaseCapabilitySurface
1Pushdown event-tag filter — drop events by EH_TAG without inflating any bankFilter::event_tag([…]) / event_tag_any(mask)
1bTag column aligned 1:1 with the columnar readChain::event_tags(range, threads) · Python f.event_tags()
2aNamed single-bit flags as compile-time constantsTagSet + the tag_flags! macro
2bPersist the name↔bit registry in the file, so tags self-describeWriter::tag_names · Chain::tag_registry · Python f.tag_names, filtered(event_tag="dvcs")
3Write tagged DSTs — classify each event and stamp the resultChain::skim_tagged · Python f.skim(…, tags=, tag_names=)

The tag is read from the event header (stock formats) or the record directory (Lz4PerBank / Lz4PerColumn) — never by decompressing a bank. The name registry rides in the file's dictionary record as one extra text bank, so it is additive: a reader that doesn't know about it simply skips it.

Updating a tag in place. Chain::set_event_tag / set_event_tags (Python f.set_event_tag / f.set_event_tags) patch an event's EH_TAG on an existing file without rewriting it — a single 4-byte pwrite, gated by write permission. Because the tag must land at a fixed on-disk offset, this works only for uncompressed files (Compression::None); for any compressed record the tag is inside a compressed block, so it errors (InPlaceTagUnsupported / ValueError) and skim_tagged is the way to rewrite. The event-header magic is verified before every write, so a miscomputed offset can't corrupt the file.

Performance: the pushdown is free

The filter runs before a bank is touched, so filtering by tag can only save work. The question that matters is the other direction — does the per-event tag check cost anything on a read? Measured with examples/bench_event_tags.rs (Apple M4 Pro, single thread, warm cache, 200–300k synthetic events), comparing an unfiltered for_each (which never calls the filter) against an all-pass event_tag_any filter (which runs the check on every event):

PathBaseline+ all-pass tag filterΔ
Lz4 (tag from event header)~7.9 ns/ev~7.9 ns/ev≈ 0 (±0.05)
Lz4PerColumn (tag from directory)~4.2 ns/ev~4.2 ns/ev≈ 0 (±0.05)

The check is a single u32 read plus a compare — below the measurement noise on both paths, and an unfiltered read never enters the filter at all, so scans that don't use tags are unchanged. event_tags() runs at the speed of a bare scan (no bank inflation). Reproduce:

cargo run --release --example bench_event_tags -- 200000 15

Roadmap

The five shipped phases cover the common case: a per-event u32 used as up to 32 named bit-flags. Two heavier extensions are deferred — they solve real but rarer needs and touch more of the format, so they wait until a workload asks.

Phase 4 — writer-side record-tag routing

What. A public API on the Writer to set the per-record tag (user_word_1) as records are flushed, and to route events into records by a coarse key — so all events of a category land in the same records.

Why. oxihipo already reads record tags with pushdown (Filter::record_tag), which skips a whole record (thousands of events) on a single header read — strictly cheaper than the per-event check. But nothing on the writer produces that layout today: skim renumbers output records and tags them 0. Phase 4 makes the coarse pushdown usable end-to-end: bin a skim by category, and a later read of one category touches only its records.

Shape (proposed). A skim_binned(|ev| -> u64)-style routing closure that groups consecutive same-key events into records and stamps user_word_1, plus a Writer::set_record_tag for the manual path. It composes with Phase 3 — per-event tags for fine selection, per-record tags for cheap coarse skipping.

Cost / risk. Buffering to group by key fights the streaming, bounded-memory writer; the simplest version only groups runs of same-key events (no global sort), which keeps memory bounded but needs the caller to pre-sort for full benefit. Deferred until a binning workload is concrete.

Phase 5 — a schema'd TAG::Event bank

What. An optional, dictionary-described bank (e.g. TAG::Event) written alongside the data, holding richer per-event labels than 32 bits: named integer categories, floats (a classifier score, a weight), or several tag columns.

Why. EH_TAG is exactly 32 bits with no room for payloads. Analyses that want a BDT/DNN score per event, an MC-truth channel id, or more than 32 mutually-exclusive categories currently can't express that in the tag. A schema'd bank reuses everything oxihipo already does well — typed columns, zero-copy reads, per-column compression, arrays() in Python — so the labels read like any other bank.

Shape (proposed). A convention (a reserved bank name + helper builders) plus optional pushdown that reads just the TAG::Event bank's columns to filter, analogous to today's per-column tag read. No new wire format — it's an ordinary bank — so it is automatically C++-hipo4-readable, unlike the EH_TAG directory path.

Cost / risk. Larger than a header word (a real bank per event), and it overlaps with "just make your own bank," so the value is mostly the pushdown and the ergonomic helpers. Deferred until >32 flags or per-tag payloads are actually needed.

Not planned

Anything that would pull physics, ROOT, or a schema-migration layer into the reader stays out of scope, matching the crate's boundaries.