Serialization#
Genogrove supports serialization for persisting groves to disk and loading them back. This avoids re-parsing and re-inserting data from source files, which is significantly faster for large datasets.
Basic Usage
Save a grove to disk and load it back:
#include <genogrove/structure/grove/grove.hpp>
#include <genogrove/data_type/interval.hpp>
#include <fstream>
namespace gdt = genogrove::data_type;
namespace gst = genogrove::structure;
int main() {
gst::grove<gdt::interval, std::string> my_grove(100);
my_grove.insert_data("chr1", gdt::interval{100, 200}, "gene1");
my_grove.insert_data("chr1", gdt::interval{300, 400}, "gene2");
// Save to disk
{
std::ofstream out("grove.bin", std::ios::binary);
my_grove.serialize(out);
}
// Load from disk
{
std::ifstream in("grove.bin", std::ios::binary);
auto loaded = gst::grove<gdt::interval, std::string>::deserialize(in);
// loaded is a fully functional grove with all data restored
}
return 0;
}
Always open streams with std::ios::binary to avoid platform-specific newline translation.
grove::serialize() (and the supporting grove_to_sif() and node::serialize()) are
const-qualified, so consumers holding a const grove& — for example a read-only post-build
query layer — can serialize without a const_cast:
void persist(const gst::grove<gdt::interval, std::string>& g, std::ostream& os) {
g.serialize(os); // OK — serialize() is const
}
How It Works
The grove serializes its complete B+ tree structure using zlib compression. The output is a compressed binary stream (not raw bytes), so files are compact but not directly inspectable with hex editors. The stream opens with a small plain directory (order, index count, per-index name + root block id, block counts), then the payload.
Block-structured payload (format 0.2). As of genogrove v0.25.0 the payload is
block-structured: each B+ tree node is an independently zlib-compressed, length-prefixed block,
and external keys are distributed into fixed-size blocks. (Previously the whole file was a single
zlib stream — format 0.1.) The block structure is what makes random-access partial reading possible
— see Partial random-access reading below. Because each
block is inflated from an isolated buffer of exactly its length, the source is read sequentially,
so grove::deserialize now works on non-seekable input streams (pipes, sockets) and leaves any
trailing bytes after the grove intact.
The public signatures (serialize(std::ostream&) / deserialize(std::istream&)) are unchanged.
Warning
No serialization back-compat. Format 0.1 .gg files are not readable by a v0.25.0+ build
and must be regenerated (re-run genogrove idx or re-serialize()). The header reports
format_major = 0, format_minor = 2; while format_major == 0 the format is still evolving and
may break again. See The .gg File Header.
Within a block the data is still written in depth-first order:
Tree order and number of indices (chromosomes)
For each index: the index name followed by the full tree (nodes, keys, and associated data)
External key storage
Graph overlay edges
All built-in key types (interval, genomic_coordinate, numeric, kmer) and common data types
(std::string, trivially copyable types like int, double, uint32_t) are serialized automatically.
Combined Persistence with Registry
When using registry to store shared metadata (e.g., sample names referenced by ID in the grove),
serialize the registry before the grove and deserialize in the same order:
#include <genogrove/data_type/registry.hpp>
#include <fstream>
namespace gdt = genogrove::data_type;
namespace gst = genogrove::structure;
int main() {
auto& reg = gdt::registry<std::string>::instance();
gst::grove<gdt::interval, uint32_t> my_grove(100);
// Intern shared metadata, store IDs in the grove
auto id1 = reg.intern("SampleA_liver");
auto id2 = reg.intern("SampleB_brain");
my_grove.insert_data("chr1", gdt::interval{100, 200}, id1);
my_grove.insert_data("chr1", gdt::interval{150, 250}, id2);
// Save: registry first, then grove
{
std::ofstream out("data.bin", std::ios::binary);
reg.serialize(out);
my_grove.serialize(out);
}
// Load: same order
reg.clear();
{
std::ifstream in("data.bin", std::ios::binary);
auto& restored = gdt::registry<std::string>::deserialize(in);
auto loaded = gst::grove<gdt::interval, uint32_t>::deserialize(in);
// Registry IDs in the grove still resolve correctly
auto results = loaded.intersect(gdt::interval{100, 200}, "chr1");
for (auto* key : results.get_keys()) {
const auto& name = restored.get(key->get_data()); // "SampleA_liver"
}
}
return 0;
}
Custom Key Type Serialization
If you use a custom key type with the grove, it must implement a serialize member method and a
static deserialize factory method:
struct CustomInterval {
size_t start;
size_t end;
void serialize(std::ostream& os) const {
os.write(reinterpret_cast<const char*>(&start), sizeof(start));
os.write(reinterpret_cast<const char*>(&end), sizeof(end));
}
[[nodiscard]] static CustomInterval deserialize(std::istream& is) {
CustomInterval ci;
is.read(reinterpret_cast<char*>(&ci.start), sizeof(ci.start));
is.read(reinterpret_cast<char*>(&ci.end), sizeof(ci.end));
if (!is) throw std::runtime_error("Failed to deserialize CustomInterval");
return ci;
}
// ... other key_type_base requirements (operator<, overlap, aggregate, etc.)
};
Custom Data Type Serialization
For custom data types stored as associated data in keys, you have two options:
Option 1: Member methods
Add serialize and deserialize methods directly to your type:
struct Annotation {
std::string name;
double score;
void serialize(std::ostream& os) const {
genogrove::data_type::serializer<std::string>::write(os, name);
os.write(reinterpret_cast<const char*>(&score), sizeof(score));
}
[[nodiscard]] static Annotation deserialize(std::istream& is) {
auto name = genogrove::data_type::serializer<std::string>::read(is);
double score;
is.read(reinterpret_cast<char*>(&score), sizeof(score));
if (!is) throw std::runtime_error("Failed to deserialize Annotation");
return {std::move(name), score};
}
};
Option 2: Specialize serialization_traits
For third-party types you cannot modify, specialize serialization_traits:
#include <genogrove/data_type/serialization_traits.hpp>
template<>
struct genogrove::data_type::serialization_traits<ThirdPartyType> {
static void serialize(std::ostream& os, const ThirdPartyType& value) {
// write fields to os
}
static ThirdPartyType deserialize(std::istream& is) {
// read fields from is and return constructed object
}
};
What works automatically
Trivially copyable types (
int,double,uint32_t, etc.) — serialized viamemcpystd::string— built-in specialization (length-prefixed)Built-in key types (
interval,genomic_coordinate,numeric,kmer) — member methods providedgio::bed_entry(with its nestedgio::block_info) — memberserialize/deserializeprovided, so agrove<gdt::interval, gio::bed_entry>can be persisted directly. The.ggfiles produced by theidxCLI subcommand are this form.gio::rgb_colorandgio::thick_infoare trivially copyable and serialize automatically.
Non-Seekable Sources and Concatenated Payloads
As of format 0.2, grove::deserialize() reads each block from an isolated buffer of exactly its
length, consuming the source sequentially. It no longer needs to rewind, so it works on
non-seekable sources (pipes, sockets, custom streambufs) and leaves any bytes that follow the
grove intact — the concatenated-payload pattern (registry then grove from the same stream, multiple
grove payloads back-to-back, sentinel trailers) works without a seekable source.
Note
Random-access partial reading via grove_view still needs a
seekable source — it seeks to individual block offsets — which is why grove_view::open takes a
file path rather than an arbitrary stream. Eager grove::deserialize has no such requirement.
Partial random-access reading
genogrove::structure::grove_view is a read-only, partial reader over a serialized format 0.2
.gg. Where grove::deserialize eagerly loads the whole file, grove_view loads only the blocks a
query walks and caches them for its lifetime. It complements — does not replace — the eager grove,
which remains the builder and the load-it-all reader.
#include <genogrove/structure/grove/grove_view.hpp>
namespace gst = genogrove::structure;
// Open a .gg for partial reading (seekable source required — takes a path).
auto view = gst::grove_view<gdt::interval, gio::bed_entry, std::string>::open("index.gg");
// Same intersect() signatures/semantics as grove — loads only the O(log n)
// descent path plus overlapping leaves.
auto hits = view.intersect(gdt::interval{100, 200}, "chr1");
// Outgoing graph neighbours; loads only the edge-target block, across chromosomes.
for (auto* k : hits.get_keys()) {
for (auto* nbr : view.get_neighbors(k)) { /* ... */ }
}
Public surface (grove_view<key_type, data_type = void, edge_data_type = void>):
static grove_view open(const std::string& path, std::streamoff data_offset = 0)— opens a.gg, reads the directory, and scans the block chain to build ablock_id → file offsetindex. Passdata_offsetto start past a leading wrapper (the CLI passesgg_header::SIZEto skip the 12-byte header). Throwsstd::runtime_erroron a missing file, bad magic, a non-seekable/truncated stream, or a malformed directory.intersect(const key_type& query, std::string_view index)andintersect(const key_type& query)— same signatures and semantics asgrove::intersect(they share the query engine).flanking(const key_type& query, std::string_view index)and the predicate overloadflanking(const key_type& query, std::string_view index, Pred is_compatible)— nearest non-overlapping predecessor/successor, same semantics asgrove::flanking(the interval-nesting predecessor rule and smallest-start successor rule are preserved; either field may be null, both null if the index is absent). The predicate overload’sis_compatibleis applied at leaf candidates only — internal-node pruning stays purely structural (aggregate ranges) and never consults the predicate, matchinggrove::flanking. Because flanking can branch into both sides of the query, it may page in more blocks than a single-pathintersect.get_neighbors(const key* source)— outgoing graph neighbours, loading only the target block (including across chromosomes).sourcemust be a key pointer thisgrove_viewproduced.blocks_loaded()/block_count()— introspection (e.g. to assert a query really was partial).
Semantics worth calling out:
Non-copyable and non-movable — it owns the file and a zlib state. Hold it by value from
open(); the return is guaranteed copy elision, so no move is needed.The block cache never evicts — memory grows with the set of blocks touched, bounded by the query footprint.
Not thread-safe — a query pages in blocks on demand, so use one view per thread.
Requires a plain format-0.2
.ggwritten bygrove::serialize.
The CLI’s isec --in-place is built on grove_view — see the CLI reference.
CLI indexes carry edge metadata
The .gg indexes written by the idx / isec CLI use grove<interval, {bed,gff}_entry, std::string>
— a std::string graph-edge type — so that idx --links can attach per-edge metadata.
This changed the on-disk format from the earlier void edge type: CLI-built indexes from before
v0.25.0 must be regenerated. (Consistent with the format-0.2 no-back-compat policy above.)
Important Notes
grove::deserialize()returns a grove by value. Becausegroveis a move-only type (copy is deleted), the return relies on Named Return Value Optimization (NRVO) or implicit move. No special handling is needed—just assign the result to a local variable as shown in the examples above.All
deserializemethods (node::deserialize,grove::deserialize,registry::deserialize,serialization_traits<std::string>::deserialize) throwstd::runtime_erroron corrupt or truncated streams.node::deserializeadditionally validates B+ tree invariants (num_keys < order, num_children <= order).registry::deserializeprovides a strong exception guarantee: if the stream throws or is truncated, the singleton is left exactly as it was before the call. The new state is built into local containers and committed via noexcept move-assign only after the read loop completes. It also rejects header counts that exceed theid_typecapacity ("Failed to deserialize registry: entry count exceeds id_type capacity") and streams containing duplicate keys ("Failed to deserialize registry: duplicate key"). See Registry for details.Graph edges added via
add_edge()orlink_if()are now persisted during serialization and restored on deserialize.Breaking format change: The serialized format now includes graph edges after external keys. Files serialized with older versions are incompatible and must be re-created.
All
deserializemethods are marked[[nodiscard]]to prevent accidentally discarding the result.
The .gg File Header
Every .gg file produced by the idx CLI subcommand begins with a 12-byte plain (uncompressed)
header written before the zlib-compressed grove payload. Because it is uncompressed, a .gg file
can be identified and validated without decompressing first (e.g. with xxd or file). The
intersect -i (isec) subcommand validates this header before deserializing.
The on-disk layout is:
Offset |
Size |
Field |
Notes |
|---|---|---|---|
0 |
4 |
|
inspectable via |
4 |
1 |
|
pre-1.0; any change may break compatibility |
5 |
1 |
|
0.2 = block-structured payload (was 0.1, single zlib stream) |
6 |
1 |
|
informational |
7 |
1 |
|
informational |
8 |
1 |
|
informational |
9 |
1 |
|
|
10 |
2 |
|
zero |
The API lives in genogrove::io:
#include <genogrove/io/gg_format.hpp>
namespace gio = genogrove::io;
enum class gg_payload_type : uint8_t { BED = 0x01, GFF = 0x02 };
struct gg_header {
[[nodiscard]] static gg_header current(gg_payload_type payload_type); // writer-side factory
void write(std::ostream& os) const; // write the 12 bytes
[[nodiscard]] static gg_header read(std::istream& is); // read + validate
};
gg_header::current(payload_type)builds a header stamped with the current library version and the given payload type — use it on the writer side.write(std::ostream&)writes the 12-byte header.gg_header::read(std::istream&)reads and validates the header. It throwsstd::runtime_erroron a bad magic value, an unsupported(format_major, format_minor)pair, or an unknownpayload_type. Library-version differences (lib_major/lib_minor/lib_patch) are informational only and never cause rejection.
Warning
No backwards compatibility. Pre-existing .gg files written before this header was added no
longer parse and must be re-indexed. This is consistent with the project’s
no-backwards-compatibility-for-serialization policy.
SIF Export (visualization)
The grove can be exported to SIF (Simple Interaction Format) text for visualization in tools such as Cytoscape. Two overloads are available:
// Whole-grove export: walks the grove's own roots, no node pointer needed.
void grove_to_sif(std::ostream& os) const;
// Per-tree primitive: visualize a single tree given its root node.
void grove_to_sif(std::ostream& os, const node<key_type, data_type>* root) const;
The node-less overload exports the entire grove (all indexed trees) in one call. An empty grove
produces no output. Both overloads are stream-only by design (mirroring serialize() /
deserialize()) — there is intentionally no to_sif(path) overload, so callers wrap their own
std::ofstream:
#include <fstream>
std::ofstream out("grove.sif");
my_grove.grove_to_sif(out); // writes every indexed tree
The per-tree overload (grove_to_sif(os, root)) remains available for visualizing a single tree.
The output is tab-separated and uses three interaction types: nodelink (internal node → child),
leaflink (leaf → next leaf), and keylink (key → graph-overlay neighbour).
Warning
Index iteration order is not stable across runs (hash-map iteration) — treat the output as a set of interactions rather than an ordered list.
Serialization
Groves persist to a zlib-compressed .gg binary.
grove.serialize(path)— write the grove (keys + payloads + graph overlay) topath. Releases the GIL.Grove.deserialize(path) -> Grove(static) — load a grove written byserialize. Releases the GIL.
g.serialize("out.gg")
reloaded = pg.Grove.deserialize("out.gg")
Note the C++ interop: an edgeless universal-Grove .gg stores its payload as JSON text, readable
by a C++ grove<genomic_coordinate, std::string>; with labelled edges the interop type is
grove<genomic_coordinate, std::string, std::string>. Typed BedGrove / GffGrove .gg files
round-trip the structured BedEntry / GffEntry payloads.
Partial random-access reading with GroveView
GroveView is a read-only, partial reader over a serialized format 0.2 .gg. Where
Grove.deserialize() loads the whole grove into memory, a GroveView reads only the block
directory up front and pages in individual blocks on demand as a query descends the tree, caching
them for the view’s lifetime (no eviction). Use it to query a large on-disk index without
materializing it. It complements — does not replace — the eager Grove, which stays the builder
and the load-it-all reader.
There is one view class per grove flavour — GroveView, NumericGroveView, KmerGroveView,
BedGroveView, GffGroveView — each reusing the Key / QueryResult types of the matching grove.
import pygenogrove as pg
# Open a .gg written by Grove.serialize() (a bare grove stream — data_offset=0).
view = pg.GroveView.open("index.gg")
# Same intersect() signatures/semantics as Grove — loads only the descent path
# plus overlapping leaves. Pass an index name, or omit it to search all indices.
hits = view.intersect(pg.GenomicCoordinate("*", 100, 200), "chr1")
# Outgoing graph neighbours; pages in each target's block on demand, across chromosomes.
for key in hits:
for nbr in view.get_neighbors(key):
...
view.get_edges(key) # edge payloads, parallel to get_neighbors
view.get_neighbors_if(key, lambda m: m is not None and m["weight"] > 5) # filtered (universal view only)
# Proof the query was partial: only a subset of blocks was paged in.
assert view.blocks_loaded() < view.block_count()
Surface (query-only — a view has no insert() or serialize()):
GroveView.open(path, data_offset=0)(static) — open a.ggfor partial reading.pathis a file written byGrove.serialize()(usedata_offset=0); pass a non-zerodata_offsetonly for a.ggembedded behind a leading header, e.g. a genogrove CLI index. RaisesRuntimeErroron a missing file, bad magic, a non-seekable source, or a malformed directory.intersect(query)/intersect(query, index)— same results as the eagerGrove, loading only the blocks the search touches.get_neighbors(key)— the target keys directly reachable fromkeyvia graph edges, paging in each target’s block on demand.keymust be one this view produced (fromintersect()or a priorget_neighbors()). RaisesTypeErrorifkeyisNone.get_edges(source) -> list— the metadata payloads ofsource’s outgoing edges, in edge order (parallel toget_neighbors(source)), read from the block already paged in forsource— no full deserialize. Edges added without a payload yieldNone; returns an empty list ifsourcehas no recorded edges. Edge-carrying views only (see below).get_neighbors_if(source, predicate) -> list[Key]— the target keys whose edge metadata satisfiespredicate(metadata), paging in each surviving target’s block on demand exactly likeget_neighbors. The predicate receives the decoded payload — which isNonefor an edge added without one, so guard for it when mixing labelled and unlabelled edges. RaisesTypeErrorifsourceisNone. Edge-carrying views only (see below).blocks_loaded()/block_count()— partial-load counters (block_count()is0for an empty grove).
get_edges / get_neighbors_if exist only on the universal GroveView (and its point-key
siblings NumericGroveView / KmerGroveView), whose edges carry a payload. The typed
BedGroveView / GffGroveView keep unlabelled (void) edges for binary interop, so — mirroring the
mutable BedGrove / GffGrove — the two labelled-edge reads are absent there; use get_neighbors
to traverse. These accessors match the mutable Grove’s adjacency surface (see the
graph guide), but query-only.
Warning
Not thread-safe. A query mutates the view’s block cache and holds the GIL, so concurrent Python
threads serialize on view I/O — use one view per thread. The Keys a view returns point into its
own storage and are valid only while the GroveView is alive.
Requires a format 0.2 .gg (genogrove v0.25.x). See the C++ tab for the underlying grove_view,
and the Python API reference for the full class listing.
SIF export (visualization)
grove.to_sif(path) writes the grove to a SIF (Simple Interaction Format) text file for
Cytoscape — available on every grove. Tab-separated interactions: nodelink (node → child),
leaflink (leaf → next leaf), keylink (key → graph-overlay neighbour). An empty grove writes an
empty file. Releases the GIL.
Warning
Line and index order are not stable across runs (hash-map iteration) — treat the output as a set of interactions.
g.to_sif("graph.sif")