Research-Stack/4-Infrastructure/shim/pagerank_eigenvalue_survey.py
allaun 7b0c0a363b feat(infra): citation crawler and spectral cross-validation research shims
Add exploratory Python shims for:
- OpenAlex citation crawling
- Semantic Scholar citation crawling
- PageRank eigenvalue survey
- SNAP PIST spectral cross-validation
- Fiedler vector validation
2026-06-21 01:04:19 -05:00

229 lines
11 KiB
Python

#!/usr/bin/env python3
"""
pagerank_eigenvalue_survey.py
Surveys the eigenvalue sorts produced by PageRank, HITS, Fiedler, and PPR
on a synthetic social graph, then maps each sort onto the 8-bin Sidon spectral
basis from Semantics.Spectrum / Semantics.GraphRank.
Goal: show that each classical ranking algorithm "observes" a different subset
of the 8 spectral bins, and that the bad-link gate (verifySpectralGap) is the
Sidon-filter analog of the cut identified by the Fiedler eigenvector.
Requires: networkx, scipy, numpy
uv pip install networkx scipy numpy
"""
import numpy as np
import networkx as nx
from scipy.sparse.linalg import eigsh
from typing import List, Dict, Tuple
# ── Sidon spectral primitives (mirror Semantics.Spectrum) ────────────────────
BIN_COUNT = 8
def verify_spectral_gap(active: List[int]) -> bool:
"""No two active bin indices may be adjacent. Mirrors verifySpectralGap."""
return all(abs(i - j) > 1 for i in active for j in active if i != j)
def piecewise_merge(a: List[float], b: List[float]) -> List[float]:
"""Saturating superposition. Mirrors SpectralSignature.piecewiseMerge."""
return [min(1.0, x + y) for x, y in zip(a, b)]
def spectral_overlap(a: List[float], b: List[float]) -> float:
"""Inner product. Mirrors SpectralSignature.spectralOverlap."""
return sum(x * y for x, y in zip(a, b))
def bad_link(src_sig: List[float], edge_sig: List[float],
dst_sig: List[float], threshold: float = 0.05) -> bool:
merged = piecewise_merge(piecewise_merge(src_sig, edge_sig), dst_sig)
active = [i for i, v in enumerate(merged) if v > threshold]
return not verify_spectral_gap(active)
# ── Rank mode → bin mapping (mirrors Semantics.GraphRank.RankMode) ────────────
def pagerank_sig(score: float, max_score: float) -> List[float]:
"""PageRank mass pools into bin 0 (DC / stationary distribution)."""
sig = [0.0] * BIN_COUNT
sig[0] = score / max_score if max_score > 0 else 0.0
return sig
def hits_sig(auth: float, hub: float) -> List[float]:
"""HITS: authority → bin 0, hub → bin 2. Gap of 2 satisfies Sidon."""
sig = [0.0] * BIN_COUNT
sig[0] = auth
sig[2] = hub # bin 1 is skipped — maintains verifySpectralGap
return sig
def fiedler_sig(fiedler_val: float) -> List[float]:
"""Fiedler component → bin 1 (community boundary = second eigenvector)."""
sig = [0.0] * BIN_COUNT
sig[1] = min(1.0, abs(fiedler_val))
return sig
def ppr_sig(score: float, seed_bin: int) -> List[float]:
"""PPR seeded at bin k: propagates energy near that bin."""
sig = [0.0] * BIN_COUNT
sig[seed_bin] = score
return sig
# ── Build test graph ──────────────────────────────────────────────────────────
def build_test_graph() -> nx.DiGraph:
"""
Two communities with a bridge and a spam link farm.
Community A (0-3): densely linked, should have high PageRank
Community B (4-7): densely linked, should have high PageRank
Bridge 3→4: the "bad link" candidate — crosses Sidon bin boundary
Spam farm (8-10): link farm → node 0 (artificial authority injection)
"""
g = nx.DiGraph()
for i in range(3):
for j in range(i + 1, 4):
g.add_edge(i, j)
g.add_edge(j, i)
for i in range(4, 7):
for j in range(i + 1, 8):
g.add_edge(i, j)
g.add_edge(j, i)
g.add_edge(3, 4) # inter-community bridge
for i in [8, 9, 10]:
g.add_edge(i, 0) # spam → target
g.add_edge(i, 8 if i != 8 else 10) # farm cross-links
return g
# ── Run classical algorithms ──────────────────────────────────────────────────
def run_pagerank(g: nx.DiGraph, alpha: float = 0.85) -> Dict[int, float]:
return nx.pagerank(g, alpha=alpha, max_iter=300)
def run_hits(g: nx.DiGraph) -> Tuple[Dict[int, float], Dict[int, float]]:
try:
hubs, auths = nx.hits(g, max_iter=300, normalized=True)
except nx.PowerIterationFailedConvergence:
default = {n: 1.0 / len(g) for n in g}
hubs, auths = default, default
return hubs, auths
def run_fiedler(g: nx.DiGraph) -> np.ndarray:
ug = g.to_undirected()
nodes = sorted(ug.nodes())
L = nx.laplacian_matrix(ug, nodelist=nodes).astype(float)
try:
vals, vecs = eigsh(L, k=2, which='SM', tol=1e-8, maxiter=1000)
# Second column = Fiedler vector (index 1 after sorting by eigenvalue)
order = np.argsort(vals)
return vecs[:, order[1]], nodes
except Exception as exc:
print(f" [fiedler] eigsh failed: {exc}")
return np.zeros(len(nodes)), nodes
# ── Survey ────────────────────────────────────────────────────────────────────
def run_survey(g: nx.DiGraph):
nodes = sorted(g.nodes())
pr = run_pagerank(g)
hubs, auths = run_hits(g)
fiedler_vec, fiedler_nodes = run_fiedler(g)
fiedler_map = {fiedler_nodes[i]: fiedler_vec[i]
for i in range(len(fiedler_nodes))}
max_pr = max(pr.values()) or 1.0
max_auth = max(auths.values()) or 1.0
max_hub = max(hubs.values()) or 1.0
print("=" * 72)
print("EIGENVALUE SORT SURVEY — mapped to 8-bin Sidon spectral basis")
print("=" * 72)
# ── PageRank (bin-0 dominant) ──────────────────────────────────────────
pr_sorted = sorted(pr.items(), key=lambda x: -x[1])
print("\n[PageRank] bin 0 ← DC / stationary distribution")
print(f" {'node':>5} {'PR':>8} {'bin0':>6} {'gap':>5} community")
for node, score in pr_sorted:
sig = pagerank_sig(score, max_pr)
active = [i for i, v in enumerate(sig) if v > 0.05]
gap_ok = verify_spectral_gap(active)
comm = 'A' if node <= 3 else ('B' if node <= 7 else 'spam')
print(f" {node:>5} {score:>8.5f} {sig[0]:>6.3f} {'OK' if gap_ok else 'BAD':>5} {comm}")
# ── HITS (bins 0 + 2) ─────────────────────────────────────────────────
auth_sorted = sorted(auths.items(), key=lambda x: -x[1])
print("\n[HITS Authority] bin 0 (auth) + bin 2 (hub); gap=2 satisfies Sidon")
print(f" {'node':>5} {'auth':>8} {'hub':>8} {'gap':>5} community")
for node, auth in auth_sorted:
hub = hubs.get(node, 0.0)
sig = hits_sig(auth / max_auth, hub / max_hub)
active = [i for i, v in enumerate(sig) if v > 0.05]
gap_ok = verify_spectral_gap(active)
comm = 'A' if node <= 3 else ('B' if node <= 7 else 'spam')
print(f" {node:>5} {auth:>8.5f} {hub:>8.5f} {'OK' if gap_ok else 'BAD':>5} {comm}")
# ── Fiedler (bin 1) ───────────────────────────────────────────────────
print("\n[Fiedler] bin 1 ← community boundary (second Laplacian eigenvector)")
fiedler_sorted = sorted(fiedler_map.items(), key=lambda x: x[1])
neg_side = [n for n, v in fiedler_sorted if v < 0]
pos_side = [n for n, v in fiedler_sorted if v >= 0]
print(f" Community A (Fiedler < 0): {neg_side}")
print(f" Community B (Fiedler ≥ 0): {pos_side}")
print(f" {'node':>5} {'F_val':>9} {'bin1':>6} community")
for node, val in fiedler_sorted:
sig = fiedler_sig(val)
comm = 'A' if val < 0 else 'B'
print(f" {node:>5} {val:>+9.5f} {sig[1]:>6.3f} {comm}")
# ── Bad-link gate on bridge and spam ──────────────────────────────────
print("\n[Bad-link gate]")
# Bridge 3→4
src_sig = pagerank_sig(pr[3], max_pr)
dst_sig = pagerank_sig(pr[4], max_pr)
edge_sig = [0.5] + [0.0] * 7 # bridge carries half bin-0 authority
merged = piecewise_merge(piecewise_merge(src_sig, edge_sig), dst_sig)
active_m = [i for i, v in enumerate(merged) if v > 0.05]
is_bad = not verify_spectral_gap(active_m)
print(f" Bridge 3→4: merged active bins={active_m} "
f"gap={'FAIL (bad link)' if is_bad else 'OK'}")
for spam_node in [8, 9, 10]:
s_sig = pagerank_sig(pr[spam_node], max_pr)
t_sig = pagerank_sig(pr[0], max_pr)
e_sig = [0.5] + [0.0] * 7
merged = piecewise_merge(piecewise_merge(s_sig, e_sig), t_sig)
active_m = [i for i, v in enumerate(merged) if v > 0.05]
is_bad = not verify_spectral_gap(active_m)
print(f" Spam {spam_node}→0: merged active bins={active_m} "
f"gap={'FAIL (bad link)' if is_bad else 'OK'}")
# ── Spectral overlap as PPR dot product ───────────────────────────────
print("\n[Spectral overlap — PPR dot product with seed = community A sig]")
seed_sig = pagerank_sig(1.0, 1.0) # pure bin-0 seed
scores = []
for node in nodes:
sig = pagerank_sig(pr[node], max_pr)
score = spectral_overlap(sig, seed_sig)
scores.append((node, score))
scores.sort(key=lambda x: -x[1])
print(f" {'node':>5} {'overlap':>9} community")
for node, sc in scores:
comm = 'A' if node <= 3 else ('B' if node <= 7 else 'spam')
print(f" {node:>5} {sc:>9.5f} {comm}")
# ── Summary ───────────────────────────────────────────────────────────
print("\n[Summary — eigenvalue sort → spectral bin mapping]")
print(" PageRank → bin 0 (DC authority; bad link = bin-0 bleed)")
print(" HITS auth → bin 0 (top singular vector = pure authority)")
print(" HITS hub → bin 2 (gap=2 from authority; Sidon-safe)")
print(" Fiedler → bin 1 (community cut = spectral gap)")
print(" PPR seed → bin k (propagates along matching-bin paths)")
print(" CheiRank → 1-bin0 (givers have LOW bin-0; receivers HIGH)")
print(" verifySpectralGap ← detects ALL of the above failures at once")
print(" erdosHooleyDelta ← density threshold for Sidon property in 8-bin space")
if __name__ == "__main__":
g = build_test_graph()
print(f"Graph: {len(g.nodes())} nodes, {len(g.edges())} edges\n")
run_survey(g)