v0.7.0
Install: pip install oxihipo==0.7.0
Breaking
TagRegistry::insertandTagRegistry::from_namesreturnResult. A tag
name that cannot survive the on-diskname=bittext form is now refused
instead of silently mangled (below).WriterBuilder::tag_nameskeeps its
signature and surfaces the error frombuild.
Changed
-
Split-codec record format bumped:
Lz4PerBank2 → 3,Lz4PerColumn1 → 2.
The directory gained aB × u8compositeheader_sizetable, appended after
the existing tables so every offset before it is byte-for-byte unchanged. One
parser reads both versions, with the tail defaulting to 0 — which is exactly
what the old versions meant. Any other version is refused rather than
misread — measured: a 0.6.0 reader opens a 0.7.0 split-codec file and reports
its event count, then fails the firstevents()item withLz4PerBank: unsupported extension-format version. It never returns wrong data.Compatibility was checked in both directions, all four codecs, by building
v0.6.0 and HEAD side by side and cross-reading:NoneLz4Lz4PerBankLz4PerColumnHEAD reads 0.6.0 ✅ ✅ ✅ ✅ 0.6.0 reads HEAD ✅ ✅ clean error clean error The blob codecs are not merely compatible, they are byte-identical: the
same inputs written by 0.6.0 and by HEAD hash to the same SHA-256. Since those
are the only codecs C++ hipo4 and Java can read, interoperability with them
cannot have changed. Neither split codec was ever readable outside this
library.
Fixed
-
Composite banks lost their format string under
Lz4PerBankand
Lz4PerColumn. A bank's structure length word packs the data size into its
low 24 bits and the compositeheader_size— the format string's length —
into its top byte. Both split codecs take a record apart and store bank
payloads separately, discarding the structure headers, and rebuilt that byte
as zero. A composite bank therefore came back looking like an ordinary one:
header_sizeread as 0 andcomposite()returnedNone. The two blob
codecs,NoneandLz4, were unaffected, so the same file written two ways
disagreed about whether its banks were composite. Fixed by the format change
above, plus carrying the byte through the by-bank structure iterator and both
event synthesisers.Validated on real data as well as fixtures: 2,000 events of an 8.5 GB CLAS12
DST (71 distinct banks) and of a simulation file (106 banks) were rewritten
through all four codecs and compared bank for bank — everyheader_sizeand
payload identical to the source. Neither file carries a composite bank, so
this bug was not corrupting CLAS12 reconstruction output; it was corrupting
composites, which the format allows anywhere. -
OwnedEvent::compositereturnedNoneon every split-codec event. It
delegated toEventCtx::composite, which needs original structure bytes and
documents itself as returningNonefor by-bank backends — advising callers
to up-convert toOwnedEvent, the very thing that did not work. It now goes
through the synthesised event blob, which (since the fix above) carries the
compositeheader_size. -
Lz4PerColumnstored a composite bank column-major when a schema happened
to describe its group/item and its payload divided evenly into rows — a layout
that assumes fixed-width rows the bank does not have. Composites are now kept
opaque regardless. Belt-and-braces: the split is a permutation the synthesiser
inverts, so no round-trip through this library could observe it.
Added
tests/composite_codecs.rs— composite banks across all four codecs, checked
both for the survivingheader_sizeand for decoded field values. Two
composites with different format-string padding, since only the 8-byte-padded
one divides evenly into rows and so reaches theLz4PerColumnguard.Lz4PerBankadded to the read benchmark's format list. It reaches the reader
through the by-bank structure iterator rather than the per-column synthesiser,
so benching onlyLz4PerColumnleft that scan path unmeasured.tests/salvage.rs— four tests for the sequential scan: the trailer is not
indexed as data, salvage resynchronises past a damaged header, an impossible
event_countis rejected, and an intact file still indexes exactly on every
codec.tests/event_tag.rs— tag names that cannot round-trip are refused, by the
registry and by the writer; ordinary names still survive a real write and read.tests/no_alloc.rs— the per-event allocation contract is now checked on
every codec, by comparing two files with the same record count and 4× the
events rather than against a fixed budget.
Performance
-
Lz4PerBankandLz4PerColumnsequential reads are ~25 % faster.
Removing the per-eventArcallocation below: 959 µs → 724 µs and 998 µs →
713 µs on the benchmark fixture. The blob codecs are unchanged — two
interleaved A/B rounds disagreed on their sign (+4 % then −2.6 %), which is
the machine's noise floor, andOwnedEventis the same 56 bytes it was. -
A tag name containing
=, a line break, or edge whitespace was written and
silently read back as something else. The registry is stored asname=bit
lines and the reader splits on the first=, splits lines on\n, and trims
each name. Of five names written, only one survived unchanged:written read back plainplainhas=equalsdropped has\nnewlinenewline␣␣padded␣␣padded`` (empty) dropped Two came back under a different name, so
mask("has\nnewline")returned
Noneand the flag quietly stopped matching. Such names are now refused when
inserted, andWriter::buildfails rather than writing one. -
The scan indexed the trailer as a data record. A trailer is an ordinary
one-event record carrying thefile::indexbank — no header bit sets it
apart — and the fallback scan walked straight into it. The comment said the
normal path never met this; it does, whenever a trailer exists but does not
parse, which is exactly when the fallback runs. A 12-event file with a
corrupted trailer index reported 13 events. -
One damaged record header cost the whole file,
open_salvageincluded.
The scan propagated the parse error instead of resynchronising, so the salvage
path — whose entire purpose is recovering from this — recovered nothing.
Salvage now finds the next record and continues: on a 12-event file with one
destroyed header it returns the 8 events on either side of the damage. The
normal path still reports the corruption, deliberately. -
event_countwas taken on trust. A corrupt header propagated straight
intoChain::event_count(): flipping one record's count to 1,000,000 made a
12-event file report 1,000,009, and the firstevents()item was an error.
The record's index array bounds the real count at four bytes per event, and
that is a header field, so no decompression is needed. Checked against real
data before relying on it: across 1,951 records of an 8.5 GB CLAS12 DST, a
simulation file, and C++ hipo4's own golden file,index_array_lengthis
exactlyevent_count * 4. -
Every split-codec event heap-allocated a cell it usually never filled.
OwnedEventheld the lazy whole-event blob asArc<OnceLock<Vec<u8>>>, so
constructing one allocated even when nothing ever asked for the blob: 852
allocations for 800 events created and dropped untouched. TheArcmoved
inside the cell (cell::OnceCell<Arc<Vec<u8>>>), which allocates only on
first use and keepsOwnedEventat 56 bytes.Chain::eventsdocuments "no
per-event allocation"; that is now true on every codec, and tested.
Documentation
Chain::events' memory contract now says what the split codecs actually cost
(more per record, still nothing per event) and notes that the whole-event
views synthesise a blob on first use.- The split codecs sort banks by
(group, item)— load-bearing, since the
reader binary-searches that table — so they do not preserve the order
banks were added in.structures()yields ascending(group, item)there and
write order on the blob codecs. Nothing addresses a bank by position, so this
is an iteration-order difference rather than a data one; it is now documented
onCompressionand at both sort sites rather than left to be discovered.
Removed
tests/zz_b.rs— a diagnostic that printed recordbit_infowords and
asserted nothing, committed by accident in the record-header bit fix.