File size: 8,899 Bytes
02de126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""Directed graph stored as out-edge and in-edge CSR structures.

Web graphs are fundamentally directed and the in/out asymmetry is the key signal for tasks like
spam detection (see ``docs/brainstorming/edge-direction.md``). We therefore keep two CSR views and
never symmetrize implicitly:

* **out-CSR** (``out_indptr``, ``out_indices``): successors of each node (who it links to).
* **in-CSR**  (``in_indptr``,  ``in_indices``):  predecessors of each node (who links to it).

The COO ``edge_index`` of shape ``(2, num_edges)`` (rows: ``src``, ``dst``) is the canonical edge
list. Arrays are plain NumPy so they save/load with ``np.savez`` and can be memory-mapped at scale.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from numpy.typing import NDArray


def _build_csr(
    rows: NDArray[np.int64],
    cols: NDArray[np.int64],
    num_nodes: int,
    index_dtype: np.dtype = np.int64,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
    """Build a CSR ``(indptr, indices)`` grouping ``cols`` by ``rows``.

    Args:
        rows: Source endpoints (the CSR row of each entry).
        cols: Destination endpoints (the value stored in each row).
        num_nodes: Total number of nodes (CSR has ``num_nodes + 1`` indptr entries).
        index_dtype: dtype of the returned ``indices`` (the node-id column). ``int32`` halves the
            footprint when node ids fit (``num_nodes < 2**31``) — worth it at 10^10-edge scale.
            ``indptr`` is always ``int64`` (it accumulates to ``num_edges``, which can exceed 2^31).

    Returns:
        A tuple ``(indptr, indices)``.
    """
    order = np.argsort(rows, kind="stable")
    sorted_rows = rows[order]
    sorted_cols = cols[order]
    counts = np.bincount(sorted_rows, minlength=num_nodes)
    indptr = np.zeros(num_nodes + 1, dtype=np.int64)
    np.cumsum(counts, out=indptr[1:])
    return indptr, sorted_cols.astype(index_dtype, copy=False)


@dataclass
class DirectedGraph:
    """An immutable directed graph with cached out/in CSR adjacency.

    Attributes:
        num_nodes: Number of nodes.
        edge_index: COO edges of shape ``(2, num_edges)`` (row 0 = src, row 1 = dst).
        out_indptr: Out-CSR row pointers, shape ``(num_nodes + 1,)``.
        out_indices: Out-CSR successors.
        in_indptr: In-CSR row pointers, shape ``(num_nodes + 1,)``.
        in_indices: In-CSR predecessors.
        node_names: Optional per-node string identifiers (e.g. reversed hostnames).
        features: Optional per-node feature matrix, shape ``(num_nodes, feature_dim)`` (e.g.
            ogbn-arxiv's word2vec vectors). Persisted as ``node_features.npy``; ``None`` for
            structure-only graphs (the CC web graph has no such features).
    """

    num_nodes: int
    edge_index: NDArray[np.int64]
    out_indptr: NDArray[np.int64]
    out_indices: NDArray[np.int64]
    in_indptr: NDArray[np.int64]
    in_indices: NDArray[np.int64]
    node_names: list[str] | None = None
    features: NDArray[np.float32] | None = None

    @property
    def num_edges(self) -> int:
        """Number of directed edges."""
        return int(self.edge_index.shape[1])

    @classmethod
    def from_edges(
        cls,
        edge_index: NDArray[np.int64],
        num_nodes: int | None = None,
        node_names: list[str] | None = None,
    ) -> DirectedGraph:
        """Construct a graph from a COO edge list, building both CSR views.

        Args:
            edge_index: Array of shape ``(2, num_edges)`` with src in row 0 and dst in row 1.
            num_nodes: Node count; inferred from the max id + 1 when omitted.
            node_names: Optional per-node identifiers.

        Returns:
            A new :class:`DirectedGraph`.
        """
        edge_index = np.ascontiguousarray(edge_index, dtype=np.int64)
        if edge_index.ndim != 2 or edge_index.shape[0] != 2:
            msg = f"edge_index must have shape (2, E), got {edge_index.shape}"
            raise ValueError(msg)
        src, dst = edge_index[0], edge_index[1]
        if num_nodes is None:
            num_nodes = int(edge_index.max()) + 1 if edge_index.size else 0
        out_indptr, out_indices = _build_csr(src, dst, num_nodes)
        in_indptr, in_indices = _build_csr(dst, src, num_nodes)
        return cls(
            num_nodes=num_nodes,
            edge_index=edge_index,
            out_indptr=out_indptr,
            out_indices=out_indices,
            in_indptr=in_indptr,
            in_indices=in_indices,
            node_names=node_names,
        )

    def out_neighbors(self, node: int) -> NDArray[np.int64]:
        """Return the successors of ``node`` (nodes it links to)."""
        return self.out_indices[self.out_indptr[node] : self.out_indptr[node + 1]]

    def in_neighbors(self, node: int) -> NDArray[np.int64]:
        """Return the predecessors of ``node`` (nodes that link to it)."""
        return self.in_indices[self.in_indptr[node] : self.in_indptr[node + 1]]

    def out_degree(self) -> NDArray[np.int64]:
        """Return the out-degree of every node."""
        return np.diff(self.out_indptr)

    def in_degree(self) -> NDArray[np.int64]:
        """Return the in-degree of every node."""
        return np.diff(self.in_indptr)

    def to_undirected(self) -> DirectedGraph:
        """Return a symmetrized copy with both ``(u, v)`` and ``(v, u)`` for every edge.

        Used only to build the *undirected baseline* for direction ablations — it deliberately
        discards the in/out asymmetry that the directed models exploit, so it should never be used
        for the primary directed experiments.

        Returns:
            A new :class:`DirectedGraph` over the same nodes with reciprocated, de-duplicated edges.
        """
        both = np.concatenate([self.edge_index, self.edge_index[::-1]], axis=1)
        undirected = np.unique(both, axis=1) if both.size else both
        return DirectedGraph.from_edges(
            undirected, num_nodes=self.num_nodes, node_names=self.node_names
        )

    def subgraph_from_edge_mask(self, mask: NDArray[np.bool_]) -> DirectedGraph:
        """Return a graph over the same node set keeping only the masked edges.

        Used to build the *message* graph for link prediction (a structural view that excludes
        held-out evaluation edges).

        Args:
            mask: Boolean mask over edges of length ``num_edges``.

        Returns:
            A new :class:`DirectedGraph` with the same ``num_nodes`` and node names.
        """
        return DirectedGraph.from_edges(
            self.edge_index[:, mask], num_nodes=self.num_nodes, node_names=self.node_names
        )

    def save(self, directory: str | Path) -> None:
        """Persist the graph to ``directory`` (``graph.npz`` + ``meta.json``).

        Args:
            directory: Output directory (created if needed).
        """
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)
        np.savez(
            directory / "graph.npz",
            edge_index=self.edge_index,
            out_indptr=self.out_indptr,
            out_indices=self.out_indices,
            in_indptr=self.in_indptr,
            in_indices=self.in_indices,
        )
        meta = {"num_nodes": self.num_nodes, "num_edges": self.num_edges}
        (directory / "meta.json").write_text(json.dumps(meta, indent=2))
        if self.node_names is not None:
            (directory / "node_names.txt").write_text("\n".join(self.node_names))
        if self.features is not None:
            np.save(directory / "node_features.npy", self.features)

    @classmethod
    def load(cls, directory: str | Path, mmap: bool = False) -> DirectedGraph:
        """Load a graph previously written by :meth:`save`.

        Args:
            directory: Directory containing ``graph.npz`` / ``meta.json``.
            mmap: If True, memory-map the arrays (recommended for large graphs).

        Returns:
            The loaded :class:`DirectedGraph`.
        """
        directory = Path(directory)
        data = np.load(directory / "graph.npz", mmap_mode="r" if mmap else None)
        meta = json.loads((directory / "meta.json").read_text())
        names_path = directory / "node_names.txt"
        node_names = names_path.read_text().splitlines() if names_path.exists() else None
        features_path = directory / "node_features.npy"
        features = np.load(features_path) if features_path.exists() else None
        return cls(
            num_nodes=int(meta["num_nodes"]),
            edge_index=data["edge_index"],
            out_indptr=data["out_indptr"],
            out_indices=data["out_indices"],
            in_indptr=data["in_indptr"],
            in_indices=data["in_indices"],
            node_names=node_names,
            features=features,
        )