malteos commited on
Commit
02de126
·
unverified ·
0 Parent(s):
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Web Graph kNN Spot-check
3
+ emoji: 🔎
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: 6.26.0
8
+ python_version: 3.12.12
9
+ app_file: app.py
10
+ pinned: false
11
+ short_description: Nearest neighbours of a host in the CC host-graph embedding
12
+ ---
13
+ # Web graph kNN spot-check
14
+
15
+ Type a hostname, get its nearest neighbours in the learned 128-d Common Crawl host-graph
16
+ embedding. Companion to the 2D viewer: that one shows *where* hosts land after a UMAP
17
+ projection, this one answers *who is actually nearest* in the real space.
18
+
19
+ ## Running locally
20
+
21
+ ```bash
22
+ uv sync --extra faiss --extra viewer
23
+ wgl knn build --hf-repo commoncrawl/web-graph-embeddings --shards 8 --row-groups 1 \
24
+ --out data/knn/cc_deg8_poc
25
+ WGL_KNN_INDEX=data/knn/cc_deg8_poc uv run python knn-viewer/app.py
26
+ ```
27
+
28
+ ## Running as a Space
29
+
30
+ The app reads its index from `$WGL_KNN_INDEX`, defaulting to `/data` — the mount path for a
31
+ HuggingFace volume. Point a **private** bucket at it:
32
+
33
+ ```bash
34
+ hf buckets create commoncrawl/web-graph-knn-index --private
35
+ hf sync data/knn/cc_deg8_full hf://buckets/commoncrawl/web-graph-knn-index/cc_deg8 --delete
36
+ hf spaces volumes set commoncrawl/web-graph-knn \
37
+ -v hf://buckets/commoncrawl/web-graph-knn-index:/data
38
+ ```
39
+
40
+ `app.py` needs `wgl.knn.index`, which pulls only numpy, faiss and tldextract, so `wgl` is
41
+ **vendored** next to `app.py` on the Space rather than installed. Only the *import closure* of
42
+ `app.py` ships — 13 modules of 100, so the Space holds the code it runs and not the training
43
+ tree. `KnnIndex` stays a `wgl` module rather than being copied into the app on purpose: it reads
44
+ the artifact format `wgl.knn.build` writes, `wgl knn query` reads the same one, and
45
+ `tests/test_knn_index.py` round-trips build against load to keep them honest. Deploying does the
46
+ vendoring for you:
47
+
48
+ ```bash
49
+ make knn-diff # which files the Space is missing or has stale (changes nothing)
50
+ make knn-deploy # upload the app + vendored src/wgl, skipping what the Space already has
51
+ make knn-serve # run it locally against $KNN_INDEX first
52
+ ```
53
+
app.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI for the kNN spot-check viewer.
2
+
3
+ Type a hostname, get its nearest neighbours in the learned 128-d embedding space. Companion to the
4
+ 2D viewer (``embedding-2d-viewer/``): that one shows *where* hosts land after a UMAP projection,
5
+ this one answers *who is actually nearest* in the real space.
6
+
7
+ The point of the tool is **walking** the space, not one lookup: click any neighbour to make it the
8
+ new query, with a back button and a breadcrumb trail. That turns "are these neighbours sensible?"
9
+ into "does the neighbourhood stay coherent two or three hops out?", which is the question that
10
+ actually catches a bad embedding.
11
+
12
+ Runs two ways, unchanged:
13
+
14
+ * **locally** — ``WGL_KNN_INDEX=data/knn/cc_deg8_poc python knn-viewer/app.py``;
15
+ * **as a HF Space** — the index arrives on a mounted volume at ``/data``, which is the default.
16
+
17
+ The index loads once at module scope, not per request: it is the whole cost of a query, and a
18
+ Space re-imports this module only on cold start.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ import gradio as gr
28
+
29
+ # Allow running straight from a repo checkout without installing the package.
30
+ _REPO_SRC = Path(__file__).resolve().parent.parent / "src"
31
+ if _REPO_SRC.is_dir() and str(_REPO_SRC) not in sys.path:
32
+ sys.path.insert(0, str(_REPO_SRC))
33
+
34
+ from wgl.knn.index import KnnIndex # noqa: E402
35
+ from wgl.release.identity import domain_key as to_domain_key # noqa: E402
36
+
37
+ INDEX_PATH = Path(os.environ.get("WGL_KNN_INDEX", "/data"))
38
+ EXAMPLES = ["www.bundestag.de", "www.bild.de", "https://www.bahn.de/service", "portal.gov.cz"]
39
+ HEADERS = ["#", "host", "cosine", "registrable domain", "same site", "open"]
40
+ DATATYPES = ["number", "str", "number", "str", "str", "markdown"]
41
+
42
+ INDEX = KnnIndex.load(INDEX_PATH)
43
+ _META = INDEX.meta
44
+ _FOOTER = (
45
+ f"**{len(INDEX):,} hosts** · `{_META.get('release_id', 'unknown release')}` · "
46
+ f"model `{_META.get('model_space_version', 'unknown')}` · "
47
+ f"index `{_META.get('index_factory', '?')}` · dim {_META.get('dim', '?')} · "
48
+ f"built {str(_META.get('built_at', '?'))[:19]}"
49
+ )
50
+ _SUBSET_NOTE = ""
51
+ if _META.get("source_row_groups_per_shard") is not None:
52
+ _SUBSET_NOTE = (
53
+ f"\n\n⚠️ **This is a proof-of-concept subset**, not the full corpus: source rows "
54
+ f"{_META.get('source_row_min'):,} to {_META.get('source_row_max'):,} of 52,913,544. "
55
+ "Hosts outside that slice will read as *not in the corpus*."
56
+ )
57
+
58
+
59
+ # ── Common Crawl look ─────────────────────────────────────────────────────────────────────────
60
+ # Same palette as the release figures (``src/wgl/viz/svg.py``) and the 2D viewer: brand blue
61
+ # #0050bd, navy ink #152a47, cool neutral greys, Libre Franklin + IBM Plex Mono. Gradio derives a
62
+ # lot of shades from its hue ramps, so both ramps are spelled out and the tokens that matter are
63
+ # then pinned explicitly; every colour has a ``_dark`` twin because the Space follows the browser.
64
+ _CC_BLUE = gr.themes.Color(
65
+ c50="#e8f0fb",
66
+ c100="#cfe0f6",
67
+ c200="#9dc0ee",
68
+ c300="#6ba1e5",
69
+ c400="#4a90e8",
70
+ c500="#0050bd",
71
+ c600="#0047a8",
72
+ c700="#003d92",
73
+ c800="#00306f",
74
+ c900="#152a47",
75
+ c950="#0d1929",
76
+ )
77
+ _CC_SLATE = gr.themes.Color(
78
+ c50="#f6f7fa",
79
+ c100="#eef1f6",
80
+ c200="#e8ecf2",
81
+ c300="#dde2e8",
82
+ c400="#8d99b0",
83
+ c500="#6e7191",
84
+ c600="#44536f",
85
+ c700="#2a456d",
86
+ c800="#1d3557",
87
+ c900="#152a47",
88
+ c950="#0d1929",
89
+ )
90
+ THEME = gr.themes.Base(
91
+ primary_hue=_CC_BLUE,
92
+ secondary_hue=_CC_BLUE,
93
+ neutral_hue=_CC_SLATE,
94
+ font=(
95
+ gr.themes.GoogleFont("Libre Franklin"),
96
+ "system-ui",
97
+ "-apple-system",
98
+ "Segoe UI",
99
+ "sans-serif",
100
+ ),
101
+ font_mono=(
102
+ gr.themes.GoogleFont("IBM Plex Mono"),
103
+ "ui-monospace",
104
+ "SFMono-Regular",
105
+ "Menlo",
106
+ "monospace",
107
+ ),
108
+ ).set(
109
+ # page + panels: surface over a recessed plane, inverted in dark
110
+ body_background_fill="#f6f7fa",
111
+ body_background_fill_dark="#0d1929",
112
+ body_text_color="#152a47",
113
+ body_text_color_dark="#ffffff",
114
+ body_text_color_subdued="#6e7191",
115
+ body_text_color_subdued_dark="#8d99b0",
116
+ background_fill_primary="#ffffff",
117
+ background_fill_primary_dark="#152a47",
118
+ background_fill_secondary="#f6f7fa",
119
+ background_fill_secondary_dark="#0d1929",
120
+ block_background_fill="#ffffff",
121
+ block_background_fill_dark="#152a47",
122
+ block_border_color="#dde2e8",
123
+ block_border_color_dark="#2a456d",
124
+ block_label_text_color="#44536f",
125
+ block_label_text_color_dark="#c3ccdb",
126
+ block_title_text_color="#152a47",
127
+ block_title_text_color_dark="#ffffff",
128
+ border_color_primary="#dde2e8",
129
+ border_color_primary_dark="#2a456d",
130
+ border_color_accent="#0050bd",
131
+ border_color_accent_dark="#4a90e8",
132
+ code_background_fill="#f6f7fa",
133
+ code_background_fill_dark="#0d1929",
134
+ # accents: brand blue in light, the lighter dark-theme blue on navy
135
+ color_accent="#0050bd",
136
+ link_text_color="#0050bd",
137
+ link_text_color_dark="#4a90e8",
138
+ link_text_color_hover="#0047a8",
139
+ link_text_color_hover_dark="#2895f7",
140
+ link_text_color_active="#0047a8",
141
+ link_text_color_active_dark="#2895f7",
142
+ link_text_color_visited="#6631ff",
143
+ link_text_color_visited_dark="#9353ff",
144
+ loader_color="#0050bd",
145
+ loader_color_dark="#4a90e8",
146
+ slider_color="#0050bd",
147
+ slider_color_dark="#4a90e8",
148
+ # inputs
149
+ input_background_fill="#ffffff",
150
+ input_background_fill_dark="#0d1929",
151
+ input_border_color="#dde2e8",
152
+ input_border_color_dark="#2a456d",
153
+ input_border_color_focus="#0050bd",
154
+ input_border_color_focus_dark="#4a90e8",
155
+ input_placeholder_color="#6e7191",
156
+ input_placeholder_color_dark="#8d99b0",
157
+ # buttons
158
+ button_primary_background_fill="#0050bd",
159
+ button_primary_background_fill_hover="#0047a8",
160
+ button_primary_background_fill_dark="#0050bd",
161
+ button_primary_background_fill_hover_dark="#4a90e8",
162
+ button_primary_border_color="#0050bd",
163
+ button_primary_border_color_dark="#0050bd",
164
+ button_primary_text_color="#ffffff",
165
+ button_primary_text_color_dark="#ffffff",
166
+ button_secondary_background_fill="#f6f7fa",
167
+ button_secondary_background_fill_hover="#e8ecf2",
168
+ button_secondary_background_fill_dark="#152a47",
169
+ button_secondary_background_fill_hover_dark="#1d3557",
170
+ button_secondary_border_color="#dde2e8",
171
+ button_secondary_border_color_dark="#2a456d",
172
+ button_secondary_text_color="#152a47",
173
+ button_secondary_text_color_dark="#ffffff",
174
+ # the neighbour table, the one dense surface in the app
175
+ table_border_color="#dde2e8",
176
+ table_border_color_dark="#2a456d",
177
+ table_even_background_fill="#f6f7fa",
178
+ table_even_background_fill_dark="#0d1929",
179
+ table_odd_background_fill="#ffffff",
180
+ table_odd_background_fill_dark="#152a47",
181
+ table_row_focus="#e8ecf2",
182
+ table_row_focus_dark="#1d3557",
183
+ table_text_color="#152a47",
184
+ table_text_color_dark="#ffffff",
185
+ # status colours, shared by both themes
186
+ error_text_color="#d03b3b",
187
+ error_text_color_dark="#d03b3b",
188
+ )
189
+
190
+ # The few things the theme tokens do not reach: the brand rule under the title, the mono trail and
191
+ # the small print in the footer. Dark values ride on Gradio's own ``.dark`` class on the wrapper.
192
+ CSS = """
193
+ .gradio-container { max-width: 1180px !important; }
194
+ #title h1 { margin-bottom: .35rem; padding-bottom: .35rem; letter-spacing: -.01em;
195
+ border-bottom: 3px solid #0050bd; }
196
+ .dark #title h1 { border-bottom-color: #4a90e8; }
197
+ #crumb, #footer { font-size: .82rem; color: #6e7191; }
198
+ .dark #crumb, .dark #footer { color: #8d99b0; }
199
+ #crumb code, #footer code { background: transparent; padding: 0; color: #44536f; }
200
+ .dark #crumb code, .dark #footer code { color: #c3ccdb; }
201
+ """
202
+
203
+
204
+ def _table(result) -> list[list[object]]: # noqa: ANN001 - QueryResult, kept loose for gradio
205
+ """Render a query result as dataframe rows, with the free per-neighbour metadata."""
206
+ query_domain = to_domain_key(result.query, reversed_host=False)
207
+ rows: list[list[object]] = []
208
+ for neighbor in result.neighbors:
209
+ domain = to_domain_key(neighbor.host, reversed_host=False)
210
+ rows.append(
211
+ [
212
+ neighbor.rank,
213
+ neighbor.host,
214
+ round(neighbor.score, 4),
215
+ domain,
216
+ "✓" if domain and domain == query_domain else "",
217
+ f"[↗](https://{neighbor.host})",
218
+ ]
219
+ )
220
+ return rows
221
+
222
+
223
+ def _breadcrumb(trail: list[str]) -> str:
224
+ """Render the navigation trail."""
225
+ if len(trail) < 2:
226
+ return ""
227
+ shown = trail[-6:]
228
+ prefix = "… → " if len(trail) > len(shown) else ""
229
+ return "**Trail:** " + prefix + " → ".join(f"`{h}`" for h in shown)
230
+
231
+
232
+ def search(host: str, k: int, trail: list[str]) -> tuple:
233
+ """Run a lookup and return every output the UI needs.
234
+
235
+ Args:
236
+ host: Raw user input — a bare host, a domain, or a pasted URL.
237
+ k: Number of neighbours to return.
238
+ trail: The navigation history so far.
239
+
240
+ Returns:
241
+ ``(status, rows, neighbour_hosts, trail, breadcrumb, host_box)`` — the extra state lets a
242
+ row click resolve back to a hostname, and keeps the textbox in sync while walking.
243
+ """
244
+ trail = list(trail or [])
245
+ if not host or not host.strip():
246
+ return "Type a hostname to begin.", [], [], trail, _breadcrumb(trail), host
247
+
248
+ result = INDEX.query(host, k=int(k))
249
+ if not result.found:
250
+ return (
251
+ f"❌ **`{result.query or host.strip()}` is not in the indexed corpus.**\n\n"
252
+ "That is a normal answer, not an error — the index covers a degree-filtered "
253
+ "subset of the Common Crawl host graph. Tried the exact host, its `www.` variant "
254
+ "and its registrable domain.",
255
+ [],
256
+ [],
257
+ trail,
258
+ _breadcrumb(trail),
259
+ host,
260
+ )
261
+
262
+ if not trail or trail[-1] != result.query:
263
+ trail.append(result.query)
264
+ via = {
265
+ "exact": "exact match",
266
+ "www": "matched the `www.` variant",
267
+ "no-www": "matched without the `www.` prefix",
268
+ "domain": "fell back to the registrable domain",
269
+ }.get(result.resolved_via, result.resolved_via)
270
+ status = (
271
+ f"✅ **`{result.query}`** — {via}, row {result.row:,}. "
272
+ "*Click any row to explore that host's neighbours.*"
273
+ )
274
+ hosts = [n.host for n in result.neighbors]
275
+ return status, _table(result), hosts, trail, _breadcrumb(trail), result.query
276
+
277
+
278
+ def follow(evt: gr.SelectData, hosts: list[str], k: int, trail: list[str]) -> tuple:
279
+ """Make the clicked neighbour the new query."""
280
+ row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
281
+ if not hosts or row is None or row >= len(hosts):
282
+ return gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip()
283
+ return search(hosts[row], k, trail)
284
+
285
+
286
+ def go_back(k: int, trail: list[str]) -> tuple:
287
+ """Step one hop back along the trail."""
288
+ trail = list(trail or [])
289
+ if len(trail) < 2:
290
+ return gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip()
291
+ trail.pop()
292
+ previous = trail.pop()
293
+ return search(previous, k, trail)
294
+
295
+
296
+ with gr.Blocks(title="Web graph kNN spot-check", analytics_enabled=False) as demo:
297
+ neighbor_state = gr.State([])
298
+ trail_state = gr.State([])
299
+
300
+ gr.Markdown("# Web graph kNN spot-check", elem_id="title")
301
+ gr.Markdown(
302
+ "Nearest neighbours of a host in the learned link-prediction embedding space "
303
+ "(cosine similarity on L2-normalized vectors). **Click a neighbour to walk to it.**"
304
+ + _SUBSET_NOTE
305
+ )
306
+ with gr.Row():
307
+ host_box = gr.Textbox(
308
+ label="Host or URL", placeholder="www.example.com", scale=4, autofocus=True
309
+ )
310
+ k_slider = gr.Slider(minimum=5, maximum=100, value=20, step=5, label="Neighbours", scale=1)
311
+ with gr.Row():
312
+ go = gr.Button("Search", variant="primary", scale=3)
313
+ back = gr.Button("← Back", scale=1)
314
+ status = gr.Markdown()
315
+ crumb = gr.Markdown(elem_id="crumb")
316
+ table = gr.Dataframe(
317
+ headers=HEADERS,
318
+ datatype=DATATYPES,
319
+ label="Nearest neighbours — click a row to explore it",
320
+ wrap=True,
321
+ interactive=False,
322
+ )
323
+ gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[host_box])
324
+ gr.Markdown(_FOOTER, elem_id="footer")
325
+
326
+ OUTPUTS = [status, table, neighbor_state, trail_state, crumb, host_box]
327
+ go.click(search, [host_box, k_slider, trail_state], OUTPUTS)
328
+ host_box.submit(search, [host_box, k_slider, trail_state], OUTPUTS)
329
+ k_slider.release(search, [host_box, k_slider, trail_state], OUTPUTS)
330
+ table.select(follow, [neighbor_state, k_slider, trail_state], OUTPUTS)
331
+ back.click(go_back, [k_slider, trail_state], OUTPUTS)
332
+
333
+
334
+ if __name__ == "__main__":
335
+ # Gradio 6 takes ``theme``/``css`` on launch(), not on the Blocks constructor (it warns and
336
+ # drops them there). The Space runs this file as its app_file, so this is the one entry point.
337
+ demo.launch(theme=THEME, css=CSS)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # The Space vendors src/wgl next to app.py, so only wgl.knn's own runtime deps are needed:
2
+ # numpy + faiss for the index, tldextract for host_key/domain_key, rich for wgl.logging.
3
+ # Versions are floors, not pins: the Space image resolves them for its own Python.
4
+ gradio==6.26.0
5
+ faiss-cpu>=1.14
6
+ numpy>=2.0,<3
7
+ tldextract>=5.1
8
+ rich>=13.7
wgl/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """web-graph-learning: directed Web graph representation learning at scale."""
2
+
3
+ __version__ = "0.1.0"
wgl/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Graph data structures, loaders, and leakage-safe splitting."""
wgl/data/graph.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Directed graph stored as out-edge and in-edge CSR structures.
2
+
3
+ Web graphs are fundamentally directed and the in/out asymmetry is the key signal for tasks like
4
+ spam detection (see ``docs/brainstorming/edge-direction.md``). We therefore keep two CSR views and
5
+ never symmetrize implicitly:
6
+
7
+ * **out-CSR** (``out_indptr``, ``out_indices``): successors of each node (who it links to).
8
+ * **in-CSR** (``in_indptr``, ``in_indices``): predecessors of each node (who links to it).
9
+
10
+ The COO ``edge_index`` of shape ``(2, num_edges)`` (rows: ``src``, ``dst``) is the canonical edge
11
+ list. Arrays are plain NumPy so they save/load with ``np.savez`` and can be memory-mapped at scale.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ from numpy.typing import NDArray
22
+
23
+
24
+ def _build_csr(
25
+ rows: NDArray[np.int64],
26
+ cols: NDArray[np.int64],
27
+ num_nodes: int,
28
+ index_dtype: np.dtype = np.int64,
29
+ ) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
30
+ """Build a CSR ``(indptr, indices)`` grouping ``cols`` by ``rows``.
31
+
32
+ Args:
33
+ rows: Source endpoints (the CSR row of each entry).
34
+ cols: Destination endpoints (the value stored in each row).
35
+ num_nodes: Total number of nodes (CSR has ``num_nodes + 1`` indptr entries).
36
+ index_dtype: dtype of the returned ``indices`` (the node-id column). ``int32`` halves the
37
+ footprint when node ids fit (``num_nodes < 2**31``) — worth it at 10^10-edge scale.
38
+ ``indptr`` is always ``int64`` (it accumulates to ``num_edges``, which can exceed 2^31).
39
+
40
+ Returns:
41
+ A tuple ``(indptr, indices)``.
42
+ """
43
+ order = np.argsort(rows, kind="stable")
44
+ sorted_rows = rows[order]
45
+ sorted_cols = cols[order]
46
+ counts = np.bincount(sorted_rows, minlength=num_nodes)
47
+ indptr = np.zeros(num_nodes + 1, dtype=np.int64)
48
+ np.cumsum(counts, out=indptr[1:])
49
+ return indptr, sorted_cols.astype(index_dtype, copy=False)
50
+
51
+
52
+ @dataclass
53
+ class DirectedGraph:
54
+ """An immutable directed graph with cached out/in CSR adjacency.
55
+
56
+ Attributes:
57
+ num_nodes: Number of nodes.
58
+ edge_index: COO edges of shape ``(2, num_edges)`` (row 0 = src, row 1 = dst).
59
+ out_indptr: Out-CSR row pointers, shape ``(num_nodes + 1,)``.
60
+ out_indices: Out-CSR successors.
61
+ in_indptr: In-CSR row pointers, shape ``(num_nodes + 1,)``.
62
+ in_indices: In-CSR predecessors.
63
+ node_names: Optional per-node string identifiers (e.g. reversed hostnames).
64
+ features: Optional per-node feature matrix, shape ``(num_nodes, feature_dim)`` (e.g.
65
+ ogbn-arxiv's word2vec vectors). Persisted as ``node_features.npy``; ``None`` for
66
+ structure-only graphs (the CC web graph has no such features).
67
+ """
68
+
69
+ num_nodes: int
70
+ edge_index: NDArray[np.int64]
71
+ out_indptr: NDArray[np.int64]
72
+ out_indices: NDArray[np.int64]
73
+ in_indptr: NDArray[np.int64]
74
+ in_indices: NDArray[np.int64]
75
+ node_names: list[str] | None = None
76
+ features: NDArray[np.float32] | None = None
77
+
78
+ @property
79
+ def num_edges(self) -> int:
80
+ """Number of directed edges."""
81
+ return int(self.edge_index.shape[1])
82
+
83
+ @classmethod
84
+ def from_edges(
85
+ cls,
86
+ edge_index: NDArray[np.int64],
87
+ num_nodes: int | None = None,
88
+ node_names: list[str] | None = None,
89
+ ) -> DirectedGraph:
90
+ """Construct a graph from a COO edge list, building both CSR views.
91
+
92
+ Args:
93
+ edge_index: Array of shape ``(2, num_edges)`` with src in row 0 and dst in row 1.
94
+ num_nodes: Node count; inferred from the max id + 1 when omitted.
95
+ node_names: Optional per-node identifiers.
96
+
97
+ Returns:
98
+ A new :class:`DirectedGraph`.
99
+ """
100
+ edge_index = np.ascontiguousarray(edge_index, dtype=np.int64)
101
+ if edge_index.ndim != 2 or edge_index.shape[0] != 2:
102
+ msg = f"edge_index must have shape (2, E), got {edge_index.shape}"
103
+ raise ValueError(msg)
104
+ src, dst = edge_index[0], edge_index[1]
105
+ if num_nodes is None:
106
+ num_nodes = int(edge_index.max()) + 1 if edge_index.size else 0
107
+ out_indptr, out_indices = _build_csr(src, dst, num_nodes)
108
+ in_indptr, in_indices = _build_csr(dst, src, num_nodes)
109
+ return cls(
110
+ num_nodes=num_nodes,
111
+ edge_index=edge_index,
112
+ out_indptr=out_indptr,
113
+ out_indices=out_indices,
114
+ in_indptr=in_indptr,
115
+ in_indices=in_indices,
116
+ node_names=node_names,
117
+ )
118
+
119
+ def out_neighbors(self, node: int) -> NDArray[np.int64]:
120
+ """Return the successors of ``node`` (nodes it links to)."""
121
+ return self.out_indices[self.out_indptr[node] : self.out_indptr[node + 1]]
122
+
123
+ def in_neighbors(self, node: int) -> NDArray[np.int64]:
124
+ """Return the predecessors of ``node`` (nodes that link to it)."""
125
+ return self.in_indices[self.in_indptr[node] : self.in_indptr[node + 1]]
126
+
127
+ def out_degree(self) -> NDArray[np.int64]:
128
+ """Return the out-degree of every node."""
129
+ return np.diff(self.out_indptr)
130
+
131
+ def in_degree(self) -> NDArray[np.int64]:
132
+ """Return the in-degree of every node."""
133
+ return np.diff(self.in_indptr)
134
+
135
+ def to_undirected(self) -> DirectedGraph:
136
+ """Return a symmetrized copy with both ``(u, v)`` and ``(v, u)`` for every edge.
137
+
138
+ Used only to build the *undirected baseline* for direction ablations — it deliberately
139
+ discards the in/out asymmetry that the directed models exploit, so it should never be used
140
+ for the primary directed experiments.
141
+
142
+ Returns:
143
+ A new :class:`DirectedGraph` over the same nodes with reciprocated, de-duplicated edges.
144
+ """
145
+ both = np.concatenate([self.edge_index, self.edge_index[::-1]], axis=1)
146
+ undirected = np.unique(both, axis=1) if both.size else both
147
+ return DirectedGraph.from_edges(
148
+ undirected, num_nodes=self.num_nodes, node_names=self.node_names
149
+ )
150
+
151
+ def subgraph_from_edge_mask(self, mask: NDArray[np.bool_]) -> DirectedGraph:
152
+ """Return a graph over the same node set keeping only the masked edges.
153
+
154
+ Used to build the *message* graph for link prediction (a structural view that excludes
155
+ held-out evaluation edges).
156
+
157
+ Args:
158
+ mask: Boolean mask over edges of length ``num_edges``.
159
+
160
+ Returns:
161
+ A new :class:`DirectedGraph` with the same ``num_nodes`` and node names.
162
+ """
163
+ return DirectedGraph.from_edges(
164
+ self.edge_index[:, mask], num_nodes=self.num_nodes, node_names=self.node_names
165
+ )
166
+
167
+ def save(self, directory: str | Path) -> None:
168
+ """Persist the graph to ``directory`` (``graph.npz`` + ``meta.json``).
169
+
170
+ Args:
171
+ directory: Output directory (created if needed).
172
+ """
173
+ directory = Path(directory)
174
+ directory.mkdir(parents=True, exist_ok=True)
175
+ np.savez(
176
+ directory / "graph.npz",
177
+ edge_index=self.edge_index,
178
+ out_indptr=self.out_indptr,
179
+ out_indices=self.out_indices,
180
+ in_indptr=self.in_indptr,
181
+ in_indices=self.in_indices,
182
+ )
183
+ meta = {"num_nodes": self.num_nodes, "num_edges": self.num_edges}
184
+ (directory / "meta.json").write_text(json.dumps(meta, indent=2))
185
+ if self.node_names is not None:
186
+ (directory / "node_names.txt").write_text("\n".join(self.node_names))
187
+ if self.features is not None:
188
+ np.save(directory / "node_features.npy", self.features)
189
+
190
+ @classmethod
191
+ def load(cls, directory: str | Path, mmap: bool = False) -> DirectedGraph:
192
+ """Load a graph previously written by :meth:`save`.
193
+
194
+ Args:
195
+ directory: Directory containing ``graph.npz`` / ``meta.json``.
196
+ mmap: If True, memory-map the arrays (recommended for large graphs).
197
+
198
+ Returns:
199
+ The loaded :class:`DirectedGraph`.
200
+ """
201
+ directory = Path(directory)
202
+ data = np.load(directory / "graph.npz", mmap_mode="r" if mmap else None)
203
+ meta = json.loads((directory / "meta.json").read_text())
204
+ names_path = directory / "node_names.txt"
205
+ node_names = names_path.read_text().splitlines() if names_path.exists() else None
206
+ features_path = directory / "node_features.npy"
207
+ features = np.load(features_path) if features_path.exists() else None
208
+ return cls(
209
+ num_nodes=int(meta["num_nodes"]),
210
+ edge_index=data["edge_index"],
211
+ out_indptr=data["out_indptr"],
212
+ out_indices=data["out_indices"],
213
+ in_indptr=data["in_indptr"],
214
+ in_indices=data["in_indices"],
215
+ node_names=node_names,
216
+ features=features,
217
+ )
wgl/knn/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """kNN spot-check index over a released host-embedding table.
2
+
3
+ A small debugging tool: given a hostname, return its nearest neighbours in the learned 128-d
4
+ embedding space. Companion to the 2D viewer (``docs/embedding-2d-viewer.md``) — that one shows
5
+ *where* hosts land after a UMAP projection, this one answers *who is actually nearest* in the real
6
+ space. See [`docs/knn-viewer/plan.md`](../../../docs/knn-viewer/plan.md).
7
+
8
+ The artifact is a flat FAISS ``IndexPQ`` plus a sorted hash → row map and an offset-indexed name
9
+ file, so a query is a hash lookup followed by one exhaustive (but tiny) PQ scan. There is no IVF
10
+ and no ``nprobe``: at PQ M=32 a full scan of 52.9 M rows costs well under a second, so the only
11
+ approximation in the tool is the PQ codebook itself.
12
+ """
13
+
14
+ from wgl.knn.build import BuildResult, build_index
15
+ from wgl.knn.index import KnnIndex, Neighbor
16
+ from wgl.knn.source import HostVectorSource, NpySource, ParquetSource, RowBatch
17
+
18
+ __all__ = [
19
+ "BuildResult",
20
+ "HostVectorSource",
21
+ "KnnIndex",
22
+ "Neighbor",
23
+ "NpySource",
24
+ "ParquetSource",
25
+ "RowBatch",
26
+ "build_index",
27
+ ]
wgl/knn/build.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Build the kNN spot-check index artifact from a host-vector source.
2
+
3
+ The artifact is deliberately five small files plus a manifest, all in one directory:
4
+
5
+ =============== ========================================== ===========================
6
+ file dtype / format contents
7
+ =============== ========================================== ===========================
8
+ ``index.faiss`` FAISS ``OPQ<M>,PQ<M>`` (or ``Flat``) one code per row + codebooks
9
+ ``keys.npy`` ``uint64[n]``, **sorted ascending** hash of each host key
10
+ ``rows.npy`` ``uint32[n]``, permuted with ``keys`` index row for each key
11
+ ``names.txt`` UTF-8, ``\n``-joined forward host per index row
12
+ ``names.idx`` ``uint64[n + 1]`` byte offset of each line
13
+ ``meta.json`` JSON provenance + invariants
14
+ =============== ========================================== ===========================
15
+
16
+ Names live on disk behind an offset index rather than in RAM: a query needs about twenty of them,
17
+ so it seeks instead of loading ~1 GB of strings. Keys are hashed to ``uint64`` and sorted so the
18
+ host lookup is a :func:`numpy.searchsorted` over a 0.4 GB array instead of a ~6 GB Python dict; the
19
+ hash is verified by reading the name back, which makes a collision a clean miss rather than a wrong
20
+ answer.
21
+
22
+ The builder makes a **single pass** over the source. A quantizer has to be trained before anything
23
+ can be encoded, so early batches are buffered until the training sample is full, trained on, then
24
+ flushed — which keeps a remote source from being downloaded twice and keeps peak memory at the
25
+ training sample rather than the whole table.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ from dataclasses import dataclass, field
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+ from numpy.typing import NDArray
38
+
39
+ from wgl.knn.source import HostVectorSource
40
+ from wgl.logging import get_logger
41
+
42
+ # Below this row count there is not enough data to train a product quantizer (PQ needs 256 training
43
+ # points per sub-quantizer to fill its codebook), so the builder stores exact vectors instead.
44
+ MIN_ROWS_FOR_PQ = 10_000
45
+ TRAIN_SAMPLE = 200_000
46
+
47
+
48
+ @dataclass
49
+ class BuildResult:
50
+ """Summary of a completed build.
51
+
52
+ Attributes:
53
+ out: Directory the artifact was written to.
54
+ n: Number of rows indexed.
55
+ dim: Embedding dimensionality.
56
+ factory: The FAISS index-factory string actually used.
57
+ duplicates: Host keys dropped because they appeared more than once.
58
+ bytes_written: Total artifact size on disk.
59
+ meta: The ``meta.json`` contents.
60
+ """
61
+
62
+ out: Path
63
+ n: int
64
+ dim: int
65
+ factory: str
66
+ duplicates: int
67
+ bytes_written: int
68
+ meta: dict[str, object] = field(default_factory=dict)
69
+
70
+
71
+ def hash_keys(keys: list[str]) -> NDArray[np.uint64]:
72
+ """Hash host keys to ``uint64``.
73
+
74
+ Uses BLAKE2b truncated to 8 bytes — a stdlib hash, so the index builder adds no dependency for
75
+ it. Collisions are not prevented (about 7.6e-5 probability across 53 M keys by the birthday
76
+ approximation) but they are *detected*: :meth:`wgl.knn.index.KnnIndex.lookup` reads the stored
77
+ name back and rejects a mismatch.
78
+
79
+ Args:
80
+ keys: Forward host keys.
81
+
82
+ Returns:
83
+ One ``uint64`` per key, in input order.
84
+ """
85
+ out = np.empty(len(keys), dtype=np.uint64)
86
+ for i, key in enumerate(keys):
87
+ digest = hashlib.blake2b(key.encode("utf-8"), digest_size=8).digest()
88
+ out[i] = int.from_bytes(digest, "big")
89
+ return out
90
+
91
+
92
+ def choose_factory(n: int, dim: int, requested: str = "auto") -> str:
93
+ """Pick a FAISS index-factory string.
94
+
95
+ Args:
96
+ n: Row count.
97
+ dim: Embedding dimensionality.
98
+ requested: An explicit factory string, or ``"auto"``.
99
+
100
+ Returns:
101
+ The factory string to build with. ``"auto"`` resolves to ``OPQ32,PQ32`` when there are
102
+ enough rows to train a quantizer and the dimensionality divides evenly, else ``"Flat"``.
103
+ """
104
+ if requested != "auto":
105
+ return requested
106
+ if n < MIN_ROWS_FOR_PQ or dim % 32 != 0:
107
+ return "Flat"
108
+ return "OPQ32,PQ32"
109
+
110
+
111
+ def _normalize(vectors: NDArray[np.float32]) -> NDArray[np.float32]:
112
+ """L2-normalize rows so that an inner product equals cosine similarity."""
113
+ array = np.ascontiguousarray(vectors, dtype=np.float32)
114
+ norms = np.linalg.norm(array, axis=1, keepdims=True)
115
+ return array / np.maximum(norms, 1e-12)
116
+
117
+
118
+ def build_index(
119
+ source: HostVectorSource,
120
+ out: Path,
121
+ *,
122
+ factory: str = "auto",
123
+ train_sample: int = TRAIN_SAMPLE,
124
+ extra_meta: dict[str, object] | None = None,
125
+ ) -> BuildResult:
126
+ """Build and write the kNN index artifact.
127
+
128
+ Vectors are L2-normalized *before* training and encoding, so the index metric is inner product
129
+ and a returned score is a cosine similarity. The source's own ``row_id`` is recorded in
130
+ ``meta.json`` but the index uses its own dense 0-based numbering, so a subset build is a
131
+ first-class citizen rather than a sparse index full of holes.
132
+
133
+ Args:
134
+ source: Where rows come from.
135
+ out: Output directory; created if absent.
136
+ factory: FAISS factory string, or ``"auto"`` (see :func:`choose_factory`).
137
+ train_sample: Rows to buffer and train the quantizer on.
138
+ extra_meta: Extra provenance fields merged into ``meta.json``.
139
+
140
+ Returns:
141
+ A :class:`BuildResult` describing what was written.
142
+
143
+ Raises:
144
+ ValueError: If the source yields no rows.
145
+ """
146
+ import faiss
147
+
148
+ logger = get_logger()
149
+ out = Path(out)
150
+ out.mkdir(parents=True, exist_ok=True)
151
+ dim = source.dim
152
+
153
+ index: object | None = None
154
+ resolved_factory: str | None = None
155
+ pending: list[NDArray[np.float32]] = []
156
+ pending_rows = 0
157
+ key_chunks: list[NDArray[np.uint64]] = []
158
+ length_chunks: list[NDArray[np.uint64]] = []
159
+ row_id_chunks: list[NDArray[np.int64]] = []
160
+ n = 0
161
+
162
+ def _flush(buffered: list[NDArray[np.float32]]) -> None:
163
+ """Train on the buffer if needed, then add every buffered batch to the index."""
164
+ nonlocal index, resolved_factory
165
+ stacked = np.concatenate(buffered, axis=0)
166
+ if index is None:
167
+ resolved_factory = choose_factory(max(stacked.shape[0], n), dim, factory)
168
+ logger.info("building %s index (dim=%d)", resolved_factory, dim)
169
+ index = faiss.index_factory(dim, resolved_factory, faiss.METRIC_INNER_PRODUCT)
170
+ if not index.is_trained:
171
+ # Cap the training set: a source that yields million-row batches would otherwise
172
+ # train on the whole first batch, which is far slower for no gain in codebook
173
+ # quality (FAISS wants ~256 points per centroid, not millions).
174
+ train_on = stacked[:train_sample]
175
+ logger.info("training quantizer on %d rows", train_on.shape[0])
176
+ index.train(train_on)
177
+ index.add(stacked)
178
+
179
+ names_path = out / "names.txt"
180
+ with names_path.open("wb") as handle:
181
+ for batch in source.batches():
182
+ encoded = [key.encode("utf-8") for key in batch.host_keys]
183
+ handle.write(b"\n".join(encoded) + b"\n")
184
+ length_chunks.append(np.fromiter((len(b) + 1 for b in encoded), dtype=np.uint64))
185
+ key_chunks.append(hash_keys(batch.host_keys))
186
+ row_id_chunks.append(batch.row_ids)
187
+ n += len(encoded)
188
+
189
+ pending.append(_normalize(batch.vectors))
190
+ pending_rows += batch.vectors.shape[0]
191
+ if pending_rows >= train_sample:
192
+ _flush(pending)
193
+ pending, pending_rows = [], 0
194
+ logger.info("scanned %d rows", n)
195
+
196
+ if n == 0:
197
+ msg = "source yielded no rows"
198
+ raise ValueError(msg)
199
+ if pending:
200
+ _flush(pending)
201
+ del pending
202
+
203
+ if index is None or index.ntotal != n:
204
+ held = 0 if index is None else index.ntotal
205
+ msg = f"index holds {held} vectors but {n} names were written"
206
+ raise ValueError(msg)
207
+
208
+ # Identity map: hash, sort, and drop any duplicate host key (the contract's uniqueness rule).
209
+ keys = np.concatenate(key_chunks)
210
+ order = np.argsort(keys, kind="stable")
211
+ keys_sorted = keys[order]
212
+ rows_sorted = order.astype(np.uint32)
213
+ unique = np.ones(keys_sorted.shape[0], dtype=bool)
214
+ if keys_sorted.shape[0] > 1:
215
+ unique[1:] = keys_sorted[1:] != keys_sorted[:-1]
216
+ duplicates = int((~unique).sum())
217
+ if duplicates:
218
+ logger.warning("dropped %d duplicate host key(s) from the lookup map", duplicates)
219
+ keys_sorted = keys_sorted[unique]
220
+ rows_sorted = rows_sorted[unique]
221
+
222
+ offsets = np.zeros(n + 1, dtype=np.uint64)
223
+ np.cumsum(np.concatenate(length_chunks), out=offsets[1:])
224
+
225
+ faiss.write_index(index, str(out / "index.faiss"))
226
+ np.save(out / "keys.npy", keys_sorted)
227
+ np.save(out / "rows.npy", rows_sorted)
228
+ with (out / "names.idx").open("wb") as handle:
229
+ np.save(handle, offsets)
230
+
231
+ source_rows = np.concatenate(row_id_chunks)
232
+ meta: dict[str, object] = {
233
+ "n": n,
234
+ "dim": dim,
235
+ "index_factory": resolved_factory,
236
+ "metric": "inner_product",
237
+ "normalized": True,
238
+ "hash": "blake2b-64",
239
+ "host_key_fn": "wgl.release.identity.host_key",
240
+ "duplicates_dropped": duplicates,
241
+ "source_row_min": int(source_rows.min()),
242
+ "source_row_max": int(source_rows.max()),
243
+ "built_at": datetime.now(UTC).isoformat(),
244
+ "faiss_version": getattr(faiss, "__version__", "unknown"),
245
+ **source.describe(),
246
+ **(extra_meta or {}),
247
+ }
248
+ (out / "meta.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
249
+
250
+ written = sum(p.stat().st_size for p in out.iterdir() if p.is_file())
251
+ logger.info("wrote %s (%.1f MB)", out, written / 1e6)
252
+ return BuildResult(
253
+ out=out,
254
+ n=n,
255
+ dim=dim,
256
+ factory=str(meta["index_factory"]),
257
+ duplicates=duplicates,
258
+ bytes_written=written,
259
+ meta=meta,
260
+ )
wgl/knn/index.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load a built kNN artifact and answer nearest-neighbour queries.
2
+
3
+ A query is three cheap steps and one scan:
4
+
5
+ 1. normalize the typed host to a **host key** (:func:`wgl.release.identity.host_key`) and hash it;
6
+ 2. :func:`numpy.searchsorted` the sorted key array to get the index row, then **read the stored
7
+ name back** to confirm the hash actually belongs to that host — a collision becomes a clean miss
8
+ rather than a confidently wrong answer;
9
+ 3. reconstruct that row's vector from the index and scan for the top ``k + 1``, dropping the self
10
+ match.
11
+
12
+ Everything is in-process: after :meth:`KnnIndex.load` returns there is no network access, so the
13
+ same object serves a CLI query, a test, and a Gradio callback identically.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ from numpy.typing import NDArray
24
+
25
+ from wgl.knn.build import hash_keys
26
+ from wgl.release.identity import domain_key as to_domain_key
27
+ from wgl.release.identity import host_key as to_host_key
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Neighbor:
32
+ """One nearest-neighbour result.
33
+
34
+ Attributes:
35
+ rank: 1-based position in the result list.
36
+ host: The neighbour's forward host key.
37
+ score: Cosine similarity to the query (vectors are L2-normalized, so a dot product).
38
+ row: The neighbour's row in this index.
39
+ """
40
+
41
+ rank: int
42
+ host: str
43
+ score: float
44
+ row: int
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class QueryResult:
49
+ """The answer to one lookup.
50
+
51
+ Attributes:
52
+ query: The host key the query resolved to.
53
+ resolved_via: How it resolved — ``"exact"``, ``"www"``, ``"no-www"``, ``"domain"``, or
54
+ ``"miss"`` when the host is not in the corpus at all.
55
+ row: The matched index row, or ``None`` on a miss.
56
+ neighbors: The ranked neighbours; empty on a miss.
57
+ """
58
+
59
+ query: str
60
+ resolved_via: str
61
+ row: int | None
62
+ neighbors: list[Neighbor]
63
+
64
+ @property
65
+ def found(self) -> bool:
66
+ """Whether the query resolved to a row in the corpus."""
67
+ return self.row is not None
68
+
69
+
70
+ def normalize_query(text: str) -> str:
71
+ """Turn user input into a host key.
72
+
73
+ Accepts a bare host, a host with a scheme and path, or a pasted URL. The result goes through
74
+ :func:`wgl.release.identity.host_key`, so it matches exactly what the builder stored.
75
+
76
+ Args:
77
+ text: Raw user input.
78
+
79
+ Returns:
80
+ The canonical forward host key, or an empty string when nothing usable was typed.
81
+ """
82
+ candidate = text.strip()
83
+ if not candidate:
84
+ return ""
85
+ if "//" in candidate:
86
+ candidate = candidate.split("//", 1)[1]
87
+ candidate = candidate.split("/", 1)[0].split("?", 1)[0]
88
+ candidate = candidate.rsplit("@", 1)[-1]
89
+ if candidate.startswith("[") and "]" in candidate: # bracketed IPv6 literal
90
+ candidate = candidate[1 : candidate.index("]")]
91
+ elif candidate.count(":") == 1:
92
+ candidate = candidate.split(":", 1)[0]
93
+ return to_host_key(candidate, reversed_host=False)
94
+
95
+
96
+ class KnnIndex:
97
+ """A loaded kNN artifact: the FAISS index, the key map, and the on-disk name file."""
98
+
99
+ def __init__(
100
+ self,
101
+ index: object,
102
+ keys: NDArray[np.uint64],
103
+ rows: NDArray[np.uint32],
104
+ offsets: NDArray[np.uint64],
105
+ names_path: Path,
106
+ meta: dict[str, object],
107
+ ) -> None:
108
+ """Wrap already-loaded artifact components. Use :meth:`load` instead."""
109
+ self._index = index
110
+ self._keys = keys
111
+ self._rows = rows
112
+ self._offsets = offsets
113
+ self._names_path = names_path
114
+ self.meta = meta
115
+
116
+ @classmethod
117
+ def load(cls, path: Path) -> KnnIndex:
118
+ """Load an artifact directory written by :func:`wgl.knn.build.build_index`.
119
+
120
+ Args:
121
+ path: The artifact directory.
122
+
123
+ Returns:
124
+ A ready-to-query index.
125
+
126
+ Raises:
127
+ FileNotFoundError: If the directory is missing an expected file.
128
+ """
129
+ import faiss
130
+
131
+ path = Path(path)
132
+ required = ["index.faiss", "keys.npy", "rows.npy", "names.txt", "names.idx", "meta.json"]
133
+ missing = [name for name in required if not (path / name).is_file()]
134
+ if missing:
135
+ msg = f"{path} is not a kNN artifact; missing {', '.join(missing)}"
136
+ raise FileNotFoundError(msg)
137
+ with (path / "names.idx").open("rb") as handle:
138
+ offsets = np.load(handle)
139
+ return cls(
140
+ index=faiss.read_index(str(path / "index.faiss")),
141
+ keys=np.load(path / "keys.npy"),
142
+ rows=np.load(path / "rows.npy"),
143
+ offsets=offsets,
144
+ names_path=path / "names.txt",
145
+ meta=json.loads((path / "meta.json").read_text(encoding="utf-8")),
146
+ )
147
+
148
+ def __len__(self) -> int:
149
+ """Return the number of indexed rows."""
150
+ return int(self._index.ntotal)
151
+
152
+ def name_of(self, row: int) -> str:
153
+ """Return the host key stored at ``row`` by seeking into ``names.txt``."""
154
+ start = int(self._offsets[row])
155
+ end = int(self._offsets[row + 1])
156
+ with self._names_path.open("rb") as handle:
157
+ handle.seek(start)
158
+ return handle.read(end - start).decode("utf-8", "replace").rstrip("\n")
159
+
160
+ def names_of(self, rows: list[int]) -> list[str]:
161
+ """Return host keys for several rows, reusing one open file handle."""
162
+ with self._names_path.open("rb") as handle:
163
+ out = []
164
+ for row in rows:
165
+ start = int(self._offsets[row])
166
+ handle.seek(start)
167
+ blob = handle.read(int(self._offsets[row + 1]) - start)
168
+ out.append(blob.decode("utf-8", "replace").rstrip("\n"))
169
+ return out
170
+
171
+ def lookup(self, host: str) -> int | None:
172
+ """Return the index row for an exact host key, or ``None``.
173
+
174
+ The stored name is read back and compared, so a hash collision returns ``None`` rather than
175
+ a neighbouring host's row.
176
+
177
+ Args:
178
+ host: A canonical forward host key.
179
+
180
+ Returns:
181
+ The row id, or ``None`` when the host is not indexed.
182
+ """
183
+ if not host:
184
+ return None
185
+ needle = hash_keys([host])[0]
186
+ position = int(np.searchsorted(self._keys, needle))
187
+ if position >= self._keys.shape[0] or self._keys[position] != needle:
188
+ return None
189
+ row = int(self._rows[position])
190
+ return row if self.name_of(row) == host else None
191
+
192
+ def resolve(self, text: str, *, domain_fallback: bool = True) -> tuple[str, str, int | None]:
193
+ """Resolve user input to an index row, trying sensible variants in order.
194
+
195
+ ``cc_deg8`` covers 52.9 M of a 279.4 M-host graph, so a miss is a normal answer rather than
196
+ an error. Before giving up this tries the ``www.`` variant in both directions and then the
197
+ registrable domain, which is very often the host that was actually crawled.
198
+
199
+ Args:
200
+ text: Raw user input.
201
+ domain_fallback: Whether to fall back to the registrable domain.
202
+
203
+ Returns:
204
+ A ``(host_key, resolved_via, row)`` triple; ``row`` is ``None`` on a miss.
205
+ """
206
+ host = normalize_query(text)
207
+ if not host:
208
+ return "", "miss", None
209
+
210
+ candidates: list[tuple[str, str]] = [(host, "exact")]
211
+ if host.startswith("www."):
212
+ candidates.append((host[4:], "no-www"))
213
+ else:
214
+ candidates.append((f"www.{host}", "www"))
215
+ if domain_fallback:
216
+ domain = to_domain_key(host, reversed_host=False)
217
+ if domain and domain != host:
218
+ candidates.append((domain, "domain"))
219
+ candidates.append((f"www.{domain}", "domain"))
220
+
221
+ seen: set[str] = set()
222
+ for candidate, via in candidates:
223
+ if candidate in seen:
224
+ continue
225
+ seen.add(candidate)
226
+ row = self.lookup(candidate)
227
+ if row is not None:
228
+ return candidate, via, row
229
+ return host, "miss", None
230
+
231
+ def query(self, text: str, k: int = 20, *, domain_fallback: bool = True) -> QueryResult:
232
+ """Return the ``k`` nearest neighbours of a typed host.
233
+
234
+ Args:
235
+ text: Raw user input — a host, a URL, or a host with a port.
236
+ k: Number of neighbours to return (the query itself is excluded).
237
+ domain_fallback: Whether to retry the registrable domain when the host is absent.
238
+
239
+ Returns:
240
+ A :class:`QueryResult`; ``found`` is False when the host is not in the corpus.
241
+ """
242
+ host, via, row = self.resolve(text, domain_fallback=domain_fallback)
243
+ if row is None:
244
+ return QueryResult(query=host, resolved_via=via, row=None, neighbors=[])
245
+ vector = self._index.reconstruct(row).reshape(1, -1).astype(np.float32)
246
+ wanted = min(k + 1, len(self))
247
+ scores, ids = self._index.search(vector, wanted)
248
+ neighbors: list[Neighbor] = []
249
+ hits = [(int(i), float(s)) for i, s in zip(ids[0], scores[0], strict=True) if i != row]
250
+ hits = hits[:k]
251
+ for rank, (neighbor_row, score) in enumerate(hits, start=1):
252
+ neighbors.append(
253
+ Neighbor(rank=rank, host="", score=score, row=neighbor_row),
254
+ )
255
+ names = self.names_of([hit.row for hit in neighbors])
256
+ neighbors = [
257
+ Neighbor(rank=hit.rank, host=name, score=hit.score, row=hit.row)
258
+ for hit, name in zip(neighbors, names, strict=True)
259
+ ]
260
+ return QueryResult(query=host, resolved_via=via, row=row, neighbors=neighbors)
wgl/knn/source.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Row sources for the kNN index builder.
2
+
3
+ The builder is corpus-agnostic: it consumes :class:`RowBatch` objects and never learns where they
4
+ came from. Two sources are provided.
5
+
6
+ * :class:`ParquetSource` reads the **published release shards** (``row_id``/``host_key``/
7
+ ``domain_key``/``embedding``), locally or straight from a HuggingFace dataset repo over HTTP
8
+ range requests. ``host_key`` is already forward and normalized there, so nothing has to be
9
+ un-reversed. This is the quick path — a single row group is a usable proof-of-concept subset.
10
+ * :class:`NpySource` reads the **cluster export** (a ``[n, dim]`` ``.npy`` plus the CC-reversed
11
+ ``node_names.txt``). This is the full-precision path used to scale up.
12
+
13
+ Both stream in batches and never materialize the whole table.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import json
20
+ import os
21
+ import urllib.request
22
+ from collections.abc import Iterator
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Protocol
26
+
27
+ import numpy as np
28
+ from numpy.typing import NDArray
29
+
30
+ from wgl.release.identity import host_key as to_host_key
31
+
32
+ _HF_RESOLVE = "https://huggingface.co/datasets/{repo}/resolve/{revision}/{path}"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RowBatch:
37
+ """A contiguous batch of rows from a host-embedding table.
38
+
39
+ Attributes:
40
+ row_ids: Global row ids, shape ``(m,)``. These index the *source* table, which for a
41
+ subset build is not the same as the index's own 0-based row numbering.
42
+ host_keys: Forward, normalized host keys (``www.example.com``), length ``m``.
43
+ vectors: Embedding rows, shape ``(m, dim)``. Not necessarily normalized.
44
+ """
45
+
46
+ row_ids: NDArray[np.int64]
47
+ host_keys: list[str]
48
+ vectors: NDArray[np.float32]
49
+
50
+ def __post_init__(self) -> None:
51
+ """Validate that the three parallel fields agree on length."""
52
+ n = len(self.host_keys)
53
+ if self.row_ids.shape[0] != n or self.vectors.shape[0] != n:
54
+ msg = (
55
+ f"RowBatch fields disagree: {self.row_ids.shape[0]} row_ids, {n} host_keys, "
56
+ f"{self.vectors.shape[0]} vectors"
57
+ )
58
+ raise ValueError(msg)
59
+
60
+
61
+ class HostVectorSource(Protocol):
62
+ """A stream of :class:`RowBatch` objects plus the metadata the builder records."""
63
+
64
+ @property
65
+ def dim(self) -> int:
66
+ """Embedding dimensionality."""
67
+ ...
68
+
69
+ def describe(self) -> dict[str, object]:
70
+ """Return provenance fields to embed in the built index's ``meta.json``."""
71
+ ...
72
+
73
+ def batches(self) -> Iterator[RowBatch]:
74
+ """Yield row batches in ascending ``row_id`` order."""
75
+ ...
76
+
77
+
78
+ class HttpRangeFile(io.RawIOBase):
79
+ """A seekable read-only file over HTTP range requests.
80
+
81
+ Parquet only needs the footer plus the column chunks actually requested, so wrapping a remote
82
+ shard in this class lets :mod:`pyarrow` read a schema (or one row group) without downloading
83
+ the whole file. HuggingFace serves ``206 Partial Content``, which is what makes this work.
84
+ """
85
+
86
+ def __init__(self, url: str, token: str | None = None) -> None:
87
+ """Open ``url`` and resolve its size and any redirect.
88
+
89
+ Args:
90
+ url: The URL to read.
91
+ token: Optional bearer token, required for private repositories.
92
+ """
93
+ self._headers = {"Authorization": f"Bearer {token}"} if token else {}
94
+ with self._open(url, {**self._headers, "Range": "bytes=0-0"}) as resp:
95
+ self.url = resp.geturl()
96
+ content_range = resp.headers.get("Content-Range")
97
+ if not content_range:
98
+ msg = f"server did not honour a Range request for {url}"
99
+ raise OSError(msg)
100
+ self.size = int(content_range.split("/")[1])
101
+ self._pos = 0
102
+
103
+ @staticmethod
104
+ def _open(url: str, headers: dict[str, str]) -> io.BufferedIOBase:
105
+ """Issue a GET with ``headers`` and return the open response."""
106
+ request = urllib.request.Request(url, headers=headers) # https URLs only
107
+ return urllib.request.urlopen(request)
108
+
109
+ def readable(self) -> bool:
110
+ """Return True; the file is read-only but readable."""
111
+ return True
112
+
113
+ def seekable(self) -> bool:
114
+ """Return True; range requests make the stream seekable."""
115
+ return True
116
+
117
+ def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
118
+ """Move the read cursor and return the new absolute position."""
119
+ if whence == os.SEEK_SET:
120
+ self._pos = offset
121
+ elif whence == os.SEEK_CUR:
122
+ self._pos += offset
123
+ else:
124
+ self._pos = self.size + offset
125
+ return self._pos
126
+
127
+ def tell(self) -> int:
128
+ """Return the current absolute read position."""
129
+ return self._pos
130
+
131
+ def readinto(self, buffer: memoryview) -> int: # type: ignore[override]
132
+ """Fill ``buffer`` from the current position and advance it."""
133
+ want = len(buffer)
134
+ if want == 0 or self._pos >= self.size:
135
+ return 0
136
+ end = min(self._pos + want, self.size) - 1
137
+ headers = {**self._headers, "Range": f"bytes={self._pos}-{end}"}
138
+ with self._open(self.url, headers) as resp:
139
+ data = resp.read()
140
+ buffer[: len(data)] = data
141
+ self._pos += len(data)
142
+ return len(data)
143
+
144
+
145
+ def _open_shard(
146
+ location: str, token: str | None, buffer_bytes: int = 1 << 20
147
+ ) -> io.BufferedReader | io.IOBase:
148
+ """Open a shard that is either a local path or an ``https://`` URL."""
149
+ if location.startswith("https://"):
150
+ return io.BufferedReader(HttpRangeFile(location, token), buffer_size=buffer_bytes) # type: ignore[arg-type]
151
+ return Path(location).open("rb")
152
+
153
+
154
+ class ParquetSource:
155
+ """Read rows from published release Parquet shards (local paths or a HF dataset repo).
156
+
157
+ The release shards carry identity *co-located per row* (``row_id``, ``host_key``,
158
+ ``domain_key``) alongside the vector, so this source needs no separate join file and no
159
+ un-reversing — see ``docs/release-contract.md``.
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ shards: list[str],
165
+ *,
166
+ token: str | None = None,
167
+ row_groups: int | None = None,
168
+ repo: str | None = None,
169
+ revision: str = "main",
170
+ ) -> None:
171
+ """Configure the source.
172
+
173
+ Args:
174
+ shards: Shard locations — local paths or ``https://`` URLs, in row order.
175
+ token: Bearer token for private repositories.
176
+ row_groups: Read at most this many row groups per shard (``None`` = all). This is the
177
+ subset knob: one row group of a release shard is ~1 M contiguous hosts.
178
+ repo: HuggingFace dataset repo id, recorded in ``meta.json`` when the shards came
179
+ from the Hub.
180
+ revision: Repo revision, recorded for provenance.
181
+ """
182
+ if not shards:
183
+ msg = "ParquetSource needs at least one shard"
184
+ raise ValueError(msg)
185
+ self.shards = shards
186
+ self.token = token
187
+ self.row_groups = row_groups
188
+ self.repo = repo
189
+ self.revision = revision
190
+ self._dim: int | None = None
191
+
192
+ @classmethod
193
+ def from_hf(
194
+ cls,
195
+ repo: str,
196
+ *,
197
+ token: str | None = None,
198
+ shards: list[int] | None = None,
199
+ row_groups: int | None = None,
200
+ revision: str = "main",
201
+ num_shards: int = 14,
202
+ pattern: str = "vectors/emb-{shard:05d}-of-{total:05d}.parquet",
203
+ ) -> ParquetSource:
204
+ """Build a source over shards of a HuggingFace dataset repo, read by range request.
205
+
206
+ Args:
207
+ repo: Dataset repo id, e.g. ``malteos/web-graph-embeddings``.
208
+ token: Bearer token; required while the dataset is private.
209
+ shards: Shard indices to read (``None`` = all ``num_shards``).
210
+ row_groups: Row groups per shard to read (``None`` = all).
211
+ revision: Git revision to resolve against.
212
+ num_shards: Total shard count, used to format the file names.
213
+ pattern: Shard path template.
214
+
215
+ Returns:
216
+ A configured :class:`ParquetSource`.
217
+ """
218
+ picked = list(range(num_shards)) if shards is None else shards
219
+ urls = [
220
+ _HF_RESOLVE.format(
221
+ repo=repo,
222
+ revision=revision,
223
+ path=pattern.format(shard=index, total=num_shards),
224
+ )
225
+ for index in picked
226
+ ]
227
+ return cls(urls, token=token, row_groups=row_groups, repo=repo, revision=revision)
228
+
229
+ @property
230
+ def dim(self) -> int:
231
+ """Embedding dimensionality, read from the first shard's schema."""
232
+ if self._dim is None:
233
+ import pyarrow.parquet as pq
234
+
235
+ with _open_shard(self.shards[0], self.token) as handle:
236
+ field = pq.ParquetFile(handle).schema_arrow.field("embedding")
237
+ self._dim = int(field.type.list_size)
238
+ return self._dim
239
+
240
+ def describe(self) -> dict[str, object]:
241
+ """Return provenance fields for ``meta.json``."""
242
+ return {
243
+ "source_kind": "parquet",
244
+ "source_repo": self.repo,
245
+ "source_revision": self.revision,
246
+ "source_shards": len(self.shards),
247
+ "source_row_groups_per_shard": self.row_groups,
248
+ }
249
+
250
+ def batches(self) -> Iterator[RowBatch]:
251
+ """Yield one :class:`RowBatch` per row group, in shard then row-group order."""
252
+ import pyarrow.parquet as pq
253
+
254
+ for location in self.shards:
255
+ with _open_shard(location, self.token) as handle:
256
+ reader = pq.ParquetFile(handle)
257
+ limit = reader.num_row_groups
258
+ if self.row_groups is not None:
259
+ limit = min(limit, self.row_groups)
260
+ for group in range(limit):
261
+ table = reader.read_row_group(
262
+ group, columns=["row_id", "host_key", "embedding"]
263
+ )
264
+ vectors = np.asarray(
265
+ table.column("embedding").combine_chunks().flatten(),
266
+ dtype=np.float32,
267
+ ).reshape(table.num_rows, -1)
268
+ yield RowBatch(
269
+ row_ids=table.column("row_id").to_numpy().astype(np.int64),
270
+ host_keys=table.column("host_key").to_pylist(),
271
+ vectors=vectors,
272
+ )
273
+
274
+
275
+ class NpySource:
276
+ """Read rows from a cluster export: an ``[n, dim]`` ``.npy`` plus ``node_names.txt``.
277
+
278
+ This is the full-precision path. The ``.npy`` is memory-mapped and read in batches, so a 27 GB
279
+ fp32 table never lands in RAM. Names are CC-**reversed** in that file and are converted to
280
+ forward host keys here, so the rest of the pipeline only ever sees forward hosts.
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ emb: Path,
286
+ names: Path,
287
+ *,
288
+ batch_rows: int = 1_000_000,
289
+ limit: int | None = None,
290
+ reversed_names: bool = True,
291
+ ) -> None:
292
+ """Configure the source.
293
+
294
+ Args:
295
+ emb: Path to the ``[n, dim]`` float ``.npy`` export.
296
+ names: Path to the newline-delimited node-name file, one name per embedding row.
297
+ batch_rows: Rows per yielded batch.
298
+ limit: Read only the first ``limit`` rows (the subset knob for this source).
299
+ reversed_names: Whether ``names`` holds CC-reversed hosts (``com.example.www``).
300
+ """
301
+ self.emb = Path(emb)
302
+ self.names = Path(names)
303
+ self.batch_rows = batch_rows
304
+ self.limit = limit
305
+ self.reversed_names = reversed_names
306
+ self._shape: tuple[int, int] | None = None
307
+
308
+ @property
309
+ def shape(self) -> tuple[int, int]:
310
+ """Return the ``(rows, dim)`` shape of the export, honouring ``limit``."""
311
+ if self._shape is None:
312
+ table = np.load(self.emb, mmap_mode="r")
313
+ rows = table.shape[0] if self.limit is None else min(self.limit, table.shape[0])
314
+ self._shape = (int(rows), int(table.shape[1]))
315
+ return self._shape
316
+
317
+ @property
318
+ def dim(self) -> int:
319
+ """Embedding dimensionality."""
320
+ return self.shape[1]
321
+
322
+ def describe(self) -> dict[str, object]:
323
+ """Return provenance fields for ``meta.json``."""
324
+ return {
325
+ "source_kind": "npy",
326
+ "emb_source": str(self.emb),
327
+ "names_source": str(self.names),
328
+ "source_rows": self.shape[0],
329
+ "limit": self.limit,
330
+ }
331
+
332
+ def batches(self) -> Iterator[RowBatch]:
333
+ """Yield row batches, converting reversed CC names to forward host keys."""
334
+ table = np.load(self.emb, mmap_mode="r")
335
+ total = self.shape[0]
336
+ with self.names.open("r", encoding="utf-8", errors="replace") as handle:
337
+ start = 0
338
+ while start < total:
339
+ stop = min(start + self.batch_rows, total)
340
+ keys = []
341
+ for _ in range(stop - start):
342
+ line = handle.readline()
343
+ if not line:
344
+ break
345
+ keys.append(to_host_key(line.strip(), reversed_host=self.reversed_names))
346
+ if not keys:
347
+ break
348
+ stop = start + len(keys)
349
+ yield RowBatch(
350
+ row_ids=np.arange(start, stop, dtype=np.int64),
351
+ host_keys=keys,
352
+ vectors=np.asarray(table[start:stop], dtype=np.float32),
353
+ )
354
+ start = stop
355
+
356
+
357
+ def load_hf_token(explicit: str | None = None) -> str | None:
358
+ """Resolve a HuggingFace token from an explicit value, the environment, or the CLI cache.
359
+
360
+ Args:
361
+ explicit: A token passed directly; wins when set.
362
+
363
+ Returns:
364
+ The token, or ``None`` when no token is configured.
365
+ """
366
+ if explicit:
367
+ return explicit
368
+ for var in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
369
+ value = os.environ.get(var)
370
+ if value:
371
+ return value
372
+ cached = Path.home() / ".cache" / "huggingface" / "token"
373
+ if cached.is_file():
374
+ text = cached.read_text(encoding="utf-8").strip()
375
+ if text:
376
+ return text
377
+ return None
378
+
379
+
380
+ def read_release_manifest(
381
+ repo: str, token: str | None, revision: str = "main"
382
+ ) -> dict[str, object]:
383
+ """Fetch a release ``manifest.json`` from a HuggingFace dataset repo.
384
+
385
+ The manifest carries the ``release_id`` and ``model_space_version`` that make a built index
386
+ self-describing; a stale index is only detectable if these are copied across.
387
+
388
+ Args:
389
+ repo: Dataset repo id.
390
+ token: Bearer token for private repositories.
391
+ revision: Git revision.
392
+
393
+ Returns:
394
+ The parsed manifest, or an empty dict when it cannot be read.
395
+ """
396
+ url = _HF_RESOLVE.format(repo=repo, revision=revision, path="manifest.json")
397
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
398
+ try:
399
+ request = urllib.request.Request(url, headers=headers) # https URL
400
+ with urllib.request.urlopen(request) as resp:
401
+ return json.loads(resp.read().decode("utf-8"))
402
+ except (OSError, ValueError):
403
+ return {}
wgl/logging.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured logging and run-directory management.
2
+
3
+ Every training/eval invocation creates a timestamped run directory under ``runs/<name>/<ts>/``
4
+ containing the resolved ``config.yaml``, a human-readable ``log.txt``, and a machine-readable
5
+ ``metrics.jsonl`` (one JSON object per logged step). Logging goes to both stdout (rich) and the
6
+ log file, so the workflow is friendly to humans and agents alike.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from rich.logging import RichHandler
19
+
20
+ _LOGGER_NAME = "wgl"
21
+
22
+
23
+ def get_logger() -> logging.Logger:
24
+ """Return the package logger (configured by :func:`setup_logging`)."""
25
+ return logging.getLogger(_LOGGER_NAME)
26
+
27
+
28
+ def setup_logging(log_file: Path | None = None, level: int = logging.INFO) -> logging.Logger:
29
+ """Configure the package logger with a rich stdout handler and optional file handler.
30
+
31
+ Args:
32
+ log_file: Optional path to also write plain-text logs to.
33
+ level: Logging level.
34
+
35
+ Returns:
36
+ The configured logger.
37
+ """
38
+ logger = logging.getLogger(_LOGGER_NAME)
39
+ logger.setLevel(level)
40
+ logger.handlers.clear()
41
+ logger.propagate = False
42
+
43
+ rich_handler = RichHandler(rich_tracebacks=True, show_path=False, omit_repeated_times=False)
44
+ rich_handler.setFormatter(logging.Formatter("%(message)s", datefmt="[%X]"))
45
+ logger.addHandler(rich_handler)
46
+
47
+ if log_file is not None:
48
+ log_file.parent.mkdir(parents=True, exist_ok=True)
49
+ file_handler = logging.FileHandler(log_file)
50
+ file_handler.setFormatter(
51
+ logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
52
+ )
53
+ logger.addHandler(file_handler)
54
+ return logger
55
+
56
+
57
+ def timestamp() -> str:
58
+ """Return a filesystem-safe timestamp string."""
59
+ return time.strftime("%Y%m%d-%H%M%S", time.localtime())
60
+
61
+
62
+ class RunDirectory:
63
+ """A timestamped output directory for a single experiment run.
64
+
65
+ Attributes:
66
+ path: The run directory path.
67
+ """
68
+
69
+ def __init__(self, runs_dir: Path, name: str) -> None:
70
+ """Create (or address) a run directory under ``runs_dir/name/<ts>``.
71
+
72
+ Args:
73
+ runs_dir: Root directory holding all runs.
74
+ name: Experiment name (a subdirectory).
75
+ """
76
+ self.path = Path(runs_dir) / name / timestamp()
77
+ self.path.mkdir(parents=True, exist_ok=True)
78
+ self._metrics_file = self.path / "metrics.jsonl"
79
+
80
+ @property
81
+ def config_path(self) -> Path:
82
+ """Path to the serialized resolved config."""
83
+ return self.path / "config.yaml"
84
+
85
+ @property
86
+ def log_path(self) -> Path:
87
+ """Path to the plain-text log file."""
88
+ return self.path / "log.txt"
89
+
90
+ @property
91
+ def checkpoint_path(self) -> Path:
92
+ """Path to the model checkpoint."""
93
+ return self.path / "model.pt"
94
+
95
+ def log_metrics(self, step: int, **metrics: Any) -> None:
96
+ """Append a metrics record to ``metrics.jsonl``.
97
+
98
+ Args:
99
+ step: Step or epoch index.
100
+ **metrics: Scalar metric values to record.
101
+ """
102
+ record = {"step": step, "wall_time": time.time(), **metrics}
103
+ with self._metrics_file.open("a") as fh:
104
+ fh.write(json.dumps(record) + "\n")
105
+
106
+ def read_metrics(self) -> list[dict[str, Any]]:
107
+ """Read all recorded metric rows back."""
108
+ if not self._metrics_file.exists():
109
+ return []
110
+ return [json.loads(line) for line in self._metrics_file.read_text().splitlines() if line]
111
+
112
+
113
+ def mark_last_run(reference_dir: Path, run_path: Path) -> None:
114
+ """Write a ``.last_run`` pointer file so the CLI/Makefile can find the latest run.
115
+
116
+ Args:
117
+ reference_dir: Directory to write the pointer into (typically the data dir).
118
+ run_path: The run directory to record.
119
+ """
120
+ reference_dir.mkdir(parents=True, exist_ok=True)
121
+ (reference_dir / ".last_run").write_text(str(run_path) + os.linesep)
wgl/release/__init__.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V1 release contract: canonical identity keys and cross-release manifests.
2
+
3
+ This package implements the *artifact contract* frozen in
4
+ [`docs/release-contract.md`](../../../docs/release-contract.md) (runbook-v2 Phase 0):
5
+
6
+ * :mod:`wgl.release.identity` — canonical, normalized host and domain/PLD keys (the stable
7
+ identifiers that survive across monthly releases, independent of Common Crawl's per-release
8
+ integer node ids);
9
+ * :mod:`wgl.release.manifest` — a per-release manifest (stable key -> per-release local node id +
10
+ graph evidence) and the cross-release index that joins consecutive releases into a stable identity
11
+ table with ``active``/``new``/``missing``/``reappeared`` lifecycle states.
12
+
13
+ These are deliberately model-agnostic: they describe *what is released and how it is joined* before
14
+ any embedding is trained.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from wgl.release.identity import (
20
+ ConfidenceBucket,
21
+ EvidenceCohort,
22
+ confidence_bucket,
23
+ domain_key,
24
+ evidence_cohort,
25
+ forward_host,
26
+ host_key,
27
+ is_dangling,
28
+ )
29
+ from wgl.release.manifest import (
30
+ SCHEMA_VERSION,
31
+ CrossReleaseEntry,
32
+ CrossReleaseIndex,
33
+ HostState,
34
+ ReleaseManifest,
35
+ ReleaseRecord,
36
+ )
37
+ from wgl.release.schema import (
38
+ SCHEMA,
39
+ Field,
40
+ FieldLocation,
41
+ default_row_fields,
42
+ fixed_metadata_bytes_per_row,
43
+ sidecar_row_fields,
44
+ )
45
+ from wgl.release.sizing import (
46
+ CC_DOMAIN_NODES_ESTIMATE,
47
+ CC_HOST_NODES,
48
+ SizeEstimate,
49
+ estimate_size,
50
+ format_table,
51
+ size_table,
52
+ )
53
+
54
+ __all__ = [
55
+ "CC_DOMAIN_NODES_ESTIMATE",
56
+ "CC_HOST_NODES",
57
+ "SCHEMA",
58
+ "SCHEMA_VERSION",
59
+ "ConfidenceBucket",
60
+ "CrossReleaseEntry",
61
+ "CrossReleaseIndex",
62
+ "EvidenceCohort",
63
+ "Field",
64
+ "FieldLocation",
65
+ "HostState",
66
+ "ReleaseManifest",
67
+ "ReleaseRecord",
68
+ "SizeEstimate",
69
+ "confidence_bucket",
70
+ "default_row_fields",
71
+ "domain_key",
72
+ "estimate_size",
73
+ "evidence_cohort",
74
+ "fixed_metadata_bytes_per_row",
75
+ "format_table",
76
+ "forward_host",
77
+ "host_key",
78
+ "is_dangling",
79
+ "sidecar_row_fields",
80
+ "size_table",
81
+ ]
wgl/release/identity.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical identity keys for the v1 release artifact.
2
+
3
+ Common Crawl distributes the host graph with **reversed** host names (``com.example.www``) and
4
+ **per-release, 0-based contiguous integer node ids**. The integer ids are *not* stable across
5
+ monthly releases — a host gets a fresh id every crawl — so they cannot be the artifact's identity.
6
+ The stable identifiers published in the artifact are *string keys* derived deterministically from
7
+ the host name:
8
+
9
+ * :func:`host_key` — the canonical normalized host (``www.example.com``);
10
+ * :func:`domain_key` — the registrable domain / pay-level domain, a.k.a. PLD (``example.com``).
11
+
12
+ Both are pure functions of the input string (no network, no per-release state) so the same host maps
13
+ to the same key in every release. The registrable domain is computed with the offline ``tldextract``
14
+ snapshot (no network fetch), matching :mod:`wgl.tasks.tld`.
15
+
16
+ This module also provides two small, *evidence-based* (not model-based) classifiers used in the
17
+ release metadata: :func:`evidence_cohort` (the structural cohort from the runbook's coverage table)
18
+ and :func:`confidence_bucket` (a reproducible confidence label derived only from observable graph
19
+ evidence). See [`docs/release-contract.md`](../../../docs/release-contract.md).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from enum import StrEnum
25
+
26
+ import tldextract
27
+
28
+ # Offline extractor: rely on the snapshot bundled with tldextract (no network fetch), exactly as
29
+ # wgl.tasks.tld does, so host_to_tld and domain_key agree on the public-suffix boundary.
30
+ _EXTRACT = tldextract.TLDExtract(suffix_list_urls=())
31
+
32
+
33
+ def forward_host(name: str, reversed_host: bool = True) -> str:
34
+ """Return the forward (human) host name for a node name.
35
+
36
+ Args:
37
+ name: The host name, possibly in Common Crawl reversed notation (``com.example.www``).
38
+ reversed_host: Whether ``name`` is reversed. When False, ``name`` is returned normalized
39
+ but with its label order unchanged.
40
+
41
+ Returns:
42
+ The forward host (``www.example.com``), lowercased with surrounding/trailing dots stripped.
43
+ """
44
+ text = name.strip().strip(".").lower()
45
+ if not text:
46
+ return ""
47
+ if reversed_host:
48
+ return ".".join(reversed(text.split(".")))
49
+ return text
50
+
51
+
52
+ def host_key(name: str, reversed_host: bool = True) -> str:
53
+ """Return the canonical, normalized **host key** for a node name.
54
+
55
+ The host key is the stable cross-release identifier for a host vertex. It is the forward host
56
+ name, lowercased, with whitespace and surrounding dots stripped. We deliberately keep the name
57
+ as published (ASCII/punycode labels are left as-is rather than IDNA-decoded) so the key is a
58
+ pure, reversible function of the source string — see the release contract for the rationale.
59
+
60
+ Args:
61
+ name: The (possibly reversed) Common Crawl host name.
62
+ reversed_host: Whether ``name`` is in reversed notation.
63
+
64
+ Returns:
65
+ The canonical host key (``www.example.com``); empty string for an empty input.
66
+ """
67
+ return forward_host(name, reversed_host=reversed_host)
68
+
69
+
70
+ def domain_key(name: str, reversed_host: bool = True) -> str:
71
+ """Return the **domain/PLD key** (registrable domain) for a node name.
72
+
73
+ The registrable domain (eTLD+1, Common Crawl's "pay-level domain") groups every host under its
74
+ owning domain — both an independently useful artifact and the fallback representation for sparse
75
+ hosts. Computed from the public-suffix list via the offline ``tldextract`` snapshot.
76
+
77
+ Args:
78
+ name: The (possibly reversed) Common Crawl host name.
79
+ reversed_host: Whether ``name`` is in reversed notation.
80
+
81
+ Returns:
82
+ The registrable domain (``example.com``). When the host has no registrable domain above
83
+ its public suffix (e.g. the name *is* a public suffix, or an IP/onion address), falls back
84
+ to the canonical host key so every vertex still maps to a non-empty domain key.
85
+ """
86
+ host = forward_host(name, reversed_host=reversed_host)
87
+ if not host:
88
+ return ""
89
+ ext = _EXTRACT(host)
90
+ if ext.domain and ext.suffix:
91
+ return f"{ext.domain}.{ext.suffix}"
92
+ # No registrable domain above the public suffix (bare suffix, IP, etc.): host is its own domain.
93
+ return host
94
+
95
+
96
+ class EvidenceCohort(StrEnum):
97
+ """Structural cohort of a host from its directed graph evidence (runbook-v2 coverage table)."""
98
+
99
+ BIDIRECTIONAL = "bidirectional" # has both in-links and out-links
100
+ IN_ONLY = "in_only" # has in-links, no out-links (dangling sink)
101
+ OUT_ONLY = "out_only" # has out-links, no in-links (dangling source)
102
+ ISOLATED = "isolated" # graph-isolated after preprocessing (no edges)
103
+
104
+
105
+ class ConfidenceBucket(StrEnum):
106
+ """Reproducible confidence label from graph evidence only (not model-certainty claims)."""
107
+
108
+ HIGH = "high" # well-observed: links in both directions
109
+ MEDIUM = "medium" # one-directional evidence
110
+ LOW = "low" # graph-isolated; relies on metadata / domain fallback
111
+
112
+
113
+ def evidence_cohort(in_degree: int, out_degree: int) -> EvidenceCohort:
114
+ """Classify a host into its structural evidence cohort.
115
+
116
+ Args:
117
+ in_degree: Number of in-links (predecessors) in the release graph.
118
+ out_degree: Number of out-links (successors) in the release graph.
119
+
120
+ Returns:
121
+ The :class:`EvidenceCohort`.
122
+ """
123
+ has_in = in_degree > 0
124
+ has_out = out_degree > 0
125
+ if has_in and has_out:
126
+ return EvidenceCohort.BIDIRECTIONAL
127
+ if has_in:
128
+ return EvidenceCohort.IN_ONLY
129
+ if has_out:
130
+ return EvidenceCohort.OUT_ONLY
131
+ return EvidenceCohort.ISOLATED
132
+
133
+
134
+ def confidence_bucket(in_degree: int, out_degree: int) -> ConfidenceBucket:
135
+ """Map graph evidence to a reproducible confidence bucket.
136
+
137
+ The bucket reflects *how much directed evidence* supports the host's representation, not any
138
+ model-certainty claim — so it is deterministic and reproducible from the published metadata.
139
+
140
+ Args:
141
+ in_degree: Number of in-links in the release graph.
142
+ out_degree: Number of out-links in the release graph.
143
+
144
+ Returns:
145
+ The :class:`ConfidenceBucket`.
146
+ """
147
+ cohort = evidence_cohort(in_degree, out_degree)
148
+ if cohort is EvidenceCohort.BIDIRECTIONAL:
149
+ return ConfidenceBucket.HIGH
150
+ if cohort is EvidenceCohort.ISOLATED:
151
+ return ConfidenceBucket.LOW
152
+ return ConfidenceBucket.MEDIUM
153
+
154
+
155
+ def is_dangling(out_degree: int) -> bool:
156
+ """Whether a host is *dangling* (no out-links) — the dominant CC host-graph cohort (~75%)."""
157
+ return out_degree == 0
wgl/release/manifest.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-release manifest and the cross-release identity index.
2
+
3
+ A **release manifest** records, for one monthly artifact, every host vertex's stable key together
4
+ with the *ephemeral* per-release local node id and the graph evidence used for confidence/cohort
5
+ metadata. The **cross-release index** joins consecutive manifests on the stable host key to produce
6
+ a durable identity table with lifecycle states (``active``/``new``/``missing``/``reappeared``) — the
7
+ mechanism that lets users compare consecutive releases even though Common Crawl reassigns
8
+ integer node ids every crawl.
9
+
10
+ This is the Phase 0.1 contract object. At full web scale the per-release host table is sharded
11
+ (Phase 3.3); here it serializes to a small JSON header + a TSV of host rows, which is enough for the
12
+ synthetic-release gate and for downsampled CC slices.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import csv
18
+ import json
19
+ from collections import Counter
20
+ from collections.abc import Iterable, Sequence
21
+ from dataclasses import dataclass, field
22
+ from enum import StrEnum
23
+ from pathlib import Path
24
+
25
+ from wgl.data.graph import DirectedGraph
26
+ from wgl.release.identity import (
27
+ confidence_bucket,
28
+ domain_key,
29
+ evidence_cohort,
30
+ host_key,
31
+ is_dangling,
32
+ )
33
+
34
+ # Bumped when the manifest/host-row schema changes in a backward-incompatible way.
35
+ SCHEMA_VERSION = "0.1.0"
36
+
37
+ _HOST_COLUMNS = (
38
+ "host_key",
39
+ "domain_key",
40
+ "local_node_id",
41
+ "in_degree",
42
+ "out_degree",
43
+ "is_dangling",
44
+ "cohort",
45
+ "confidence",
46
+ )
47
+
48
+
49
+ class HostState(StrEnum):
50
+ """Lifecycle state of a host key *as of* a given release, relative to prior releases."""
51
+
52
+ NEW = "new" # present now, never seen in any earlier release
53
+ ACTIVE = "active" # present now and in the immediately-preceding release (persistent)
54
+ REAPPEARED = "reappeared" # present now, absent in the previous release, seen in an earlier one
55
+ MISSING = "missing" # absent now, but present in some earlier release
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ReleaseRecord:
60
+ """One host's row within a single release manifest.
61
+
62
+ Attributes:
63
+ host_key: Canonical stable host key (:func:`wgl.release.identity.host_key`).
64
+ domain_key: Registrable domain / PLD key.
65
+ local_node_id: The host's integer id *in this release's graph* (ephemeral across releases).
66
+ in_degree: In-links in this release.
67
+ out_degree: Out-links in this release.
68
+ is_dangling: Whether the host has no out-links.
69
+ cohort: Structural evidence cohort value.
70
+ confidence: Reproducible confidence-bucket value.
71
+ """
72
+
73
+ host_key: str
74
+ domain_key: str
75
+ local_node_id: int
76
+ in_degree: int
77
+ out_degree: int
78
+ is_dangling: bool
79
+ cohort: str
80
+ confidence: str
81
+
82
+ @classmethod
83
+ def from_evidence(
84
+ cls, key: str, dom: str, local_node_id: int, in_degree: int, out_degree: int
85
+ ) -> ReleaseRecord:
86
+ """Build a record, deriving the cohort/confidence/dangling fields from degrees."""
87
+ return cls(
88
+ host_key=key,
89
+ domain_key=dom,
90
+ local_node_id=int(local_node_id),
91
+ in_degree=int(in_degree),
92
+ out_degree=int(out_degree),
93
+ is_dangling=is_dangling(out_degree),
94
+ cohort=evidence_cohort(in_degree, out_degree).value,
95
+ confidence=confidence_bucket(in_degree, out_degree).value,
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class ReleaseManifest:
101
+ """A single release's host manifest: stable keys -> per-release evidence.
102
+
103
+ Attributes:
104
+ release_id: Stable release identifier (e.g. ``cc-main-2026-mar-apr-may``).
105
+ source_crawls: The constituent monthly crawl ids (1-3 under the rolling-window policy).
106
+ records: Host rows keyed by ``host_key`` (insertion order = local node id order).
107
+ schema_version: Manifest schema version.
108
+ model_space_version: Embedding model-space tag; ``None`` until embeddings are trained.
109
+ """
110
+
111
+ release_id: str
112
+ source_crawls: list[str]
113
+ records: dict[str, ReleaseRecord] = field(default_factory=dict)
114
+ schema_version: str = SCHEMA_VERSION
115
+ model_space_version: str | None = None
116
+
117
+ @property
118
+ def host_keys(self) -> set[str]:
119
+ """The set of host keys present in this release."""
120
+ return set(self.records)
121
+
122
+ def __len__(self) -> int:
123
+ """Number of host records in the manifest."""
124
+ return len(self.records)
125
+
126
+ @classmethod
127
+ def from_graph(
128
+ cls,
129
+ graph: DirectedGraph,
130
+ release_id: str,
131
+ source_crawls: Sequence[str],
132
+ reversed_host: bool = True,
133
+ model_space_version: str | None = None,
134
+ ) -> ReleaseManifest:
135
+ """Build a manifest from a prepared :class:`DirectedGraph`.
136
+
137
+ Args:
138
+ graph: The release graph (its ``node_names`` provide the host strings).
139
+ release_id: Stable release identifier.
140
+ source_crawls: Constituent crawl ids for this release.
141
+ reversed_host: Whether ``node_names`` use Common Crawl reversed notation.
142
+ model_space_version: Optional model-space tag.
143
+
144
+ Returns:
145
+ The populated :class:`ReleaseManifest`.
146
+
147
+ Raises:
148
+ ValueError: If the graph has no node names, or two nodes collide on the same host key.
149
+ """
150
+ if graph.node_names is None:
151
+ msg = "ReleaseManifest.from_graph requires graph.node_names"
152
+ raise ValueError(msg)
153
+ in_deg = graph.in_degree()
154
+ out_deg = graph.out_degree()
155
+ records: dict[str, ReleaseRecord] = {}
156
+ for nid, name in enumerate(graph.node_names):
157
+ key = host_key(name, reversed_host=reversed_host)
158
+ if key in records:
159
+ msg = f"duplicate host key {key!r} (nodes {records[key].local_node_id} and {nid})"
160
+ raise ValueError(msg)
161
+ records[key] = ReleaseRecord.from_evidence(
162
+ key, domain_key(name, reversed_host=reversed_host), nid, in_deg[nid], out_deg[nid]
163
+ )
164
+ return cls(
165
+ release_id=release_id,
166
+ source_crawls=list(source_crawls),
167
+ records=records,
168
+ model_space_version=model_space_version,
169
+ )
170
+
171
+ def save(self, directory: str | Path) -> None:
172
+ """Persist the manifest to ``directory`` (``manifest.json`` header + ``hosts.tsv`` rows)."""
173
+ directory = Path(directory)
174
+ directory.mkdir(parents=True, exist_ok=True)
175
+ header = {
176
+ "release_id": self.release_id,
177
+ "source_crawls": self.source_crawls,
178
+ "schema_version": self.schema_version,
179
+ "model_space_version": self.model_space_version,
180
+ "num_hosts": len(self.records),
181
+ }
182
+ (directory / "manifest.json").write_text(json.dumps(header, indent=2))
183
+ with (directory / "hosts.tsv").open("w", newline="") as fh:
184
+ writer = csv.writer(fh, delimiter="\t")
185
+ writer.writerow(_HOST_COLUMNS)
186
+ for rec in self.records.values():
187
+ writer.writerow(
188
+ [
189
+ rec.host_key,
190
+ rec.domain_key,
191
+ rec.local_node_id,
192
+ rec.in_degree,
193
+ rec.out_degree,
194
+ int(rec.is_dangling),
195
+ rec.cohort,
196
+ rec.confidence,
197
+ ]
198
+ )
199
+
200
+ @classmethod
201
+ def load(cls, directory: str | Path) -> ReleaseManifest:
202
+ """Load a manifest previously written by :meth:`save`."""
203
+ directory = Path(directory)
204
+ header = json.loads((directory / "manifest.json").read_text())
205
+ records: dict[str, ReleaseRecord] = {}
206
+ with (directory / "hosts.tsv").open(newline="") as fh:
207
+ for row in csv.DictReader(fh, delimiter="\t"):
208
+ records[row["host_key"]] = ReleaseRecord(
209
+ host_key=row["host_key"],
210
+ domain_key=row["domain_key"],
211
+ local_node_id=int(row["local_node_id"]),
212
+ in_degree=int(row["in_degree"]),
213
+ out_degree=int(row["out_degree"]),
214
+ is_dangling=bool(int(row["is_dangling"])),
215
+ cohort=row["cohort"],
216
+ confidence=row["confidence"],
217
+ )
218
+ return cls(
219
+ release_id=header["release_id"],
220
+ source_crawls=list(header["source_crawls"]),
221
+ records=records,
222
+ schema_version=header.get("schema_version", SCHEMA_VERSION),
223
+ model_space_version=header.get("model_space_version"),
224
+ )
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class CrossReleaseEntry:
229
+ """A host key's durable cross-release identity row.
230
+
231
+ Attributes:
232
+ host_key: The stable host key.
233
+ first_release: Release id where the key was first observed.
234
+ last_seen_release: Most recent release where the key was present.
235
+ state: Lifecycle state as of the latest release in the index.
236
+ local_node_ids: Map ``release_id -> local node id`` for releases where the key was present.
237
+ """
238
+
239
+ host_key: str
240
+ first_release: str
241
+ last_seen_release: str
242
+ state: HostState
243
+ local_node_ids: dict[str, int]
244
+
245
+
246
+ @dataclass
247
+ class CrossReleaseIndex:
248
+ """Stable identity table joining an ordered sequence of release manifests on the host key.
249
+
250
+ The lifecycle ``state`` of each key is computed *relative to the latest release* in the
251
+ sequence: a key present in the latest release is ``new`` (never seen before), ``active`` (also
252
+ in the immediately-preceding release), or ``reappeared`` (absent in the previous release but
253
+ seen earlier); a key absent from the latest release but seen in any earlier one is ``missing``.
254
+ """
255
+
256
+ release_ids: list[str]
257
+ entries: dict[str, CrossReleaseEntry]
258
+
259
+ @classmethod
260
+ def build(cls, manifests: Iterable[ReleaseManifest]) -> CrossReleaseIndex:
261
+ """Join an *ordered* (oldest-to-newest) sequence of manifests into a cross-release index.
262
+
263
+ Args:
264
+ manifests: Release manifests in chronological order. At least one is required.
265
+
266
+ Returns:
267
+ The :class:`CrossReleaseIndex`.
268
+
269
+ Raises:
270
+ ValueError: If no manifests are given, or two share a release id.
271
+ """
272
+ mans = list(manifests)
273
+ if not mans:
274
+ msg = "CrossReleaseIndex.build requires at least one manifest"
275
+ raise ValueError(msg)
276
+ release_ids = [m.release_id for m in mans]
277
+ if len(set(release_ids)) != len(release_ids):
278
+ msg = f"duplicate release ids: {release_ids}"
279
+ raise ValueError(msg)
280
+
281
+ latest_id = release_ids[-1]
282
+ prev_id = release_ids[-2] if len(mans) >= 2 else None
283
+
284
+ entries: dict[str, CrossReleaseEntry] = {}
285
+ all_keys = set().union(*(m.host_keys for m in mans))
286
+ for key in all_keys:
287
+ present = [m for m in mans if key in m.records]
288
+ local_ids = {m.release_id: m.records[key].local_node_id for m in present}
289
+ entries[key] = CrossReleaseEntry(
290
+ host_key=key,
291
+ first_release=present[0].release_id,
292
+ last_seen_release=present[-1].release_id,
293
+ state=_state_for(set(local_ids), latest_id, prev_id),
294
+ local_node_ids=local_ids,
295
+ )
296
+ return cls(release_ids=release_ids, entries=entries)
297
+
298
+ def state_counts(self) -> dict[str, int]:
299
+ """Return a ``{state_value: count}`` summary over all entries."""
300
+ return dict(Counter(e.state.value for e in self.entries.values()))
301
+
302
+ def keys_in_state(self, state: HostState) -> set[str]:
303
+ """Return the host keys currently in the given lifecycle ``state``."""
304
+ return {k for k, e in self.entries.items() if e.state is state}
305
+
306
+
307
+ def _state_for(present_releases: set[str], latest_id: str, prev_id: str | None) -> HostState:
308
+ """Lifecycle state of a key from the set of release ids it appears in.
309
+
310
+ Args:
311
+ present_releases: Release ids in which the key is present (non-empty — every key reaches
312
+ this function from the union of all manifests, so ``MISSING`` is the residual case).
313
+ latest_id: The newest release id in the index.
314
+ prev_id: The immediately-preceding release id, or ``None`` when only one release exists.
315
+
316
+ Returns:
317
+ ``MISSING`` if absent from the latest release; otherwise ``ACTIVE`` (also in the previous
318
+ release), ``REAPPEARED`` (absent in the previous release but seen in an earlier one), or
319
+ ``NEW`` (present only in the latest release).
320
+ """
321
+ if latest_id not in present_releases:
322
+ return HostState.MISSING
323
+ if prev_id is not None and prev_id in present_releases:
324
+ return HostState.ACTIVE
325
+ # Present now but not in the immediately-previous release: seen earlier => reappeared, else new.
326
+ seen_before_latest = present_releases - {latest_id}
327
+ return HostState.REAPPEARED if seen_before_latest else HostState.NEW
wgl/release/schema.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Declarative schema for the v1 release artifact (runbook-v2 Phase 0.2).
2
+
3
+ The default download stays **compact**: one row per host (or per domain) with the stable key, the
4
+ default embedding, and lightweight evidence/graph-context metadata. Larger diagnostics and any
5
+ component/role vectors live in optional **sidecars** so they do not inflate the default artifact.
6
+
7
+ Fields are split by *location*:
8
+
9
+ * ``HEADER`` — one value per release (lives in the manifest header, not repeated per row);
10
+ * ``ROW_DEFAULT`` — per-host/-domain, shipped in the default artifact;
11
+ * ``ROW_SIDECAR`` — per-host/-domain, optional, shipped only on demand.
12
+
13
+ This module is the single source of truth for which fields exist, their on-disk width, and where
14
+ they live; :mod:`wgl.release.sizing` consumes the fixed-width metadata cost to estimate artifact
15
+ sizes. See [`docs/release-contract.md`](../../../docs/release-contract.md) §0.2.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from enum import StrEnum
22
+
23
+
24
+ class FieldLocation(StrEnum):
25
+ """Where a schema field lives in the released artifact."""
26
+
27
+ HEADER = "header" # one value per release (manifest header)
28
+ ROW_DEFAULT = "row_default" # per node, in the default artifact
29
+ ROW_SIDECAR = "row_sidecar" # per node, optional sidecar
30
+
31
+
32
+ class FieldKind(StrEnum):
33
+ """Coarse value kind, used by the size model."""
34
+
35
+ KEY = "key" # variable-length string identifier
36
+ VECTOR = "vector" # the embedding (size = dim x precision, handled by sizing)
37
+ SCALAR = "scalar" # fixed-width numeric / categorical
38
+ META = "meta" # release-level metadata (header)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Field:
43
+ """One artifact field.
44
+
45
+ Attributes:
46
+ name: Column name.
47
+ dtype: On-disk dtype label (e.g. ``uint32``, ``float32``, ``uint8``, ``str``).
48
+ location: Where the field lives (:class:`FieldLocation`).
49
+ kind: Value kind (:class:`FieldKind`).
50
+ nbytes: Fixed on-disk width in bytes for a single value, or ``None`` for variable-width
51
+ (the key) and the embedding (whose width is ``dim x precision``, computed by sizing).
52
+ doc: One-line description.
53
+ """
54
+
55
+ name: str
56
+ dtype: str
57
+ location: FieldLocation
58
+ kind: FieldKind
59
+ nbytes: int | None
60
+ doc: str
61
+
62
+
63
+ # --- The v1 schema (compact default + optional sidecar) -----------------------------------------
64
+ #
65
+ # Degree maxima from the cc-main-2026-mar-apr-may host stats (maxindegree ~19.6M, maxoutdegree
66
+ # ~7.0M, nodes ~262.35M) all fit uint32, so no field needs 64-bit width.
67
+
68
+ SCHEMA: tuple[Field, ...] = (
69
+ # Release-level (header): one value per release, not repeated per row.
70
+ Field("release_id", "str", FieldLocation.HEADER, FieldKind.META, None, "Stable release id."),
71
+ Field(
72
+ "source_crawls",
73
+ "list[str]",
74
+ FieldLocation.HEADER,
75
+ FieldKind.META,
76
+ None,
77
+ "Constituent crawl ids (<=3, rolling-window policy).",
78
+ ),
79
+ Field(
80
+ "model_space_version",
81
+ "str",
82
+ FieldLocation.HEADER,
83
+ FieldKind.META,
84
+ None,
85
+ "Embedding space tag; vectors comparable only within the same value.",
86
+ ),
87
+ # Per-node default artifact.
88
+ Field(
89
+ "host_or_domain_key",
90
+ "str",
91
+ FieldLocation.ROW_DEFAULT,
92
+ FieldKind.KEY,
93
+ None,
94
+ "Stable host or domain/PLD key (the join identity).",
95
+ ),
96
+ Field(
97
+ "release_local_node_id",
98
+ "uint32",
99
+ FieldLocation.ROW_DEFAULT,
100
+ FieldKind.SCALAR,
101
+ 4,
102
+ "Ephemeral per-release integer id (for joining to this release's graph).",
103
+ ),
104
+ Field(
105
+ "embedding",
106
+ "float32",
107
+ FieldLocation.ROW_DEFAULT,
108
+ FieldKind.VECTOR,
109
+ None,
110
+ "Default L2-normalized embedding (width = dim x precision).",
111
+ ),
112
+ Field(
113
+ "confidence_bucket",
114
+ "uint8",
115
+ FieldLocation.ROW_DEFAULT,
116
+ FieldKind.SCALAR,
117
+ 1,
118
+ "Reproducible evidence-based confidence (high/medium/low).",
119
+ ),
120
+ Field(
121
+ "feature_availability",
122
+ "uint8",
123
+ FieldLocation.ROW_DEFAULT,
124
+ FieldKind.SCALAR,
125
+ 1,
126
+ "Bitmask of which feature groups were available for this node.",
127
+ ),
128
+ Field(
129
+ "in_degree", "uint32", FieldLocation.ROW_DEFAULT, FieldKind.SCALAR, 4, "In-links in release"
130
+ ),
131
+ Field(
132
+ "out_degree",
133
+ "uint32",
134
+ FieldLocation.ROW_DEFAULT,
135
+ FieldKind.SCALAR,
136
+ 4,
137
+ "Out-links in release.",
138
+ ),
139
+ Field(
140
+ "is_dangling",
141
+ "uint8",
142
+ FieldLocation.ROW_DEFAULT,
143
+ FieldKind.SCALAR,
144
+ 1,
145
+ "1 if the node has no out-links.",
146
+ ),
147
+ Field(
148
+ "component_bucket",
149
+ "uint8",
150
+ FieldLocation.ROW_DEFAULT,
151
+ FieldKind.SCALAR,
152
+ 1,
153
+ "Giant-SCC membership / coarse SCC-size bucket.",
154
+ ),
155
+ Field(
156
+ "pagerank",
157
+ "float32",
158
+ FieldLocation.ROW_DEFAULT,
159
+ FieldKind.SCALAR,
160
+ 4,
161
+ "Directed PageRank (from wgl.features).",
162
+ ),
163
+ Field(
164
+ "harmonic_centrality",
165
+ "float32",
166
+ FieldLocation.ROW_DEFAULT,
167
+ FieldKind.SCALAR,
168
+ 4,
169
+ "Harmonic centrality where available.",
170
+ ),
171
+ # Optional sidecars (kept out of the default download).
172
+ Field(
173
+ "reverse_pagerank",
174
+ "float32",
175
+ FieldLocation.ROW_SIDECAR,
176
+ FieldKind.SCALAR,
177
+ 4,
178
+ "Reverse (in-link) PageRank.",
179
+ ),
180
+ Field(
181
+ "source_role_embedding",
182
+ "float32",
183
+ FieldLocation.ROW_SIDECAR,
184
+ FieldKind.VECTOR,
185
+ None,
186
+ "Out-role/source component vector (when published separately).",
187
+ ),
188
+ Field(
189
+ "target_role_embedding",
190
+ "float32",
191
+ FieldLocation.ROW_SIDECAR,
192
+ FieldKind.VECTOR,
193
+ None,
194
+ "In-role/target component vector (when published separately).",
195
+ ),
196
+ Field(
197
+ "scc_id",
198
+ "uint32",
199
+ FieldLocation.ROW_SIDECAR,
200
+ FieldKind.SCALAR,
201
+ 4,
202
+ "Full strongly-connected-component id (diagnostic).",
203
+ ),
204
+ )
205
+
206
+
207
+ def fields_at(location: FieldLocation) -> tuple[Field, ...]:
208
+ """Return the schema fields at the given location."""
209
+ return tuple(f for f in SCHEMA if f.location is location)
210
+
211
+
212
+ def default_row_fields() -> tuple[Field, ...]:
213
+ """Per-node fields shipped in the default artifact."""
214
+ return fields_at(FieldLocation.ROW_DEFAULT)
215
+
216
+
217
+ def sidecar_row_fields() -> tuple[Field, ...]:
218
+ """Per-node fields kept in optional sidecars."""
219
+ return fields_at(FieldLocation.ROW_SIDECAR)
220
+
221
+
222
+ def header_fields() -> tuple[Field, ...]:
223
+ """Release-level header fields."""
224
+ return fields_at(FieldLocation.HEADER)
225
+
226
+
227
+ def fixed_metadata_bytes_per_row() -> int:
228
+ """Fixed-width on-disk bytes per default row, excluding the variable key and the embedding.
229
+
230
+ This is the metadata overhead the size model adds on top of the embedding bytes (the key is
231
+ sized separately because it is variable-length).
232
+ """
233
+ return sum(
234
+ f.nbytes
235
+ for f in default_row_fields()
236
+ if f.kind is FieldKind.SCALAR and f.nbytes is not None
237
+ )
wgl/release/sizing.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Artifact size model for selecting the v1 embedding dimension and precision (Phase 0.2).
2
+
3
+ Estimates the uncompressed and (approximate) compressed footprint of the host and domain artifacts
4
+ across candidate dimensions (64/128/256) and precisions (fp32/fp16/int8). The dominant term is the
5
+ embedding matrix (``num_nodes x dim x bytes_per_value``); the schema's fixed-width metadata
6
+ (:func:`wgl.release.schema.fixed_metadata_bytes_per_row`) plus the variable key add a smaller,
7
+ dimension-independent overhead.
8
+
9
+ Compression ratios are deliberate, documented estimates: L2-normalized float vectors are
10
+ high-entropy and compress poorly, so the ratios are near 1.0 for floats and lower only for int8 and
11
+ for the repetitive metadata column block. They are decision aids, not guarantees — the real ratio is
12
+ measured at export time (Phase 3.3). Node counts come from the real cc-main-2026-mar-apr-may host
13
+ stats; the domain count is an estimate until the domain graph is extracted.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from wgl.release.schema import fixed_metadata_bytes_per_row
21
+
22
+ # Real host-graph node count (cc-main-2026-mar-apr-may host.stats: nodes=262351908).
23
+ CC_HOST_NODES = 262_351_908
24
+ # Domain/PLD node count is not yet extracted on the cluster (host graph only). Registrable domains
25
+ # typically number ~40-55% of hosts for these crawls; use ~135M as a planning estimate, replaced by
26
+ # the measured count when the domain graph is prepared.
27
+ CC_DOMAIN_NODES_ESTIMATE = 135_000_000
28
+
29
+ # On-disk bytes per embedding value by precision label.
30
+ PRECISION_BYTES = {"fp32": 4, "fp16": 2, "int8": 1}
31
+
32
+ # Approximate compression ratios (compressed / uncompressed). Floats barely compress; int8 quantized
33
+ # vectors and the small-int metadata block compress more. Estimates — verified at export time.
34
+ _VECTOR_COMPRESSION = {"fp32": 0.95, "fp16": 0.95, "int8": 0.70}
35
+ _METADATA_COMPRESSION = 0.45
36
+
37
+ # Average bytes for a stable key string (forward host / domain). Estimate; tune from real keys.
38
+ DEFAULT_AVG_KEY_BYTES = 24
39
+
40
+ _GIB = 1024**3
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class SizeEstimate:
45
+ """Estimated footprint of one artifact at a given dimension and precision.
46
+
47
+ Attributes:
48
+ num_nodes: Number of rows (hosts or domains).
49
+ dim: Embedding dimension.
50
+ precision: Precision label (``fp32``/``fp16``/``int8``).
51
+ vector_bytes: Uncompressed embedding-matrix bytes.
52
+ metadata_bytes: Uncompressed default-row metadata bytes (key + fixed fields).
53
+ total_uncompressed: ``vector_bytes + metadata_bytes``.
54
+ total_compressed: Approximate compressed total.
55
+ """
56
+
57
+ num_nodes: int
58
+ dim: int
59
+ precision: str
60
+ vector_bytes: int
61
+ metadata_bytes: int
62
+ total_uncompressed: int
63
+ total_compressed: int
64
+
65
+ @property
66
+ def vector_gib(self) -> float:
67
+ """Uncompressed embedding-matrix size in GiB."""
68
+ return self.vector_bytes / _GIB
69
+
70
+ @property
71
+ def total_uncompressed_gib(self) -> float:
72
+ """Uncompressed total in GiB."""
73
+ return self.total_uncompressed / _GIB
74
+
75
+ @property
76
+ def total_compressed_gib(self) -> float:
77
+ """Approximate compressed total in GiB."""
78
+ return self.total_compressed / _GIB
79
+
80
+
81
+ def estimate_size(
82
+ num_nodes: int,
83
+ dim: int,
84
+ precision: str = "fp32",
85
+ avg_key_bytes: int = DEFAULT_AVG_KEY_BYTES,
86
+ ) -> SizeEstimate:
87
+ """Estimate the footprint of one artifact.
88
+
89
+ Args:
90
+ num_nodes: Number of rows (hosts or domains).
91
+ dim: Embedding dimension (the *published* default vector width, not any internal
92
+ ComplEx real+imag doubling).
93
+ precision: One of ``fp32``/``fp16``/``int8``.
94
+ avg_key_bytes: Average bytes for a stable key string.
95
+
96
+ Returns:
97
+ The :class:`SizeEstimate`.
98
+
99
+ Raises:
100
+ ValueError: On an unknown precision or non-positive dim/nodes.
101
+ """
102
+ if precision not in PRECISION_BYTES:
103
+ msg = f"unknown precision {precision!r}; expected one of {sorted(PRECISION_BYTES)}"
104
+ raise ValueError(msg)
105
+ if dim <= 0 or num_nodes <= 0:
106
+ msg = f"dim and num_nodes must be positive (got dim={dim}, num_nodes={num_nodes})"
107
+ raise ValueError(msg)
108
+
109
+ vector_bytes = num_nodes * dim * PRECISION_BYTES[precision]
110
+ metadata_bytes = num_nodes * (avg_key_bytes + fixed_metadata_bytes_per_row())
111
+ total_uncompressed = vector_bytes + metadata_bytes
112
+ total_compressed = round(
113
+ vector_bytes * _VECTOR_COMPRESSION[precision] + metadata_bytes * _METADATA_COMPRESSION
114
+ )
115
+ return SizeEstimate(
116
+ num_nodes=num_nodes,
117
+ dim=dim,
118
+ precision=precision,
119
+ vector_bytes=vector_bytes,
120
+ metadata_bytes=metadata_bytes,
121
+ total_uncompressed=total_uncompressed,
122
+ total_compressed=total_compressed,
123
+ )
124
+
125
+
126
+ def size_table(
127
+ num_nodes: int,
128
+ dims: tuple[int, ...] = (64, 128, 256),
129
+ precisions: tuple[str, ...] = ("fp32", "fp16", "int8"),
130
+ avg_key_bytes: int = DEFAULT_AVG_KEY_BYTES,
131
+ ) -> list[SizeEstimate]:
132
+ """Estimate sizes across the candidate (dim, precision) grid for one artifact."""
133
+ return [
134
+ estimate_size(num_nodes, dim, precision, avg_key_bytes)
135
+ for dim in dims
136
+ for precision in precisions
137
+ ]
138
+
139
+
140
+ def format_table(estimates: list[SizeEstimate], label: str) -> str:
141
+ """Render a human-readable size table (GiB) for a list of estimates."""
142
+ lines = [
143
+ f"{label} ({estimates[0].num_nodes:,} nodes)" if estimates else f"{label} (no rows)",
144
+ f" {'dim':>4} {'prec':>5} {'vectors':>10} {'uncompressed':>13} {'compressed':>11}",
145
+ ]
146
+ lines.extend(
147
+ f" {e.dim:>4} {e.precision:>5} {e.vector_gib:>8.1f}GiB "
148
+ f"{e.total_uncompressed_gib:>11.1f}GiB {e.total_compressed_gib:>9.1f}GiB"
149
+ for e in estimates
150
+ )
151
+ return "\n".join(lines)