gravity_tracker.py
1 #!/usr/bin/env python3 2 """ 3 Gravity Topology Tracker - Map the gravitational structure of the graph 4 5 Implements the gravity well concept from Sovereign_OS: 6 - Pages that accumulate more links gain "mass" 7 - High-mass pages are gravity wells - concepts with strong pull 8 - The topology emerges from use, not from design (Hayek) 9 10 Usage: 11 python scripts/gravity_tracker.py [--output FILE] [--top N] [--format FORMAT] 12 13 Outputs: 14 - Ranked list of pages by gravitational mass 15 - Cluster analysis (what's in each gravity well's orbit) 16 - Topology changes over time (if historical data exists) 17 """ 18 19 import os 20 import re 21 import sys 22 import json 23 from collections import defaultdict 24 from pathlib import Path 25 from datetime import datetime 26 from typing import Dict, List, Set, Tuple 27 import argparse 28 29 SCAN_DIRS = ['docs', 'patterns', 'sessions', 'dashboards'] 30 SKIP_DIRS = {'.git', 'node_modules', '__pycache__', 'templates'} 31 SKIP_FILES = {'README.md', 'CHANGELOG.md'} 32 33 34 def normalize_link(link: str) -> str: 35 """Normalize wiki-link to comparable format.""" 36 name = link.split('/')[-1].lower() 37 if name.endswith('.md'): 38 name = name[:-3] 39 return name.replace(' ', '-') 40 41 42 def file_to_link_name(filepath: Path) -> str: 43 """Convert filepath to wiki-link name.""" 44 return normalize_link(filepath.stem) 45 46 47 def extract_wiki_links(content: str) -> Set[str]: 48 """Extract all [[wiki-links]] from content.""" 49 pattern = r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]' 50 return {normalize_link(m) for m in re.findall(pattern, content)} 51 52 53 def extract_title(content: str) -> str: 54 """Extract page title.""" 55 match = re.search(r'^# (.+)$', content, re.MULTILINE) 56 return match.group(1) if match else "Untitled" 57 58 59 def scan_graph(base_path: Path) -> Dict[str, dict]: 60 """Scan the graph and extract all pages and links.""" 61 pages = {} 62 63 for scan_dir in SCAN_DIRS: 64 dir_path = base_path / scan_dir 65 if not dir_path.exists(): 66 continue 67 68 for filepath in dir_path.rglob('*.md'): 69 if any(skip in filepath.parts for skip in SKIP_DIRS): 70 continue 71 if filepath.name in SKIP_FILES: 72 continue 73 74 try: 75 content = filepath.read_text(encoding='utf-8') 76 except Exception: 77 continue 78 79 link_name = file_to_link_name(filepath) 80 pages[link_name] = { 81 'path': filepath, 82 'title': extract_title(content), 83 'outbound': extract_wiki_links(content), 84 } 85 86 return pages 87 88 89 def calculate_gravity(pages: Dict[str, dict]) -> Dict[str, dict]: 90 """ 91 Calculate gravitational mass for each page. 92 93 Mass = f(inbound links, outbound links, link diversity) 94 95 A page with many inbound links has high gravitational pull. 96 This is emergent - mass comes from use, not assignment. 97 """ 98 # Build inbound link map 99 inbound: Dict[str, Set[str]] = defaultdict(set) 100 for source, data in pages.items(): 101 for target in data['outbound']: 102 if target in pages: # Only count links to existing pages 103 inbound[target].add(source) 104 105 # Calculate gravity for each page 106 gravity = {} 107 for name, data in pages.items(): 108 inbound_count = len(inbound.get(name, set())) 109 outbound_count = len(data['outbound']) 110 111 # Mass formula: 112 # - Inbound links are primary (others are pulled toward you) 113 # - Outbound links matter less (you reaching out) 114 # - Bidirectional links (mutual) count extra 115 bidirectional = sum( 116 1 for t in data['outbound'] 117 if t in pages and name in pages.get(t, {}).get('outbound', set()) 118 ) 119 120 mass = (inbound_count * 2.0) + (outbound_count * 0.5) + (bidirectional * 1.0) 121 122 gravity[name] = { 123 'name': name, 124 'title': data['title'], 125 'path': str(data['path']), 126 'mass': mass, 127 'inbound': inbound_count, 128 'outbound': outbound_count, 129 'bidirectional': bidirectional, 130 'orbit': list(inbound.get(name, set()))[:10], # Pages in orbit 131 } 132 133 return gravity 134 135 136 def classify_wells(gravity: Dict[str, dict]) -> Dict[str, List[str]]: 137 """ 138 Classify pages into gravity well tiers. 139 140 - Black holes: Massive wells that everything links to 141 - Stars: Major concepts with strong pull 142 - Planets: Medium-mass pages 143 - Asteroids: Low-mass, potentially orphaned 144 """ 145 masses = [g['mass'] for g in gravity.values()] 146 if not masses: 147 return {} 148 149 max_mass = max(masses) 150 if max_mass == 0: 151 return {} 152 153 tiers = { 154 'black_holes': [], # > 0.8 of max 155 'stars': [], # 0.4 - 0.8 156 'planets': [], # 0.1 - 0.4 157 'asteroids': [], # < 0.1 158 } 159 160 for name, data in gravity.items(): 161 ratio = data['mass'] / max_mass 162 if ratio > 0.8: 163 tiers['black_holes'].append(name) 164 elif ratio > 0.4: 165 tiers['stars'].append(name) 166 elif ratio > 0.1: 167 tiers['planets'].append(name) 168 else: 169 tiers['asteroids'].append(name) 170 171 return tiers 172 173 174 def print_report(gravity: Dict[str, dict], tiers: Dict[str, List[str]], top_n: int = 20): 175 """Print the gravity topology report.""" 176 print("=" * 70) 177 print("GRAVITY TOPOLOGY - Sovereign_OS") 178 print("=" * 70) 179 print() 180 print(f"Total pages analyzed: {len(gravity)}") 181 print(f"Timestamp: {datetime.now().isoformat()}") 182 print() 183 184 # Summary by tier 185 print("=" * 70) 186 print("GRAVITATIONAL CLASSIFICATION") 187 print("=" * 70) 188 print() 189 print(f"🕳️ Black Holes (maximum gravity): {len(tiers.get('black_holes', []))}") 190 print(f"⭐ Stars (high gravity): {len(tiers.get('stars', []))}") 191 print(f"🪐 Planets (medium gravity): {len(tiers.get('planets', []))}") 192 print(f"🪨 Asteroids (low gravity): {len(tiers.get('asteroids', []))}") 193 print() 194 195 # Top gravity wells 196 sorted_pages = sorted(gravity.values(), key=lambda x: x['mass'], reverse=True) 197 198 print("=" * 70) 199 print(f"TOP {top_n} GRAVITY WELLS") 200 print("=" * 70) 201 print() 202 print(f"{'Rank':<5} {'Mass':>7} {'In':>4} {'Out':>4} {'Bi':>3} Title") 203 print("-" * 70) 204 205 for i, page in enumerate(sorted_pages[:top_n], 1): 206 tier_emoji = ( 207 "🕳️ " if page['name'] in tiers.get('black_holes', []) 208 else "⭐" if page['name'] in tiers.get('stars', []) 209 else "🪐" if page['name'] in tiers.get('planets', []) 210 else "🪨" 211 ) 212 title = page['title'][:40] + "..." if len(page['title']) > 40 else page['title'] 213 print(f"{i:<5} {page['mass']:>7.1f} {page['inbound']:>4} {page['outbound']:>4} {page['bidirectional']:>3} {tier_emoji} {title}") 214 215 print() 216 217 # Black holes detail 218 if tiers.get('black_holes'): 219 print("=" * 70) 220 print("🕳️ BLACK HOLES (maximum gravitational pull)") 221 print("=" * 70) 222 print() 223 for name in tiers['black_holes']: 224 data = gravity[name] 225 print(f" {data['title']}") 226 print(f" Path: {data['path']}") 227 print(f" Mass: {data['mass']:.1f} | Inbound: {data['inbound']} | Bidirectional: {data['bidirectional']}") 228 if data['orbit']: 229 print(f" Orbit: {', '.join(data['orbit'][:5])}{'...' if len(data['orbit']) > 5 else ''}") 230 print() 231 232 # Asteroids warning 233 asteroid_count = len(tiers.get('asteroids', [])) 234 if asteroid_count > 0: 235 print("=" * 70) 236 print(f"⚠️ ASTEROIDS ({asteroid_count} pages with low gravity)") 237 print(" These pages may drift away without more connections.") 238 print(" Run: python3 scripts/resonance_engine.py --fix-orphans") 239 print("=" * 70) 240 241 242 def save_topology( 243 gravity: Dict[str, dict], 244 tiers: Dict[str, List[str]], 245 output_file: Path 246 ): 247 """Save topology to a file for tracking over time.""" 248 topology = { 249 'timestamp': datetime.now().isoformat(), 250 'stats': { 251 'total_pages': len(gravity), 252 'black_holes': len(tiers.get('black_holes', [])), 253 'stars': len(tiers.get('stars', [])), 254 'planets': len(tiers.get('planets', [])), 255 'asteroids': len(tiers.get('asteroids', [])), 256 }, 257 'top_10': [ 258 {'name': g['name'], 'title': g['title'], 'mass': g['mass']} 259 for g in sorted(gravity.values(), key=lambda x: x['mass'], reverse=True)[:10] 260 ], 261 'tiers': tiers, 262 } 263 264 output_file.write_text(json.dumps(topology, indent=2), encoding='utf-8') 265 print(f"\nTopology saved to: {output_file}") 266 267 268 def save_markdown( 269 gravity: Dict[str, dict], 270 tiers: Dict[str, List[str]], 271 output_file: Path 272 ): 273 """Save topology as a markdown file for the graph.""" 274 sorted_pages = sorted(gravity.values(), key=lambda x: x['mass'], reverse=True) 275 276 lines = [ 277 "# Gravity Topology", 278 "", 279 f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*", 280 "", 281 "---", 282 "", 283 "## Summary", 284 "", 285 f"- **Total pages**: {len(gravity)}", 286 f"- **Black holes**: {len(tiers.get('black_holes', []))}", 287 f"- **Stars**: {len(tiers.get('stars', []))}", 288 f"- **Planets**: {len(tiers.get('planets', []))}", 289 f"- **Asteroids**: {len(tiers.get('asteroids', []))}", 290 "", 291 "---", 292 "", 293 "## Top Gravity Wells", 294 "", 295 "| Rank | Mass | Title |", 296 "|------|------|-------|", 297 ] 298 299 for i, page in enumerate(sorted_pages[:20], 1): 300 tier = ( 301 "🕳️" if page['name'] in tiers.get('black_holes', []) 302 else "⭐" if page['name'] in tiers.get('stars', []) 303 else "🪐" if page['name'] in tiers.get('planets', []) 304 else "🪨" 305 ) 306 lines.append(f"| {i} | {page['mass']:.0f} | {tier} [[{page['name']}\\|{page['title']}]] |") 307 308 lines.extend([ 309 "", 310 "---", 311 "", 312 "## Black Holes", 313 "", 314 "Pages with maximum gravitational pull:", 315 "", 316 ]) 317 318 for name in tiers.get('black_holes', []): 319 data = gravity[name] 320 lines.append(f"### [[{name}|{data['title']}]]") 321 lines.append(f"- Mass: {data['mass']:.0f} | Inbound: {data['inbound']} | Bidirectional: {data['bidirectional']}") 322 if data['orbit']: 323 orbit_links = ', '.join(f"[[{o}]]" for o in data['orbit'][:5]) 324 lines.append(f"- Orbit: {orbit_links}") 325 lines.append("") 326 327 lines.extend([ 328 "---", 329 "", 330 "*Gravity emerges from use, not assignment (Hayek)*", 331 ]) 332 333 output_file.write_text('\n'.join(lines), encoding='utf-8') 334 print(f"\nMarkdown saved to: {output_file}") 335 336 337 def main(): 338 parser = argparse.ArgumentParser( 339 description='Gravity Topology Tracker - Map the gravitational structure of the graph' 340 ) 341 parser.add_argument( 342 '--output', '-o', 343 type=Path, 344 help='Output file for topology data (JSON)' 345 ) 346 parser.add_argument( 347 '--markdown', '-m', 348 type=Path, 349 help='Output file for markdown topology' 350 ) 351 parser.add_argument( 352 '--top', '-t', 353 type=int, 354 default=20, 355 help='Number of top pages to show (default: 20)' 356 ) 357 parser.add_argument( 358 '--path', '-p', 359 type=Path, 360 default=Path(__file__).parent.parent, 361 help='Path to Sovereign_OS repo' 362 ) 363 364 args = parser.parse_args() 365 366 print(f"Scanning graph from {args.path}...\n") 367 pages = scan_graph(args.path) 368 369 if not pages: 370 print("No pages found!", file=sys.stderr) 371 return 1 372 373 gravity = calculate_gravity(pages) 374 tiers = classify_wells(gravity) 375 376 print_report(gravity, tiers, args.top) 377 378 if args.output: 379 save_topology(gravity, tiers, args.output) 380 381 if args.markdown: 382 save_markdown(gravity, tiers, args.markdown) 383 384 return 0 385 386 387 if __name__ == '__main__': 388 sys.exit(main())