Hypergraphs and hyperedge ids#
A hypergraph (dyng::hypergraph, dyng.Hypergraph) holds hyperedges: sets of vertices.
Like a graph it changes by batches, but a batch of a hypergraph deletes and inserts whole
hyperedges, and the ids of the inserted hyperedges are chosen by the store. This page explains
those ids, the order of a batch, how the storage is sized, the limits of the index types and the
materialized line graph. The exact contracts are on Hypergraphs; the decisions
behind them are ADRs ADR 0041: The hypergraph container on the ESCHER store,
ADR 0042: The sorted_merge line graph, the row views and the line-graph oracle and
ADR 0047: The hypergraph API review and freeze (0.3).
Ids are 0-based, and -1 means none#
Vertex ids lie in [0, num_vertices()); hyperedge ids are int64 and lie in
[0, hyperedge_id_bound()). Hyperedge i of from_hyperedges() (or of from_lists() in Python)
gets id i. A deleted hyperedge leaves a dead id: its row in h2v() is empty, and it is
nobody’s neighbour in the line graph. Wherever an id may be absent, -1 stands for “none” (as
for the parents of sssp). The store keeps its own keys internally (ESCHER’s trees count from 1
and use sentinels in their payload); none of that appears in the API.
The ids of a batch come from the store#
A batch (hyperedge_batch, dyng.HyperedgeBatch) lists ids to delete and member lists to insert.
apply() (or dyng::update()) returns a hyper_apply_summary whose inserted_ids holds the id
of every inserted hyperedge, in batch order. Read the ids there; never predict them. The
ESCHER store reuses the slots of deleted hyperedges by best fit (ESCHER+, TKDE’26): an
inserted hyperedge goes into a dead slot whose capacity fits it, the smallest that does (equal
capacities in id order), and takes that slot’s id. When some inserted hyperedges fit no dead slot
(they are surplus), the batch drops every dead slot it did not reuse and gives the surplus
hyperedges the ids after the last live id, in batch order. That is the surplus drop rule: it
is why hyperedge_id_bound() can go down after a batch (dead ids above the last live id
disappear), why a dead id below the bound may never be reused, and why an id that was dead before
a batch can be given out again.
Repeated members of an inserted hyperedge are dropped (dropped_members), and deletions of dead,
out-of-range or repeated ids are skipped (ignored_deletions); skipped_ops counts every
skipped operation, in 0.3 their sum (the incidence edits of 0.4 add the edits they skip).
id_reuse: the order of a batch#
Whether a slot freed by a batch can be reused by the same batch depends on the order in which the batch’s deletions and insertions reach the store, a property of the hypergraph:
id_reuse::erase_first(the default; the ESCHER paper’s order): deletions first, so an insertion can take the slot of a hyperedge the same batch deleted;id_reuse::insert_first(H-SOSP’s order, MOSP_ESCHER): insertions first, so only slots that were already dead before the batch can be reused.
A worked example. Three hyperedges {0, 1, 2, 3} (id 0), {4} (id 1) and {5, 6} (id 2)
under capacity_policy::escher_gpu(); at construction a row of k members takes a slot of
k + 1 entries rounded up to a multiple of 4 (always; row_alignment does not set it), so id 0
has a slot of 8 and ids 1 and 2 slots of 4 (room for 3 members). A surplus row inserted later by
a batch reserves its members rounded up to a multiple of row_alignment, plus one slot. One batch deletes id 1 and inserts {7, 8}:
|
what the store sees |
the id of |
|---|---|---|
|
id 1 is deleted first; its dead slot (room for 3) fits |
1 |
|
|
3 |
import dyng
for reuse in ("erase_first", "insert_first"):
h = dyng.Hypergraph.from_lists(
[[0, 1, 2, 3], [4], [5, 6]], num_vertices=9, id_reuse=reuse, capacity="escher_gpu"
)
s = h.apply(dyng.HyperedgeBatch(delete_hyperedges=[1], insert_hyperedges=[[7, 8]]))
print(reuse, s.inserted_ids.tolist())
erase_first [1]
insert_first [3]
The default is erase_first (the author’s decision O16, the plan’s default); the parity runs
against H-SOSP set insert_first. Results that depend on ids (shortest hyperpaths) are compared
across the two orders by content (the sorted member set and the weight), not by id.
Capacity presets#
The store keeps each incidence relation (h2v, v2h, and the line graph) in a payload array that is
sized when it is built and grows when it is full. capacity_policy says how:
preset |
start |
when full |
|---|---|---|
default |
|
compact first (MOSP_ESCHER’s trigger points), then grow by 1.5x, with headroom after a compaction |
|
ESCHER-GPU@abcf9b2: |
grow by its rule up to 2^31 - 1, never compact |
|
MOSP_ESCHER@4b86159: |
compact at its trigger points, never grow: |
The presets reproduce the originals’ layouts and memory exactly, which is what the parity tests
and the memory gate need; the default is the policy to use otherwise. A policy decides memory,
speed and where capacity_error can occur; the rows of the live hyperedges are the same under
every policy.
Index limits: int32 offsets and capacity_error#
The ESCHER store addresses its payload with int32 offsets, so one relation holds at most
2^31 - 1 entries (max_payload = 0 means exactly that limit; mosp_escher() stops at 2.0e9).
An apply() that would need more raises capacity_error, and so does the line graph when it
outgrows the limit. An error inside a store operation leaves the hypergraph poisoned: every
later call that reads or changes its storage (the views, weights(), apply(),
to_hyperedge_list(), clone(), check_integrity()) throws until it is destroyed or assigned,
and the noexcept accessors (num_hyperedges(), version(), store_stats(), …) return
unspecified values, because a half-applied batch cannot be undone cheaply on the device. Errors found before the store changes (an out-of-range vertex, an
empty hyperedge, a bad weight, an incidence edit before 0.4, a batch of more than INT_MAX / 3
inserted members, host memory running out while the batch is checked) change nothing.
On CUDA a hypergraph’s storage works on the stream of the resources it was built with.
apply() (and dyng::update()) with resources on another stream of the same device is ordered
after the work already queued on that stream and before the work queued there later, so the
views of G_t may be read on either stream until the call.
The public offsets (incidence_view::row_ptr, the batch’s insert_offsets) are int64
regardless, so the API does not change when a later storage lifts the limit.
Vertex ids: sizing the vertex type#
Hypergraphs are instantiated with int32 vertex ids (with or without int32 weights). Vertex ids
must lie below 2^31 - 1. A .hg file may name larger ids: read it with
relabel = dense_sorted or first_appearance (io::hypergraph_read_options), which numbers the
distinct ids 0, 1, … and returns the original ids in hypergraph_file_info::external_ids. In
Python, member arrays of NumPy’s default int64 type are refused with NotSupportedError unless
vertex_dtype="int32" is given, under which every value is range-checked: ids are never
narrowed silently.
The materialized line graph#
The line graph (h2h) joins two hyperedges when they share a vertex. dynG keeps it materialized: one sorted row per hyperedge id, maintained batch by batch, because the algorithms on hypergraphs (triad counting, shortest hyperpaths) walk it over and over.
line_graph_policy::sorted_mergebuilds it with the hypergraph and keeps it with every batch (ESCHER-GPU’s out-of-place sorted delta merge on CUDA, a host form with the same layout on the CPU backends);automatic(the default) builds it the first time something reads it (h2h(), or an algorithm that needs it), then keeps it;nonenever builds it:h2h()throws;slack_csr(H-SOSP’s slack rows) is planned for 0.4.
Each row of h2h() is sorted and contiguous, and out[r] is the offset of its first neighbour
with an id greater than r, so a loop over col_ind[out[r] .. row_ptr[r + 1]) visits each pair
of overlapping hyperedges once, from its smaller id. The line graph needs the vertex-to-hyperedge
rows (store_v2h = true), and it costs memory: on coauth-DBLP its payload is about 258 million
entries (1 GB), about half of the container’s device memory. dyng::testing::rebuild_line_graph recomputes it
from scratch, the oracle the tests compare the maintained one with.
The views (h2v(), v2h(), h2h()) are built from the store on first use per version (each
carries the version it describes) and are invalidated by the next apply(); apply() itself
never builds them. In Python they are host copies.