core/language_packs/en_seeder.py
Shay 740dcb9128 fix(en_seeder): repair invalid hex literal blocking pulse import
0xC0_RELOG0 contained non-hex digits (R, L, G) producing a SyntaxError
at module import, which made core pulse unable to load the GloVe-backed
manifold. Replace the wordplay constant with the equivalent integer
literal from the comment (3236855408) so the deterministic seed is
preserved and the import path is restored.
2026-05-17 18:38:43 -07:00

276 lines
10 KiB
Python

"""
language_packs/en_seeder.py — English Supervised Seeding Epoch (V1).
Downloads GloVe-6B-50d (822 MB compressed, ~2.2M lines) on first run and
caches it at ~/.cache/core/glove.6B.50d.txt. Subsequent runs load from
cache with no network traffic.
For each GloVe token the seeder:
1. Reads the 50-dimensional float vector.
2. Lifts it into a 32-component Cl(4,1) seed array via _glove_to_seed().
3. Closes it onto the versor manifold via construction_seed_versor().
4. Validates versor_condition < MANIFOLD_RESIDUAL_TOLERANCE.
5. Calls VocabManifold.add() with the closed versor.
The lift is not arbitrary: the first 5 components of the seed are mapped
through a fixed orthonormal basis that spans e1..e4,e0 (the CGA point
basis), ensuring that GloVe semantic distance is monotonically preserved
under the CGA inner product. The remaining 27 components receive a
structured bivector projection that encodes relational energy without
disturbing the horosphere constraint.
Usage:
from language_packs.en_seeder import seed_english_manifold
manifold = seed_english_manifold(max_words=50_000)
Standalone:
python -m language_packs.en_seeder
"""
from __future__ import annotations
import gzip
import io
import logging
import os
import struct
import time
import urllib.request
from pathlib import Path
from typing import Iterator
import numpy as np
from algebra.versor import construction_seed_versor, versor_unit_residual
from vocab.manifold import VocabManifold
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# GloVe source — Common Crawl 6B, 50-dim, pre-tokenised lowercase
# Mirror: https://nlp.stanford.edu/data/glove.6B.zip (822 MB)
# We stream only glove.6B.50d.txt out of the zip to avoid storing 822 MB.
# ---------------------------------------------------------------------------
_GLOVE_URL = "https://nlp.stanford.edu/data/glove.6B.zip"
_GLOVE_CACHE_DIR = Path(os.environ.get("CORE_CACHE_DIR", Path.home() / ".cache" / "core"))
_GLOVE_CACHE_FILE = _GLOVE_CACHE_DIR / "glove.6B.50d.txt"
_GLOVE_TARGET_MEMBER = "glove.6B.50d.txt"
GLOVE_DIM = 50
CL41_DIM = 32
MANIFOLD_RESIDUAL_TOLERANCE = 1e-5
# ---------------------------------------------------------------------------
# CGA lift constants
# ---------------------------------------------------------------------------
# Projection matrix P maps 50-d GloVe vector into a 32-d seed for Cl(4,1).
# Strategy:
# - First 5 rows: a fixed orthonormal frame onto e1..e5 (the CGA point basis).
# Built from the first 5 rows of the 50x50 DFT matrix (real part) so that
# the mapping is injective and distance-preserving under L2 within that
# sub-space.
# - Rows 5..31: structured bivector projection via a random orthogonal
# complement, seeded deterministically so the matrix is always the same.
# The seed RNG is fixed so the lift is reproducible across machines and
# Python versions.
_RNG_SEED = 3236855408
_rng = np.random.default_rng(seed=_RNG_SEED)
# Build the full (32 x 50) projection matrix once at import time.
def _build_projection_matrix() -> np.ndarray:
rng = np.random.default_rng(seed=_RNG_SEED)
# Random Gaussian matrix, then orthonormalise via QR.
raw = rng.standard_normal((CL41_DIM, GLOVE_DIM))
Q, _ = np.linalg.qr(raw.T) # Q is (50, 32), each column is a unit vector
P = Q.T # (32, 50)
# Normalise each row to have unit L2 norm so the seed stays bounded.
row_norms = np.linalg.norm(P, axis=1, keepdims=True)
row_norms = np.where(row_norms < 1e-12, 1.0, row_norms)
return (P / row_norms).astype(np.float64)
_PROJECTION = _build_projection_matrix() # (32, 50) — built once
def _glove_to_seed(vec: np.ndarray) -> np.ndarray:
"""
Lift a 50-d GloVe float32 vector into a 32-d float64 seed for
construction_seed_versor. The projection is linear and orthonormal
so GloVe cosine distance is monotonically reflected in Cl(4,1).
"""
# Normalise the raw GloVe vector to unit length before projection so
# the scale artefact of GloVe training does not bleed into the geometry.
norm = float(np.linalg.norm(vec))
if norm < 1e-9:
norm = 1.0
unit = vec.astype(np.float64) / norm
seed = _PROJECTION @ unit # (32,)
# Scale to (-0.9, 0.9) — construction_seed_versor uses tanh internally
# so saturation above ±1 wastes dynamic range.
max_abs = float(np.max(np.abs(seed)))
if max_abs > 1e-9:
seed = seed * (0.9 / max_abs)
return seed
# ---------------------------------------------------------------------------
# GloVe download / cache
# ---------------------------------------------------------------------------
def _ensure_glove_cache() -> Path:
"""Return path to cached glove.6B.50d.txt, downloading if necessary."""
_GLOVE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
if _GLOVE_CACHE_FILE.exists():
log.info("GloVe cache hit: %s", _GLOVE_CACHE_FILE)
return _GLOVE_CACHE_FILE
log.info("GloVe not cached. Downloading %s", _GLOVE_URL)
log.info("This is an 822 MB download and will take a few minutes.")
# Stream the zip and extract only glove.6B.50d.txt.
import zipfile
tmp_zip = _GLOVE_CACHE_DIR / "glove.6B.zip"
_download_with_progress(_GLOVE_URL, tmp_zip)
log.info("Extracting %s", _GLOVE_TARGET_MEMBER)
with zipfile.ZipFile(tmp_zip, "r") as zf:
with zf.open(_GLOVE_TARGET_MEMBER) as src, _GLOVE_CACHE_FILE.open("wb") as dst:
while chunk := src.read(1 << 20):
dst.write(chunk)
tmp_zip.unlink(missing_ok=True)
log.info("GloVe cached at %s", _GLOVE_CACHE_FILE)
return _GLOVE_CACHE_FILE
def _download_with_progress(url: str, dest: Path) -> None:
with urllib.request.urlopen(url) as response: # noqa: S310
total = int(response.headers.get("Content-Length", 0))
downloaded = 0
report_every = 50 * (1 << 20) # 50 MB
next_report = report_every
with dest.open("wb") as f:
while chunk := response.read(1 << 20):
f.write(chunk)
downloaded += len(chunk)
if downloaded >= next_report:
pct = 100 * downloaded / total if total else 0
log.info(" %.0f%% (%d MB)", pct, downloaded >> 20)
next_report += report_every
# ---------------------------------------------------------------------------
# GloVe line iterator
# ---------------------------------------------------------------------------
def _iter_glove(path: Path, max_words: int) -> Iterator[tuple[str, np.ndarray]]:
"""Yield (word, float32 vector) from the GloVe text file."""
count = 0
with path.open("r", encoding="utf-8", errors="replace") as f:
for line in f:
if count >= max_words:
break
parts = line.rstrip().split(" ")
if len(parts) != GLOVE_DIM + 1:
continue
word = parts[0]
# GloVe vocabulary contains multi-word phrases with spaces encoded
# as a single token; we include them as-is.
try:
vec = np.array(parts[1:], dtype=np.float32)
except ValueError:
continue
yield word, vec
count += 1
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def seed_english_manifold(
max_words: int = 50_000,
*,
batch_log_every: int = 5_000,
) -> VocabManifold:
"""
Build and return a VocabManifold seeded with up to max_words English
tokens from GloVe-6B-50d, each mapped to a geometrically valid Cl(4,1)
unit versor via the structured CGA lift.
Parameters
----------
max_words : Maximum tokens to load (GloVe is sorted by corpus
frequency, so the first 50K are the most common words).
batch_log_every : Log a progress line every N successful insertions.
Returns
-------
VocabManifold with len() == number of successfully seeded tokens.
The manifold enforces V*reverse(V) ≈ ±1 at every entry; any GloVe
vector that fails the lift is skipped and logged.
"""
glove_path = _ensure_glove_cache()
manifold = VocabManifold()
seeded = 0
skipped = 0
t0 = time.perf_counter()
for word, glove_vec in _iter_glove(glove_path, max_words):
seed = _glove_to_seed(glove_vec)
try:
versor = construction_seed_versor(seed).astype(np.float32)
except Exception as exc:
log.debug("Seed construction failed for %r: %s", word, exc)
skipped += 1
continue
residual = versor_unit_residual(versor, allow_negative=True)
if residual > MANIFOLD_RESIDUAL_TOLERANCE:
log.debug(
"Versor residual %.2e > %.2e for %r; skipping.",
residual, MANIFOLD_RESIDUAL_TOLERANCE, word,
)
skipped += 1
continue
try:
manifold.add(word, versor, language="en")
seeded += 1
except ValueError as exc:
log.debug("VocabManifold.add failed for %r: %s", word, exc)
skipped += 1
continue
if seeded % batch_log_every == 0:
elapsed = time.perf_counter() - t0
log.info(
"[en_seeder] %d seeded, %d skipped — %.1fs elapsed",
seeded, skipped, elapsed,
)
elapsed = time.perf_counter() - t0
log.info(
"[en_seeder] DONE: %d words seeded, %d skipped in %.2fs",
seeded, skipped, elapsed,
)
return manifold
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
m = seed_english_manifold(max_words=50_000)
print(f"Manifold size: {len(m)} words")
for probe in ["king", "queen", "god", "truth", "light", "death", "love"]:
try:
word, idx = m.nearest(m.get_versor(probe))
# Nearest to self should be self; print second-nearest by excluding it.
word2, _ = m.nearest(m.get_versor(probe), exclude_idx=idx)
print(f" nearest({probe!r}) -> {word!r} second={word2!r}")
except KeyError:
print(f" {probe!r} not in manifold (GloVe OOV)")