Spaces:
Running
Running
malteos commited on
Commit ·
02de126
unverified ·
0
Parent(s):
init
Browse files- .gitattributes +35 -0
- README.md +53 -0
- app.py +337 -0
- requirements.txt +8 -0
- wgl/__init__.py +3 -0
- wgl/data/__init__.py +1 -0
- wgl/data/graph.py +217 -0
- wgl/knn/__init__.py +27 -0
- wgl/knn/build.py +260 -0
- wgl/knn/index.py +260 -0
- wgl/knn/source.py +403 -0
- wgl/logging.py +121 -0
- wgl/release/__init__.py +81 -0
- wgl/release/identity.py +157 -0
- wgl/release/manifest.py +327 -0
- wgl/release/schema.py +237 -0
- wgl/release/sizing.py +151 -0
.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)
|