WebGraph compression in Python
Compresses web graphs using locality, copying and gap encoding of adjacency lists.
- File
webgraph_compression.py, 149 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/webgraph-compression/. The authors are listed in prov/763NVN.jsonl.
"""A simplified Boldi-Vigna (WebGraph) compressor for adjacency lists.
Each list is coded with a reference to one of the previous `window` lists,
copy blocks, intervals of consecutive successors, and gap-coded residuals.
Every integer is written with the Elias gamma code (WebGraph itself uses
zeta codes for residuals). The output is a string of '0' and '1'.
"""
from typing import Sequence
def gamma(k: int) -> str:
"""Elias gamma code of k + 1, for k >= 0."""
b = bin(k + 1)[2:]
return "0" * (len(b) - 1) + b
def nu(x: int) -> int:
"""Map an integer to a natural number: 0, -1, 1, -2, ... to 0, 1, 2, 3, ..."""
return 2 * x if x >= 0 else -2 * x - 1
def nu_inv(k: int) -> int:
return k // 2 if k % 2 == 0 else -(k + 1) // 2
class Reader:
def __init__(self, bits: str) -> None:
self.bits, self.pos = bits, 0
def gamma(self) -> int:
z = 0
while self.bits[self.pos] == "0":
z += 1
self.pos += 1
v = int(self.bits[self.pos:self.pos + z + 1], 2)
self.pos += z + 1
return v - 1
def copy_blocks(ref: Sequence[int], succ: set[int]) -> list[int]:
"""Run lengths of the copy mask of ref, starting with a copy run (which
may be empty), without the last run, which the decoder infers."""
runs, cur, length = [], 1, 0
for y in ref:
bit = 1 if y in succ else 0
if bit == cur:
length += 1
else:
runs.append(length)
cur, length = bit, 1
return runs
def split_intervals(extra: Sequence[int], lmin: int) -> tuple[list[tuple[int, int]], list[int]]:
"""Maximal runs of consecutive integers of length >= lmin, and the rest."""
intervals, residuals, i = [], [], 0
while i < len(extra):
j = i
while j + 1 < len(extra) and extra[j + 1] == extra[j] + 1:
j += 1
if j - i + 1 >= lmin:
intervals.append((extra[i], j - i + 1))
else:
residuals.extend(extra[i:j + 1])
i = j + 1
return intervals, residuals
def encode(adj: Sequence[Sequence[int]], window: int = 7, lmin: int = 3) -> str:
"""Compress the sorted, duplicate-free successor lists adj[0..n-1].
Time O(n * window * d) for maximum degree d, with set lookups.
"""
out = [gamma(len(adj))]
for x, lst in enumerate(adj):
succ = set(lst)
out.append(gamma(len(lst))) # @line L1
if not lst:
continue
best, gain = 0, 0
for r in range(1, min(window, x) + 1): # @line L2
g = sum(1 for y in adj[x - r] if y in succ)
if g > gain:
best, gain = r, g # @line L3
out.append(gamma(best)) # @line L4
copied: set[int] = set()
if best:
ref = adj[x - best]
runs = copy_blocks(ref, succ) # @line L5
out.append(gamma(len(runs)))
out.extend(gamma(b if i == 0 else b - 1) for i, b in enumerate(runs))
copied = succ & set(ref)
extra = [y for y in lst if y not in copied] # @line L6
intervals, residuals = split_intervals(extra, lmin) # @line L7
out.append(gamma(len(intervals))) # @line L8
prev_end = None
for left, length in intervals:
out.append(gamma(nu(left - x)) if prev_end is None else gamma(left - prev_end - 1))
out.append(gamma(length - lmin))
prev_end = left + length
prev = None
for y in residuals: # @line L9
out.append(gamma(nu(y - x)) if prev is None else gamma(y - prev - 1)) # @line L10
prev = y
return "".join(out) # @line L11
def decode(bits: str, lmin: int = 3) -> list[list[int]]:
"""Invert encode (the window is not needed to decode)."""
rd = Reader(bits)
n = rd.gamma()
adj: list[list[int]] = []
for x in range(n):
d = rd.gamma()
if d == 0:
adj.append([])
continue
r = rd.gamma()
copied: list[int] = []
if r:
ref = adj[x - r]
b = rd.gamma()
runs = [rd.gamma() + (1 if i else 0) for i in range(b)]
i, cur = 0, 1
for length in runs:
if cur:
copied.extend(ref[i:i + length])
i += length
cur ^= 1
if cur:
copied.extend(ref[i:])
extra: list[int] = []
prev_end = None
for _ in range(rd.gamma()):
left = x + nu_inv(rd.gamma()) if prev_end is None else prev_end + 1 + rd.gamma()
length = rd.gamma() + lmin
extra.extend(range(left, left + length))
prev_end = left + length
prev = None
for _ in range(d - len(copied) - len(extra)):
y = x + nu_inv(rd.gamma()) if prev is None else prev + 1 + rd.gamma()
extra.append(y)
prev = y
adj.append(sorted(copied + extra))
return adj
test_webgraph_compression.py 57 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/webgraph-compression/. The authors are listed in prov/763NVN.jsonl.
import random
from webgraph_compression import copy_blocks, decode, encode, gamma, nu, nu_inv, split_intervals
EXAMPLE = [
[13, 15, 16, 17, 18, 19, 23, 24, 203],
[15, 16, 17, 22, 23, 24, 315, 316, 317, 3041],
[],
[],
[16, 17, 18, 19, 20, 21, 22, 23, 24, 203, 315, 1034],
]
def test_codes() -> None:
assert [gamma(k) for k in range(4)] == ["1", "010", "011", "00100"]
assert [nu(x) for x in (0, -1, 1, -2, 2)] == [0, 1, 2, 3, 4]
assert all(nu_inv(nu(x)) == x for x in range(-50, 50))
def test_example_parts() -> None:
assert copy_blocks(EXAMPLE[0], set(EXAMPLE[1])) == [0, 1, 3, 2, 2]
assert split_intervals([22, 315, 316, 317, 3041], 3) == ([(315, 3)], [22, 3041])
def test_example_round_trip_and_size() -> None:
bits = encode(EXAMPLE)
assert decode(bits) == EXAMPLE
plain = sum(len(a) for a in EXAMPLE) * 12 # 12 bits per successor
assert len(bits) < plain
def test_edge_cases() -> None:
assert decode(encode([])) == []
assert decode(encode([[]])) == [[]]
assert decode(encode([[0]])) == [[0]]
same = [[1, 2, 5, 9]] * 4
assert decode(encode(same)) == same
assert decode(encode(same, window=0)) == same
def test_random_round_trip() -> None:
rng = random.Random(1999)
for _ in range(600):
n = rng.randrange(0, 15)
adj = []
for x in range(n):
base = rng.choice([x, rng.randrange(n)])
lst = {max(0, base + rng.randrange(-3, 6)) for _ in range(rng.randrange(0, 8))}
if adj and rng.random() < 0.5:
lst |= set(rng.sample(adj[-1], len(adj[-1]) // 2))
adj.append(sorted(y % max(n, 1) for y in lst))
adj[-1] = sorted(set(adj[-1]))
w, lm = rng.randrange(0, 4), rng.randrange(1, 4)
assert decode(encode(adj, window=w, lmin=lm), lmin=lm) == adj
What the next level needs
- the tests did not run in this build