diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 08e59f234..51ad580f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ graphify is a Claude Code skill backed by a Python library. The skill orchestrat detect() → extract() → build_graph() → cluster() → analyze() → report() → export() ``` -Each stage is a single function in its own module. They communicate through plain Python dicts and NetworkX graphs — no shared state, no side effects outside `.graphify/`. +Each stage is a single function in its own module. They communicate through plain Python dicts and NetworkX graphs - no shared state, no side effects outside `graphify-out/`. ## Module responsibilities @@ -68,7 +68,7 @@ All external input passes through `graphify/security.py` before use: - URLs → `validate_url()` (http/https only) + `_NoFileRedirectHandler` (blocks file:// redirects) - Fetched content → `safe_fetch()` / `safe_fetch_text()` (size cap, timeout) -- Graph file paths → `validate_graph_path()` (must resolve inside `.graphify/`) +- Graph file paths → `validate_graph_path()` (must resolve inside `graphify-out/`) - Node labels → `sanitize_label()` (strips control chars, caps 256 chars, HTML-escapes) See `SECURITY.md` for the full threat model. @@ -81,4 +81,4 @@ One test file per module under `tests/`. Run with: pytest tests/ -q ``` -All tests are pure unit tests — no network calls, no file system side effects outside `tmp_path`. +All tests are pure unit tests - no network calls, no file system side effects outside `tmp_path`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c472da65..77c14ef84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.1.3 (2026-04-04) -- Fix: `pyproject.toml` structure — `requires-python` and `dependencies` were incorrectly placed under `[project.urls]` +- Fix: `pyproject.toml` structure - `requires-python` and `dependencies` were incorrectly placed under `[project.urls]` - Add: GitHub repository and issues URLs to PyPI page - Add: `keywords` for PyPI search discoverability - Docs: README clarifies Claude Code requirement, temporary PyPI name, worked examples footnote @@ -10,10 +10,10 @@ ## 0.1.1 (2026-04-04) - Add: CI badge to README (GitHub Actions, Python 3.10 + 3.12) -- Add: ARCHITECTURE.md — pipeline overview, module table, extraction schema, how to add a language -- Add: SECURITY.md — threat model, mitigations, vulnerability reporting +- Add: ARCHITECTURE.md - pipeline overview, module table, extraction schema, how to add a language +- Add: SECURITY.md - threat model, mitigations, vulnerability reporting - Add: `worked/` directory with eval reports (karpathy-repos 71.5x benchmark, httpx, mixed-corpus) -- Fix: pytest not found in CI — added explicit `pip install pytest` step +- Fix: pytest not found in CI - added explicit `pip install pytest` step - Fix: README test count (163 → 212), language table, worked examples links - Docs: README reframed as Claude Code skill; Karpathy problem → graphify answer framing @@ -23,10 +23,10 @@ Initial release. - 13-language AST extraction via tree-sitter (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP) - Leiden community detection via graspologic with oversized community splitting -- SHA256 semantic cache — warm re-runs skip unchanged files -- MCP stdio server — `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `god_nodes` -- Memory feedback loop — Q&A results saved to `.graphify/memory/`, extracted on `--update` +- SHA256 semantic cache - warm re-runs skip unchanged files +- MCP stdio server - `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `god_nodes` +- Memory feedback loop - Q&A results saved to `graphify-out/memory/`, extracted on `--update` - Obsidian vault export with wikilinks, community tags, Canvas layout -- Security module — URL validation, safe fetch with size cap, path guards, label sanitisation -- `graphify install` CLI — copies skill to `~/.claude/skills/` and registers in `CLAUDE.md` +- Security module - URL validation, safe fetch with size cap, path guards, label sanitisation +- `graphify install` CLI - copies skill to `~/.claude/skills/` and registers in `CLAUDE.md` - Parallel subagent extraction for docs, papers, and images diff --git a/README.md b/README.md index 0038ed720..c2d76d805 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v1)](https://github.com/safishamsi/graphify/actions/workflows/ci.yml) -**A Claude Code skill.** Type `/graphify` in Claude Code — it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. +**A Claude Code skill.** Type `/graphify` in Claude Code - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. > Andrej Karpathy keeps a `/raw` folder where he drops papers, tweets, screenshots, and notes. The problem: that folder becomes opaque. You forget what's in it. You can't see what connects. graphify is the answer to that problem. @@ -11,12 +11,12 @@ ``` ``` -.graphify/ -├── obsidian/ open as Obsidian vault — visual graph, wikilinks, filter by community +graphify-out/ +├── obsidian/ open as Obsidian vault - visual graph, wikilinks, filter by community ├── GRAPH_REPORT.md what the graph found: god nodes, surprising connections, suggested questions -├── graph.json persistent graph — query it weeks later without re-reading anything -├── cache/ per-file SHA256 cache — re-runs only process changed files -└── memory/ Q&A results filed back in — what you ask grows the graph on next --update +├── graph.json persistent graph - query it weeks later without re-reading anything +├── cache/ per-file SHA256 cache - re-runs only process changed files +└── memory/ Q&A results filed back in - what you ask grows the graph on next --update ``` ## Why this exists @@ -26,20 +26,20 @@ graphify takes that observation and builds the missing infrastructure: | His problem | What graphify adds | |---|---| | Folder becomes opaque | Community detection surfaces structure automatically | -| Forget what's in it | Persistent `graph.json` — query weeks later without re-reading | +| Forget what's in it | Persistent `graph.json` - query weeks later without re-reading | | Can't see connections | Cross-community surprising connections as a first-class output | -| Claude hallucinates missing links | `EXTRACTED` / `INFERRED` / `AMBIGUOUS` — honest about what was found vs guessed | -| Context resets every session | Memory feedback loop — what you ask grows the graph on `--update` | +| Claude hallucinates missing links | `EXTRACTED` / `INFERRED` / `AMBIGUOUS` - honest about what was found vs guessed | +| Context resets every session | Memory feedback loop - what you ask grows the graph on `--update` | | Only works on text | PDFs, images, screenshots, tweets, any language via vision | **What LLMs get wrong without it:** Naive summarization fills every gap confidently. You get output that sounds complete but you can't tell what was actually in the files vs invented. And next session, it's all gone. **What graphify does differently:** -- **Persistent graph** — relationships stored in `.graphify/graph.json`, survive across sessions. Query weeks later without re-reading anything. -- **Honest audit trail** — every edge tagged `EXTRACTED` (explicitly stated), `INFERRED` (call-graph or reasonable deduction), or `AMBIGUOUS` (flagged for review). You always know what was found vs invented. -- **Cross-document surprise** — Leiden community detection finds clusters, then surfaces cross-community connections: the things you would never think to ask about directly. -- **Feedback loop** — every query answer saved to `.graphify/memory/`. On next `--update`, that Q&A becomes a node. The graph grows from what you ask, not just what you add. +- **Persistent graph** - relationships stored in `graphify-out/graph.json`, survive across sessions. Query weeks later without re-reading anything. +- **Honest audit trail** - every edge tagged `EXTRACTED` (explicitly stated), `INFERRED` (call-graph or reasonable deduction), or `AMBIGUOUS` (flagged for review). You always know what was found vs invented. +- **Cross-document surprise** - Leiden community detection finds clusters, then surfaces cross-community connections: the things you would never think to ask about directly. +- **Feedback loop** - every query answer saved to `graphify-out/memory/`. On next `--update`, that Q&A becomes a node. The graph grows from what you ask, not just what you add. The result: a navigable map of your corpus that is honest about what it knows and what it guessed. @@ -51,9 +51,9 @@ The result: a navigable map of your corpus that is honest about what it knows an pip install graphifyy && graphify install ``` -> **Note:** The PyPI package is temporarily named `graphifyy` while the `graphify` name is being reclaimed. The CLI, skill command, and everything else is still called `graphify` — only `pip install` uses the extra `y`. +> **Note:** The PyPI package is temporarily named `graphifyy` while the `graphify` name is being reclaimed. The CLI, skill command, and everything else is still called `graphify` - only `pip install` uses the extra `y`. -This copies the skill file into `~/.claude/skills/graphify/` and registers it in `~/.claude/CLAUDE.md`. The Python package and all dependencies install automatically on first `/graphify` run — you never touch pip again. +This copies the skill file into `~/.claude/skills/graphify/` and registers it in `~/.claude/CLAUDE.md`. The Python package and all dependencies install automatically on first `/graphify` run - you never touch pip again. Then open Claude Code in any directory and type: @@ -64,7 +64,7 @@ Then open Claude Code in any directory and type:
Manual install (curl) -**Step 1 — copy the skill file** +**Step 1 - copy the skill file** ```bash mkdir -p ~/.claude/skills/graphify @@ -72,12 +72,12 @@ curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v1/skills/graph > ~/.claude/skills/graphify/SKILL.md ``` -**Step 2 — register it in Claude Code** +**Step 2 - register it in Claude Code** Add this to `~/.claude/CLAUDE.md` (create the file if it doesn't exist): ``` -- **graphify** (`~/.claude/skills/graphify/SKILL.md`) — any input to knowledge graph. Trigger: `/graphify` +- **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify` When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` before doing anything else. ``` @@ -98,8 +98,8 @@ All commands are typed inside Claude Code: /graphify add https://x.com/karpathy/status/... # fetch a tweet /graphify add --author "Karpathy" --contributor "safi" -/graphify query "what connects attention to the optimizer?" # BFS — broad context -/graphify query "how does the encoder reach the loss?" --dfs # DFS — trace a path +/graphify query "what connects attention to the optimizer?" # BFS - broad context +/graphify query "how does the encoder reach the loss?" --dfs # DFS - trace a path /graphify query "..." --budget 1500 # cap at N tokens /graphify path "DigestAuth" "Response" # shortest path between two concepts @@ -119,17 +119,17 @@ Works with any mix of file types in the same folder: | Code | `.py .ts .tsx .js .go .rs .java .c .cpp .rb .cs .kt .scala .php` | AST via tree-sitter (deterministic) + call-graph pass (INFERRED) | | Documents | `.md .txt .rst` | Concepts + relationships via Claude | | Papers | `.pdf` | Citation mining + concept extraction | -| Images | `.png .jpg .webp .gif .svg` | Claude vision — screenshots, charts, whiteboards, any language | +| Images | `.png .jpg .webp .gif .svg` | Claude vision - screenshots, charts, whiteboards, any language | ## What you get After running, Claude outputs three things directly in chat: -**God nodes** — highest-degree concepts (what everything connects through) +**God nodes** - highest-degree concepts (what everything connects through) -**Surprising connections** — cross-community edges; relationships between concepts in different clusters that you didn't know to look for +**Surprising connections** - ranked by a composite surprise score, not just confidence. A code↔paper edge scores higher than code↔code. A cross-repo connection scores higher than same-repo. Each result includes a plain-English `why` explaining what makes it non-obvious. -**Suggested questions** — 4-5 questions the graph is uniquely positioned to answer, with the reason why (which bridge node makes it interesting, which community boundary it crosses) +**Suggested questions** - 4-5 questions the graph is uniquely positioned to answer, with the reason why (which bridge node makes it interesting, which community boundary it crosses) The full GRAPH_REPORT.md adds community summaries with cohesion scores and a list of ambiguous edges for review. @@ -140,26 +140,26 @@ The full GRAPH_REPORT.md adds community summaries with cohesion scores and a lis | `GRAPH_REPORT.md` | The audit report. God nodes, surprising connections, community cohesion scores, ambiguous edge list, suggested questions. | | `graph.json` | Persistent graph in node-link format. Load it with NetworkX or push to Neo4j. Survives sessions. | | `obsidian/` | Wikilink vault. Open in Obsidian → enable graph view → see communities as clusters. Filter by tag, search across everything. | -| `.graphify/cache/` | SHA256-based per-file cache. A re-run on an unchanged corpus takes seconds. | -| `.graphify/memory/` | Q&A feedback loop. Every `/graphify query` answer is saved here. Next `--update` extracts it into the graph. | +| `graphify-out/cache/` | SHA256-based per-file cache. A re-run on an unchanged corpus takes seconds. | +| `graphify-out/memory/` | Q&A feedback loop. Every `/graphify query` answer is saved here. Next `--update` extracts it into the graph. | ## What this skill will NOT do -- **Won't invent edges** — `AMBIGUOUS` exists so uncertain relationships are flagged, not hidden. If the connection isn't clear, it's tagged, not fabricated. -- **Won't claim the graph is useful when it isn't** — a corpus over 2M words or 200 files gets a cost warning before proceeding. -- **Won't re-extract unchanged files** — SHA256 cache ensures warm re-runs skip everything that hasn't changed. -- **Won't visualize graphs over 5,000 nodes** — use `--no-viz` or query instead. -- **Won't download datasets or set up infrastructure** — graphify reads your files. What you put in the folder is what it works with. -- **Won't implement baselines or run experiments** — it reads and maps. Analysis is yours. +- **Won't invent edges** - `AMBIGUOUS` exists so uncertain relationships are flagged, not hidden. If the connection isn't clear, it's tagged, not fabricated. +- **Won't claim the graph is useful when it isn't** - a corpus over 2M words or 200 files gets a cost warning before proceeding. +- **Won't re-extract unchanged files** - SHA256 cache ensures warm re-runs skip everything that hasn't changed. +- **Won't visualize graphs over 5,000 nodes** - use `--no-viz` or query instead. +- **Won't download datasets or set up infrastructure** - graphify reads your files. What you put in the folder is what it works with. +- **Won't implement baselines or run experiments** - it reads and maps. Analysis is yours. ## Design principles -1. **Extraction quality is everything** — clustering is downstream of it. A bad graph clusters into bad communities. The AST + call-graph pass exists because deterministic beats probabilistic for code. -2. **Show the numbers** — cohesion is `0.91`, not "good". Token cost is always printed. You know what you spent. -3. **The best output is what you didn't know** — Surprising Connections is not optional. God nodes you probably already suspected. Cross-community edges are what you came for. -4. **The graph earns its complexity** — below a certain density, just use Claude directly. The graph adds value when you have more than you can hold in context across sessions. -5. **What you ask grows the graph** — query results are filed back in automatically. The corpus is not static. -6. **Honest uncertainty** — `EXTRACTED`, `INFERRED`, `AMBIGUOUS` are not cosmetic labels. They are the difference between trusting the graph and being misled by it. +1. **Extraction quality is everything** - clustering is downstream of it. A bad graph clusters into bad communities. The AST + call-graph pass exists because deterministic beats probabilistic for code. +2. **Show the numbers** - cohesion is `0.91`, not "good". Token cost is always printed. You know what you spent. +3. **The best output is what you didn't know** - Surprising Connections is not optional. God nodes you probably already suspected. Cross-community edges are what you came for. +4. **The graph earns its complexity** - below a certain density, just use Claude directly. The graph adds value when you have more than you can hold in context across sessions. +5. **What you ask grows the graph** - query results are filed back in automatically. The corpus is not static. +6. **Honest uncertainty** - `EXTRACTED`, `INFERRED`, `AMBIGUOUS` are not cosmetic labels. They are the difference between trusting the graph and being misled by it. ## Contributing @@ -179,7 +179,7 @@ Worked examples are the most trust-building part of this project. To add one: **Improving extraction** -If you find a file type or language where extraction is poor, open an issue with a minimal reproduction case. The best bug reports include: the input file, the extraction output (`.graphify/cache/` entry), and what was missed or invented. +If you find a file type or language where extraction is poor, open an issue with a minimal reproduction case. The best bug reports include: the input file, the extraction output (`graphify-out/cache/` entry), and what was missed or invented. **Adding domain knowledge** @@ -193,7 +193,7 @@ If corpora in your domain consistently contain structures graphify doesn't extra | httpx (Python HTTP client) | Codebase (6 files) | small corpus¹ | [`worked/httpx/review.md`](worked/httpx/review.md) + [`GRAPH_REPORT.md`](worked/httpx/GRAPH_REPORT.md) | | Mixed corpus (code + paper + Arabic image) | Multi-type (5 files) | small corpus¹ | [`worked/mixed-corpus/review.md`](worked/mixed-corpus/review.md) | -¹ Small corpora fit in a single context window — graph value is structural clarity, not token reduction. Reduction ratios grow with corpus size. +¹ Small corpora fit in a single context window - graph value is structural clarity, not token reduction. Reduction ratios grow with corpus size. Each includes the full graph output and an honest evaluation of what the skill got right and wrong. @@ -213,26 +213,26 @@ No Neo4j required. No dashboards. No server. Runs entirely locally. ``` graphify/ -├── detect.py detect file types, auto-exclude venvs/caches/node_modules; scan .graphify/memory/ +├── detect.py detect file types, auto-exclude venvs/caches/node_modules; scan graphify-out/memory/ ├── extract.py AST extraction (13 languages via tree-sitter) + call-graph pass (INFERRED edges) ├── build.py assemble NetworkX graph from extraction JSON; schema-validates before assembly ├── cluster.py Leiden community detection, cohesion scoring ├── analyze.py god nodes, bridge nodes, surprising connections, suggested questions, graph diff ├── report.py render GRAPH_REPORT.md ├── export.py Obsidian vault, graph.json, graph.html, graph.svg, graph.graphml, Neo4j Cypher, Canvas -├── ingest.py fetch URLs (arXiv, Twitter/X, PDF, any webpage); save Q&A to .graphify/memory/ +├── ingest.py fetch URLs (arXiv, Twitter/X, PDF, any webpage); save Q&A to graphify-out/memory/ ├── cache.py SHA256-based per-file extraction cache; check_semantic_cache / save_semantic_cache ├── security.py URL validation (http/https only), safe fetch with size cap, path guards, label sanitisation ├── validate.py JSON schema checks on extraction output -├── serve.py MCP stdio server — query_graph, get_node, get_neighbors, shortest_path, god_nodes +├── serve.py MCP stdio server - query_graph, get_node, get_neighbors, shortest_path, god_nodes └── watch.py fs watcher, writes flag file when new files appear skills/graphify/ -└── skill.md the Claude Code skill — the full pipeline the agent runs step by step +└── skill.md the Claude Code skill - the full pipeline the agent runs step by step ARCHITECTURE.md module responsibilities, extraction schema, how to add a language SECURITY.md threat model, mitigations, vulnerability reporting worked/ eval reports from real corpora (karpathy-repos, httpx, mixed-corpus) -tests/ 212 tests, one file per module +tests/ 218 tests, one file per module pyproject.toml pip install graphify | pip install graphify[mcp,neo4j,pdf,watch] ``` diff --git a/SECURITY.md b/SECURITY.md index c6b42c238..6eecf757d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,27 +22,27 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 7 da ## Security Model -graphify is a **local development tool**. It runs as a Claude Code skill and optionally as a local MCP stdio server. It makes no network calls during graph analysis — only during `ingest` (explicit URL fetch by the user). +graphify is a **local development tool**. It runs as a Claude Code skill and optionally as a local MCP stdio server. It makes no network calls during graph analysis - only during `ingest` (explicit URL fetch by the user). ### Threat Surface | Vector | Mitigation | |--------|-----------| -| SSRF via URL fetch | `security.validate_url()` allows only `http` and `https` schemes. Redirect targets are re-validated by `_NoFileRedirectHandler` — a redirect to `file://` is blocked. | +| SSRF via URL fetch | `security.validate_url()` allows only `http` and `https` schemes. Redirect targets are re-validated by `_NoFileRedirectHandler` - a redirect to `file://` is blocked. | | Oversized downloads | `safe_fetch()` streams responses and aborts at 50 MB. `safe_fetch_text()` aborts at 10 MB. | -| Non-2xx HTTP responses | `safe_fetch()` raises `HTTPError` on non-2xx status codes — error pages are not silently treated as content. | -| Path traversal in MCP server | `security.validate_graph_path()` resolves paths and requires them to be inside `.graphify/`. Also requires the `.graphify/` directory to exist. | +| Non-2xx HTTP responses | `safe_fetch()` raises `HTTPError` on non-2xx status codes - error pages are not silently treated as content. | +| Path traversal in MCP server | `security.validate_graph_path()` resolves paths and requires them to be inside `graphify-out/`. Also requires the `graphify-out/` directory to exist. | | XSS in graph HTML output | `security.sanitize_label()` strips control characters, caps at 256 chars, and HTML-escapes all node labels and edge titles before pyvis embeds them. | -| Prompt injection via node labels | `sanitize_label()` also applied to MCP text output — node labels from user-controlled source files cannot break the text format returned to agents. | +| Prompt injection via node labels | `sanitize_label()` also applied to MCP text output - node labels from user-controlled source files cannot break the text format returned to agents. | | YAML frontmatter injection | Newlines stripped from user-provided strings before embedding in YAML frontmatter (e.g. in `save_query_result()`). | -| Encoding crashes on source files | All tree-sitter byte slices decoded with `errors="replace"` — non-UTF-8 source files degrade gracefully instead of crashing extraction. | +| Encoding crashes on source files | All tree-sitter byte slices decoded with `errors="replace"` - non-UTF-8 source files degrade gracefully instead of crashing extraction. | | Symlink traversal | `os.walk(..., followlinks=False)` is explicit throughout `detect.py`. | | Corrupted graph.json | `_load_graph()` in `serve.py` wraps `json.JSONDecodeError` and prints a clear recovery message instead of crashing. | ### What graphify does NOT do - Does not run a network listener (MCP server communicates over stdio only) -- Does not execute code from source files (tree-sitter parses ASTs — no eval/exec) +- Does not execute code from source files (tree-sitter parses ASTs - no eval/exec) - Does not use `shell=True` in any subprocess call - Does not store credentials or API keys diff --git a/graphify/__init__.py b/graphify/__init__.py index 3c12c5579..72fdb9b80 100644 --- a/graphify/__init__.py +++ b/graphify/__init__.py @@ -1,4 +1,4 @@ -"""graphify — extract · build · cluster · analyze · report.""" +"""graphify - extract · build · cluster · analyze · report.""" def __getattr__(name): diff --git a/graphify/__main__.py b/graphify/__main__.py index 2da1f6f5c..f59b12771 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1,4 +1,4 @@ -"""graphify CLI — `graphify install` sets up the Claude Code skill.""" +"""graphify CLI - `graphify install` sets up the Claude Code skill.""" from __future__ import annotations import json import shutil @@ -8,7 +8,7 @@ from pathlib import Path _SKILL_REGISTRATION = ( "\n# graphify\n" "- **graphify** (`~/.claude/skills/graphify/SKILL.md`) " - "— any input to knowledge graph. Trigger: `/graphify`\n" + "- any input to knowledge graph. Trigger: `/graphify`\n" "When the user types `/graphify`, invoke the Skill tool " "with `skill: \"graphify\"` before doing anything else.\n" ) @@ -22,7 +22,7 @@ def _bundled_skill() -> Path: def install() -> None: skill_src = _bundled_skill() if not skill_src.exists(): - print("error: skill.md not found in package — reinstall graphify", file=sys.stderr) + print("error: skill.md not found in package - reinstall graphify", file=sys.stderr) sys.exit(1) # Copy skill to ~/.claude/skills/graphify/SKILL.md @@ -67,7 +67,7 @@ def main() -> None: install() elif cmd == "benchmark": from graphify.benchmark import run_benchmark, print_benchmark - graph_path = sys.argv[2] if len(sys.argv) > 2 else ".graphify/graph.json" + graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json" # Try to load corpus_words from detect output corpus_words = None detect_path = Path(".graphify_detect.json") diff --git a/graphify/analyze.py b/graphify/analyze.py index cb414dd43..995b5fb7b 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -20,7 +20,7 @@ def _is_file_node(G: nx.Graph, node_id: str) -> bool: # Method stub: AST extractor labels methods as '.method_name()' if label.startswith(".") and label.endswith("()"): return True - # Module-level function stub: labeled 'function_name()' — only has a contains edge + # Module-level function stub: labeled 'function_name()' - only has a contains edge # These are real functions but structurally isolated by definition; not a gap worth flagging if label.endswith("()") and G.degree(node_id) <= 1: return True @@ -28,7 +28,7 @@ def _is_file_node(G: nx.Graph, node_id: str) -> bool: def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]: - """Return the top_n most-connected real entities — the core abstractions. + """Return the top_n most-connected real entities - the core abstractions. File-level hub nodes are excluded: they accumulate import/contains edges mechanically and don't represent meaningful architectural abstractions. @@ -55,7 +55,7 @@ def surprising_connections( top_n: int = 5, ) -> list[dict]: """ - Find connections that are genuinely surprising — not obvious from file structure. + Find connections that are genuinely surprising - not obvious from file structure. Strategy: - Multi-file corpora: cross-file edges between real entities (not concept nodes). @@ -118,7 +118,7 @@ def _file_category(path: str) -> str: def _top_level_dir(path: str) -> str: - """Return the first path component — used to detect cross-repo edges.""" + """Return the first path component - used to detect cross-repo edges.""" return path.split("/")[0] if "/" in path else path @@ -135,26 +135,26 @@ def _surprise_score( score = 0 reasons: list[str] = [] - # 1. Confidence weight — uncertain connections are more noteworthy + # 1. Confidence weight - uncertain connections are more noteworthy conf = data.get("confidence", "EXTRACTED") conf_bonus = {"AMBIGUOUS": 3, "INFERRED": 2, "EXTRACTED": 1}.get(conf, 1) score += conf_bonus if conf in ("AMBIGUOUS", "INFERRED"): - reasons.append(f"{conf.lower()} connection — not explicitly stated in source") + reasons.append(f"{conf.lower()} connection - not explicitly stated in source") - # 2. Cross file-type bonus — code↔paper or code↔image is non-obvious + # 2. Cross file-type bonus - code↔paper or code↔image is non-obvious cat_u = _file_category(u_source) cat_v = _file_category(v_source) if cat_u != cat_v: score += 2 reasons.append(f"crosses file types ({cat_u} ↔ {cat_v})") - # 3. Cross-repo bonus — different top-level directory + # 3. Cross-repo bonus - different top-level directory if _top_level_dir(u_source) != _top_level_dir(v_source): score += 2 reasons.append("connects across different repos/directories") - # 4. Cross-community bonus — Leiden says these are structurally distant + # 4. Cross-community bonus - Leiden says these are structurally distant cid_u = node_community.get(u) cid_v = node_community.get(v) if cid_u is not None and cid_v is not None and cid_u != cid_v: @@ -238,13 +238,13 @@ def _cross_community_surprises( ) -> list[dict]: """ For single-source corpora: find edges that bridge different communities. - These are surprising because Leiden grouped everything else tightly — + These are surprising because Leiden grouped everything else tightly - these edges cut across the natural structure. Falls back to high-betweenness edges if no community info is provided. """ if not communities: - # No community info — use edge betweenness centrality + # No community info - use edge betweenness centrality if G.number_of_edges() == 0: return [] betweenness = nx.edge_betweenness_centrality(G) @@ -280,7 +280,7 @@ def _cross_community_surprises( relation = data.get("relation", "") if relation in ("imports", "imports_from", "contains", "method"): continue - # This edge crosses community boundaries — interesting + # This edge crosses community boundaries - interesting confidence = data.get("confidence", "EXTRACTED") src_id = data.get("_src", u) tgt_id = data.get("_tgt", v) @@ -301,7 +301,7 @@ def _cross_community_surprises( order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2} surprises.sort(key=lambda x: order.get(x["confidence"], 3)) - # Deduplicate by community pair — one representative edge per (A→B) boundary. + # Deduplicate by community pair - one representative edge per (A→B) boundary. # Without this, a single high-betweenness god node dominates all results. seen_pairs: set[tuple] = set() deduped = [] @@ -336,7 +336,7 @@ def suggest_questions( questions.append({ "type": "ambiguous_edge", "question": f"What is the exact relationship between `{ul}` and `{vl}`?", - "why": f"Edge tagged AMBIGUOUS (relation: {relation}) — confidence is low.", + "why": f"Edge tagged AMBIGUOUS (relation: {relation}) - confidence is low.", }) # 2. Bridge nodes (high betweenness) → cross-cutting concern questions @@ -360,7 +360,7 @@ def suggest_questions( questions.append({ "type": "bridge_node", "question": f"Why does `{label}` connect `{comm_label}` to {', '.join(f'`{l}`' for l in other_labels)}?", - "why": f"High betweenness centrality ({score:.3f}) — this node is a cross-community bridge.", + "why": f"High betweenness centrality ({score:.3f}) - this node is a cross-community bridge.", }) # 3. God nodes with many INFERRED edges → verification questions @@ -387,7 +387,7 @@ def suggest_questions( questions.append({ "type": "verify_inferred", "question": f"Are the {len(inferred)} inferred relationships involving `{label}` (e.g. with `{others[0]}` and `{others[1]}`) actually correct?", - "why": f"`{label}` has {len(inferred)} INFERRED edges — model-reasoned connections that need verification.", + "why": f"`{label}` has {len(inferred)} INFERRED edges - model-reasoned connections that need verification.", }) # 4. Isolated or weakly-connected nodes → exploration questions @@ -400,7 +400,7 @@ def suggest_questions( questions.append({ "type": "isolated_nodes", "question": f"What connects {', '.join(f'`{l}`' for l in labels)} to the rest of the system?", - "why": f"{len(isolated)} weakly-connected nodes found — possible documentation gaps or missing edges.", + "why": f"{len(isolated)} weakly-connected nodes found - possible documentation gaps or missing edges.", }) # 5. Low-cohesion communities → structural questions @@ -412,7 +412,7 @@ def suggest_questions( questions.append({ "type": "low_cohesion", "question": f"Should `{label}` be split into smaller, more focused modules?", - "why": f"Cohesion score {score} — nodes in this community are weakly interconnected.", + "why": f"Cohesion score {score} - nodes in this community are weakly interconnected.", }) if not questions: diff --git a/graphify/benchmark.py b/graphify/benchmark.py index e3085e681..5d8725a28 100644 --- a/graphify/benchmark.py +++ b/graphify/benchmark.py @@ -1,4 +1,4 @@ -"""Token-reduction benchmark — measures how much context graphify saves vs naive full-corpus approach.""" +"""Token-reduction benchmark - measures how much context graphify saves vs naive full-corpus approach.""" from __future__ import annotations import json from pathlib import Path @@ -62,7 +62,7 @@ _SAMPLE_QUESTIONS = [ def run_benchmark( - graph_path: str = ".graphify/graph.json", + graph_path: str = "graphify-out/graph.json", corpus_words: int | None = None, questions: list[str] | None = None, ) -> dict: diff --git a/graphify/build.py b/graphify/build.py index 09fe172b9..02e6ac0e8 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -14,7 +14,7 @@ def build_from_json(extraction: dict) -> nx.Graph: G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) for edge in extraction.get("edges", []): attrs = {k: v for k, v in edge.items() if k not in ("source", "target")} - # Preserve original edge direction — undirected graphs lose it otherwise, + # Preserve original edge direction - undirected graphs lose it otherwise, # causing display functions to show edges backwards. attrs["_src"] = edge["source"] attrs["_tgt"] = edge["target"] diff --git a/graphify/cache.py b/graphify/cache.py index acefe6792..99db860d1 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -1,4 +1,4 @@ -# per-file extraction cache — skip unchanged files on re-run +# per-file extraction cache - skip unchanged files on re-run from __future__ import annotations import hashlib @@ -13,8 +13,8 @@ def file_hash(path: Path) -> str: def cache_dir(root: Path = Path(".")) -> Path: - """Returns .graphify/cache/ — creates it if needed.""" - d = Path(root) / ".graphify" / "cache" + """Returns graphify-out/cache/ - creates it if needed.""" + d = Path(root) / "graphify-out" / "cache" d.mkdir(parents=True, exist_ok=True) return d @@ -23,7 +23,7 @@ def load_cached(path: Path, root: Path = Path(".")) -> dict | None: """Return cached extraction for this file if hash matches, else None. Cache key: SHA256 of file contents. - Cache value: stored as .graphify/cache/{hash}.json + Cache value: stored as graphify-out/cache/{hash}.json Returns None if no cache entry or file has changed. """ try: @@ -42,7 +42,7 @@ def load_cached(path: Path, root: Path = Path(".")) -> dict | None: def save_cached(path: Path, result: dict, root: Path = Path(".")) -> None: """Save extraction result for this file. - Stores as .graphify/cache/{hash}.json where hash = SHA256 of current file contents. + Stores as graphify-out/cache/{hash}.json where hash = SHA256 of current file contents. result should be a dict with 'nodes' and 'edges' lists. """ h = file_hash(path) @@ -57,7 +57,7 @@ def cached_files(root: Path = Path(".")) -> set[str]: def clear_cache(root: Path = Path(".")) -> None: - """Delete all .graphify/cache/*.json files.""" + """Delete all graphify-out/cache/*.json files.""" d = cache_dir(root) for f in d.glob("*.json"): f.unlink() diff --git a/graphify/cluster.py b/graphify/cluster.py index dbbeacc9e..b5c97b7c8 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -36,9 +36,9 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]: if G.number_of_edges() == 0: return {i: [n] for i, n in enumerate(sorted(G.nodes))} - from graspologic.partition import leiden # lazy — avoids 15s numba JIT on import + from graspologic.partition import leiden # lazy - avoids 15s numba JIT on import - # Leiden warns and drops isolates — handle them separately + # Leiden warns and drops isolates - handle them separately isolates = [n for n in G.nodes() if G.degree(n) == 0] connected_nodes = [n for n in G.nodes() if G.degree(n) > 0] connected = G.subgraph(connected_nodes) @@ -73,7 +73,7 @@ def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]: """Run a second Leiden pass on a community subgraph to split it further.""" subgraph = G.subgraph(nodes) if subgraph.number_of_edges() == 0: - # No edges — split into individual nodes + # No edges - split into individual nodes return [[n] for n in sorted(nodes)] try: from graspologic.partition import leiden @@ -82,7 +82,7 @@ def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]: for node, cid in sub_partition.items(): sub_communities.setdefault(cid, []).append(node) if len(sub_communities) <= 1: - # Leiden couldn't split it — return as-is + # Leiden couldn't split it - return as-is return [sorted(nodes)] return [sorted(v) for v in sub_communities.values()] except Exception: diff --git a/graphify/detect.py b/graphify/detect.py index c1a90d869..7c623f3dc 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -13,18 +13,18 @@ class FileType(str, Enum): IMAGE = "image" -_MANIFEST_PATH = ".graphify/manifest.json" +_MANIFEST_PATH = "graphify-out/manifest.json" CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php'} DOC_EXTENSIONS = {'.md', '.txt', '.rst'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} -CORPUS_WARN_THRESHOLD = 50_000 # words — below this, warn "you may not need a graph" -CORPUS_UPPER_THRESHOLD = 500_000 # words — above this, warn about token cost -FILE_COUNT_UPPER = 200 # files — above this, warn about token cost +CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a graph" +CORPUS_UPPER_THRESHOLD = 500_000 # words - above this, warn about token cost +FILE_COUNT_UPPER = 200 # files - above this, warn about token cost -# Files that may contain secrets — skip silently +# Files that may contain secrets - skip silently _SENSITIVE_PATTERNS = [ re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE), re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE), @@ -111,7 +111,7 @@ def count_words(path: Path) -> int: return 0 -# Directory names to always skip — venvs, caches, build artifacts, deps +# Directory names to always skip - venvs, caches, build artifacts, deps _SKIP_DIRS = { "venv", ".venv", "env", ".env", "node_modules", "__pycache__", ".git", @@ -144,8 +144,8 @@ def detect(root: Path) -> dict: skipped_sensitive: list[str] = [] - # Always include .graphify/memory/ — query results filed back into the graph - memory_dir = root / ".graphify" / "memory" + # Always include graphify-out/memory/ - query results filed back into the graph + memory_dir = root / "graphify-out" / "memory" scan_paths = [root] if memory_dir.exists(): scan_paths.append(memory_dir) @@ -189,11 +189,11 @@ def detect(root: Path) -> dict: total_files = sum(len(v) for v in files.values()) needs_graph = total_words >= CORPUS_WARN_THRESHOLD - # Determine warning — lower bound, upper bound, or sensitive files skipped + # Determine warning - lower bound, upper bound, or sensitive files skipped warning: str | None = None if not needs_graph: warning = ( - f"Corpus is ~{total_words:,} words — fits in a single context window. " + f"Corpus is ~{total_words:,} words - fits in a single context window. " f"You may not need a graph." ) elif total_words >= CORPUS_UPPER_THRESHOLD or total_files >= FILE_COUNT_UPPER: @@ -229,7 +229,7 @@ def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PA try: manifest[f] = Path(f).stat().st_mtime except OSError: - pass # file deleted between detect() and manifest write — skip it + pass # file deleted between detect() and manifest write - skip it Path(manifest_path).parent.mkdir(parents=True, exist_ok=True) Path(manifest_path).write_text(json.dumps(manifest, indent=2)) @@ -244,7 +244,7 @@ def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict: manifest = load_manifest(manifest_path) if not manifest: - # No previous run — treat everything as new + # No previous run - treat everything as new full["incremental"] = True full["new_files"] = full["files"] full["unchanged_files"] = {k: [] for k in full["files"]} diff --git a/graphify/export.py b/graphify/export.py index 2edf82595..a52c61159 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -27,7 +27,7 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> def to_cypher(G: nx.Graph, output_path: str) -> None: - lines = ["// Neo4j Cypher import — generated by /graphify", ""] + lines = ["// Neo4j Cypher import - generated by /graphify", ""] for node_id, data in G.nodes(data=True): label = data.get("label", node_id).replace("'", "\\'") ftype = data.get("file_type", "unknown").capitalize() @@ -58,7 +58,7 @@ def to_html( if G.number_of_nodes() > MAX_NODES_FOR_VIZ: raise ValueError( - f"Graph has {G.number_of_nodes()} nodes — too large for pyvis. " + f"Graph has {G.number_of_nodes()} nodes - too large for pyvis. " f"Use --no-viz or reduce input size." ) @@ -119,7 +119,7 @@ def to_html( Path(output_path).write_text(content) -# Keep backward-compatible alias — skill.md calls generate_html +# Keep backward-compatible alias - skill.md calls generate_html generate_html = to_html @@ -130,7 +130,7 @@ def to_obsidian( community_labels: dict[int, str] | None = None, cohesion: dict[int, float] | None = None, ) -> int: - """Export graph as an Obsidian vault — one .md file per node with [[wikilinks]], + """Export graph as an Obsidian vault - one .md file per node with [[wikilinks]], plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix). Open the output directory as a vault in Obsidian to get an interactive @@ -196,7 +196,7 @@ def to_obsidian( lines: list[str] = [] - # YAML frontmatter — readable in Obsidian's properties panel + # YAML frontmatter - readable in Obsidian's properties panel lines += [ "---", f'source_file: "{data.get("source_file", "")}"', @@ -220,7 +220,7 @@ def to_obsidian( neighbor_label = node_filename[neighbor] relation = edge_data.get("relation", "") confidence = edge_data.get("confidence", "EXTRACTED") - lines.append(f"- [[{neighbor_label}]] — `{relation}` [{confidence}]") + lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]") lines.append("") # Inline tags at bottom of note body (for Obsidian tag panel) @@ -283,7 +283,7 @@ def to_obsidian( else "moderately connected" if coh_value >= 0.4 else "loosely connected" ) - lines.append(f"**Cohesion:** {coh_value:.2f} — {cohesion_desc}") + lines.append(f"**Cohesion:** {coh_value:.2f} - {cohesion_desc}") lines.append(f"**Members:** {n_members} nodes") lines.append("") @@ -296,9 +296,9 @@ def to_obsidian( source = data.get("source_file", "") entry = f"- [[{node_label}]]" if ftype: - entry += f" — {ftype}" + entry += f" - {ftype}" if source: - entry += f" — {source}" + entry += f" - {source}" lines.append(entry) lines.append("") @@ -326,7 +326,7 @@ def to_obsidian( lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[_COMMUNITY_{other_safe}]]") lines.append("") - # Top bridge nodes — highest degree nodes that connect to other communities + # Top bridge nodes - highest degree nodes that connect to other communities bridge_nodes = [ (node_id, G.degree(node_id), _community_reach(node_id)) for node_id in members @@ -339,7 +339,7 @@ def to_obsidian( for node_id, degree, reach in top_bridges: node_label = node_filename[node_id] lines.append( - f"- [[{node_label}]] — degree {degree}, connects to {reach} " + f"- [[{node_label}]] - degree {degree}, connects to {reach} " f"{'community' if reach == 1 else 'communities'}" ) @@ -372,7 +372,7 @@ def to_canvas( community_labels: dict[int, str] | None = None, node_filenames: dict[str, str] | None = None, ) -> None: - """Export graph as an Obsidian Canvas file — communities as groups, nodes as cards. + """Export graph as an Obsidian Canvas file - communities as groups, nodes as cards. Generates a structured layout: communities arranged in a grid, nodes within each community arranged in rows. Edges shown between connected nodes. @@ -481,7 +481,7 @@ def to_canvas( "color": canvas_color, }) - # Node cards inside the group — rows of 3 + # Node cards inside the group - rows of 3 sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n)) for m_idx, node_id in enumerate(sorted_members): col = m_idx % 3 @@ -499,7 +499,7 @@ def to_canvas( "height": 60, }) - # Generate edges — only between nodes both in canvas, cap at 200 highest-weight + # Generate edges - only between nodes both in canvas, cap at 200 highest-weight all_edges_weighted: list[tuple[float, str, str, str]] = [] for u, v, edata in G.edges(data=True): if u in all_canvas_nodes and v in all_canvas_nodes: @@ -533,7 +533,7 @@ def push_to_neo4j( Requires: pip install neo4j - Uses MERGE so re-running is safe — nodes and edges are upserted, not duplicated. + Uses MERGE so re-running is safe - nodes and edges are upserted, not duplicated. Returns a dict with counts of nodes and edges pushed. """ try: @@ -591,7 +591,7 @@ def to_graphml( communities: dict[int, list[str]], output_path: str, ) -> None: - """Export graph as GraphML — opens in Gephi, yEd, and any GraphML-compatible tool. + """Export graph as GraphML - opens in Gephi, yEd, and any GraphML-compatible tool. Community IDs are written as a node attribute so Gephi can colour by community. Edge confidence (EXTRACTED/INFERRED/AMBIGUOUS) is preserved as an edge attribute. @@ -612,7 +612,7 @@ def to_svg( ) -> None: """Export graph as an SVG file using matplotlib + spring layout. - Lightweight and embeddable — works in Obsidian notes, Notion, GitHub READMEs, + Lightweight and embeddable - works in Obsidian notes, Notion, GitHub READMEs, and any markdown renderer. No JavaScript required. Node size scales with degree. Community colors match the pyvis HTML output. @@ -639,7 +639,7 @@ def to_svg( node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()] node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()] - # Draw edges — dashed for non-EXTRACTED + # Draw edges - dashed for non-EXTRACTED for u, v, data in G.edges(data=True): conf = data.get("confidence", "EXTRACTED") style = "solid" if conf == "EXTRACTED" else "dashed" diff --git a/graphify/extract.py b/graphify/extract.py index c56199c46..c5ceddc00 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -60,7 +60,7 @@ def extract_python(path: Path) -> dict: "weight": 1.0, }) - # File-level node — stable ID based on stem only + # File-level node - stable ID based on stem only file_nid = _make_id(stem) add_node(file_nid, path.name, 1) @@ -94,7 +94,7 @@ def extract_python(path: Path) -> dict: add_node(class_nid, class_name, line) add_edge(file_nid, class_nid, "contains", line) - # Inheritance — create stub node for external bases so the edge is never dropped + # Inheritance - create stub node for external bases so the edge is never dropped args = node.child_by_field_name("superclasses") if args: for arg in args.children: @@ -103,7 +103,7 @@ def extract_python(path: Path) -> dict: # Try same-file base first; fall back to a bare stub base_nid = _make_id(stem, base) if base_nid not in seen_ids: - # External or forward-declared base — add a stub so edge survives + # External or forward-declared base - add a stub so edge survives base_nid = _make_id(base) if base_nid not in seen_ids: nodes.append({ @@ -162,7 +162,7 @@ def extract_python(path: Path) -> dict: seen_call_pairs: set[tuple[str, str]] = set() def walk_calls(node, caller_nid: str) -> None: - # Don't recurse into nested function definitions — they have their own context. + # Don't recurse into nested function definitions - they have their own context. if node.type == "function_definition": return if node.type == "call": @@ -1316,7 +1316,7 @@ def extract_ruby(path: Path) -> dict: add_edge_raw(file_nid, class_nid, "contains", line) body = node.child_by_field_name("body") if body is None: - # body may not be a named field — walk all children except first/last + # body may not be a named field - walk all children except first/last for child in node.children: if child.type == "body_statement": body = child @@ -1725,7 +1725,7 @@ def extract_kotlin(path: Path) -> dict: if first.type == "simple_identifier": callee_name = source[first.start_byte:first.end_byte].decode("utf-8", errors="replace") elif first.type == "navigation_expression": - # obj.method — get the suffix + # obj.method - get the suffix for child in reversed(first.children): if child.type == "simple_identifier": callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") @@ -2127,8 +2127,8 @@ def _resolve_cross_file_imports( """ Two-pass import resolution: turn file-level imports into class-level edges. - Pass 1 — build a global map: class/function name → node_id, per stem. - Pass 2 — for each `from .module import Name`, look up Name in the global + Pass 1 - build a global map: class/function name → node_id, per stem. + Pass 2 - for each `from .module import Name`, look up Name in the global map and add a direct INFERRED edge from each class in the importing file to the imported entity. @@ -2189,7 +2189,7 @@ def _resolve_cross_file_imports( def walk_imports(node) -> None: if node.type == "import_from_statement": - # Find the module name — handles both absolute and relative imports. + # Find the module name - handles both absolute and relative imports. # Relative: `from .models import X` → relative_import → dotted_name # Absolute: `from models import X` → module_name field target_stem: str | None = None @@ -2224,7 +2224,7 @@ def _resolve_cross_file_imports( source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") ) elif child.type == "aliased_import": - # `import X as Y` — take the original name + # `import X as Y` - take the original name name_node = child.child_by_field_name("name") if name_node: imported_names.append( @@ -2396,7 +2396,7 @@ def extract(paths: list[Path]) -> dict: all_nodes.extend(result.get("nodes", [])) all_edges.extend(result.get("edges", [])) - # Add cross-file class-level edges (Python only — uses Python parser internally) + # Add cross-file class-level edges (Python only - uses Python parser internally) py_paths = [p for p in paths if p.suffix == ".py"] py_results = [r for r, p in zip(per_file, paths) if p.suffix == ".py"] cross_file_edges = _resolve_cross_file_imports(py_results, py_paths) diff --git a/graphify/ingest.py b/graphify/ingest.py index 3ac54d3fc..70be44985 100644 --- a/graphify/ingest.py +++ b/graphify/ingest.py @@ -74,7 +74,7 @@ def _fetch_tweet(url: str, author: str | None, contributor: str | None) -> tuple tweet_text = re.sub(r"<[^>]+>", "", data.get("html", "")).strip() tweet_author = data.get("author_name", "unknown") except Exception: - # oEmbed failed — save URL stub + # oEmbed failed - save URL stub tweet_text = f"Tweet at {url} (could not fetch content)" tweet_author = "unknown" @@ -215,7 +215,7 @@ def ingest(url: str, target_dir: Path, author: str | None = None, contributor: s raise RuntimeError(f"ingest: failed to fetch {url!r}: {exc}") from exc out_path = target_dir / filename - # Avoid overwriting — append counter if needed + # Avoid overwriting - append counter if needed counter = 1 while out_path.exists(): stem = Path(filename).stem @@ -236,7 +236,7 @@ def save_query_result( ) -> Path: """Save a Q&A result as markdown so it gets extracted into the graph on next --update. - Files are stored in memory_dir (typically .graphify/memory/) with YAML frontmatter + Files are stored in memory_dir (typically graphify-out/memory/) with YAML frontmatter that graphify's extractor reads as node metadata. This closes the feedback loop: the system grows smarter from both what you add AND what you ask. """ diff --git a/graphify/report.py b/graphify/report.py index 885de83ef..1a67a52b8 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -1,4 +1,4 @@ -# generate GRAPH_REPORT.md — the human-readable audit trail +# generate GRAPH_REPORT.md - the human-readable audit trail from __future__ import annotations from datetime import date import networkx as nx @@ -25,7 +25,7 @@ def generate( amb_pct = round(confidences.count("AMBIGUOUS") / total * 100) lines = [ - f"# Graph Report — {root} ({today})", + f"# Graph Report - {root} ({today})", "", "## Corpus Check", ] @@ -44,10 +44,10 @@ def generate( f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS", f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output", "", - "## God Nodes (most connected — your core abstractions)", + "## God Nodes (most connected - your core abstractions)", ] for i, node in enumerate(god_node_list, 1): - lines.append(f"{i}. `{node['label']}` — {node['edges']} edges") + lines.append(f"{i}. `{node['label']}` - {node['edges']} edges") lines += ["", "## Surprising Connections (you probably didn't know these)"] if surprise_list: @@ -60,27 +60,27 @@ def generate( f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""), ] else: - lines.append("- None detected — all connections are within the same source files.") + lines.append("- None detected - all connections are within the same source files.") lines += ["", "## Communities"] from .analyze import _is_file_node as _ifn for cid, nodes in communities.items(): label = community_labels.get(cid, f"Community {cid}") score = cohesion_scores.get(cid, 0.0) - # Filter method/function stubs from display — they're structural noise + # Filter method/function stubs from display - they're structural noise real_nodes = [n for n in nodes if not _ifn(G, n)] display = [G.nodes[n].get("label", n) for n in real_nodes[:8]] suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else "" lines += [ "", - f"### Community {cid} — \"{label}\"", + f"### Community {cid} - \"{label}\"", f"Cohesion: {score}", f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}", ] ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"] if ambiguous: - lines += ["", "## Ambiguous Edges — Review These"] + lines += ["", "## Ambiguous Edges - Review These"] for u, v, d in ambiguous: ul = G.nodes[u].get("label", u) vl = G.nodes[v].get("label", v) @@ -107,13 +107,13 @@ def generate( isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]] suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else "" lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}") - lines.append(" These have ≤1 connection — possible missing edges or undocumented components.") + lines.append(" These have ≤1 connection - possible missing edges or undocumented components.") if thin_communities: for cid, nodes in thin_communities.items(): label = community_labels.get(cid, f"Community {cid}") node_labels = [G.nodes[n].get("label", n) for n in nodes] lines.append(f"- **Thin community `{label}`** ({len(nodes)} nodes): {', '.join(f'`{l}`' for l in node_labels)}") - lines.append(" Too small to be a meaningful cluster — may be noise or needs more connections extracted.") + lines.append(" Too small to be a meaningful cluster - may be noise or needs more connections extracted.") if amb_pct > 20: lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.") diff --git a/graphify/security.py b/graphify/security.py index 1e9ed132b..d23ad9570 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -1,4 +1,4 @@ -# Security helpers — URL validation, safe fetch, path guards, label sanitisation +# Security helpers - URL validation, safe fetch, path guards, label sanitisation from __future__ import annotations import html @@ -26,7 +26,7 @@ def validate_url(url: str) -> str: parsed = urllib.parse.urlparse(url) if parsed.scheme.lower() not in _ALLOWED_SCHEMES: raise ValueError( - f"Blocked URL scheme '{parsed.scheme}' — only http and https are allowed. " + f"Blocked URL scheme '{parsed.scheme}' - only http and https are allowed. " f"Got: {url!r}" ) return url @@ -63,10 +63,10 @@ def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) - - Network errors propagate as urllib.error.URLError / OSError Raises: - ValueError — disallowed scheme or redirect target - urllib.error.HTTPError — non-2xx HTTP status - urllib.error.URLError — DNS / connection failure - OSError — size cap exceeded + ValueError - disallowed scheme or redirect target + urllib.error.HTTPError - non-2xx HTTP status + urllib.error.URLError - DNS / connection failure + OSError - size cap exceeded """ validate_url(url) opener = _build_opener() @@ -112,16 +112,16 @@ def safe_fetch_text(url: str, max_bytes: int = _MAX_TEXT_BYTES, timeout: int = 1 def validate_graph_path(path: str | Path, base: Path | None = None) -> Path: """Resolve *path* and verify it stays inside *base*. - *base* defaults to the `.graphify` directory relative to CWD. + *base* defaults to the `graphify-out` directory relative to CWD. Also requires the base directory to exist, so a caller cannot trick graphify into reading files before any graph has been built. Raises: - ValueError — path escapes base, or base does not exist - FileNotFoundError — resolved path does not exist + ValueError - path escapes base, or base does not exist + FileNotFoundError - resolved path does not exist """ if base is None: - base = Path(".graphify").resolve() + base = Path("graphify-out").resolve() base = base.resolve() if not base.exists(): @@ -136,7 +136,7 @@ def validate_graph_path(path: str | Path, base: Path | None = None) -> Path: except ValueError: raise ValueError( f"Path {path!r} escapes the allowed directory {base}. " - "Only paths inside .graphify/ are permitted." + "Only paths inside graphify-out/ are permitted." ) if not resolved.exists(): diff --git a/graphify/serve.py b/graphify/serve.py index d738e5aec..cc1a398fe 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1,4 +1,4 @@ -# MCP stdio server — exposes graph query tools to Claude and other agents +# MCP stdio server - exposes graph query tools to Claude and other agents from __future__ import annotations import json import sys @@ -93,7 +93,7 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu return output -def serve(graph_path: str = ".graphify/graph.json") -> None: +def serve(graph_path: str = "graphify-out/graph.json") -> None: """Start the MCP server. Requires pip install mcp.""" try: from mcp.server import Server @@ -161,7 +161,7 @@ def serve(graph_path: str = ".graphify/graph.json") -> None: ), types.Tool( name="god_nodes", - description="Return the most connected nodes — the core abstractions of the knowledge graph.", + description="Return the most connected nodes - the core abstractions of the knowledge graph.", inputSchema={ "type": "object", "properties": { @@ -260,7 +260,7 @@ def serve(graph_path: str = ".graphify/graph.json") -> None: nodes = _god_nodes(G, top_n=top_n) lines = ["God nodes (most connected):"] for i, n in enumerate(nodes, 1): - lines.append(f" {i}. {n['label']} — {n['edges']} edges") + lines.append(f" {i}. {n['label']} - {n['edges']} edges") return [types.TextContent(type="text", text="\n".join(lines))] elif name == "graph_stats": @@ -324,5 +324,5 @@ def serve(graph_path: str = ".graphify/graph.json") -> None: if __name__ == "__main__": - graph_path = sys.argv[1] if len(sys.argv) > 1 else ".graphify/graph.json" + graph_path = sys.argv[1] if len(sys.argv) > 1 else "graphify-out/graph.json" serve(graph_path) diff --git a/graphify/skill.md b/graphify/skill.md index cddaa60e8..75191c0aa 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -14,20 +14,20 @@ Turn any folder of files into a navigable knowledge graph with community detecti /graphify # full pipeline on current directory → Obsidian vault /graphify # full pipeline on specific path /graphify --mode deep # thorough extraction, richer INFERRED edges -/graphify --update # incremental — re-extract only new/changed files +/graphify --update # incremental - re-extract only new/changed files /graphify --cluster-only # rerun clustering on existing graph /graphify --no-viz # skip visualization, just report + JSON /graphify --html # also export graph.html (pyvis, browser-based) /graphify --svg # also export graph.svg (embeds in Notion, GitHub) -/graphify --neo4j # generate .graphify/cypher.txt for Neo4j +/graphify --neo4j # generate graphify-out/cypher.txt for Neo4j /graphify --neo4j-push bolt://localhost:7687 # push directly to Neo4j /graphify --mcp # start MCP stdio server for agent access /graphify --watch # watch folder, notify when files change /graphify add # fetch URL, save to ./raw, update graph /graphify add --author "Name" # tag who wrote it /graphify add --contributor "Name" # tag who added it to the corpus -/graphify query "" # BFS traversal — broad context -/graphify query "" --dfs # DFS — trace a specific path +/graphify query "" # BFS traversal - broad context +/graphify query "" --dfs # DFS - trace a specific path /graphify query "" --budget 1500 # cap answer at N tokens /graphify path "AuthModule" "Database" # shortest path between two concepts /graphify explain "SwinTransformer" # plain-language explanation of a node @@ -35,12 +35,12 @@ Turn any folder of files into a navigable knowledge graph with community detecti ## What graphify is for -graphify is built around Andrej Karpathy's /raw folder workflow: drop anything into a folder — papers, tweets, screenshots, code, notes — and get a structured knowledge graph that shows you what you didn't know was connected. +graphify is built around Andrej Karpathy's /raw folder workflow: drop anything into a folder - papers, tweets, screenshots, code, notes - and get a structured knowledge graph that shows you what you didn't know was connected. Three things it does that Claude alone cannot: -1. **Persistent graph** — relationships are stored in `.graphify/graph.json` and survive across sessions. Ask questions weeks later without re-reading everything. -2. **Honest audit trail** — every edge is tagged EXTRACTED, INFERRED, or AMBIGUOUS. You know what was found vs invented. -3. **Cross-document surprise** — community detection finds connections between concepts in different files that you would never think to ask about directly. +1. **Persistent graph** - relationships are stored in `graphify-out/graph.json` and survive across sessions. Ask questions weeks later without re-reading everything. +2. **Honest audit trail** - every edge is tagged EXTRACTED, INFERRED, or AMBIGUOUS. You know what was found vs invented. +3. **Cross-document surprise** - community detection finds connections between concepts in different files that you would never think to ask about directly. Use it for: - A codebase you're new to (understand architecture before touching anything) @@ -54,7 +54,7 @@ If no path was given, use `.` (current directory). Do not ask the user for a pat Follow these steps in order. Do not skip steps. -### Step 1 — Ensure graphify is installed +### Step 1 - Ensure graphify is installed ```bash python3 -c "import graphify" 2>/dev/null || pip install graphify -q --break-system-packages 2>&1 | tail -3 @@ -62,7 +62,7 @@ python3 -c "import graphify" 2>/dev/null || pip install graphify -q --break-syst If the import succeeds, print nothing and move straight to Step 2. -### Step 2 — Detect files +### Step 2 - Detect files ```bash python3 -c " @@ -74,7 +74,7 @@ print(json.dumps(result)) " > .graphify_detect.json ``` -Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON — read it silently and present a clean summary instead: +Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` Corpus: X files · ~Y words @@ -88,15 +88,15 @@ Then act on it: - If `total_files` is 0: stop with "No supported files found in [path]." - If `skipped_sensitive` is non-empty: mention file count skipped, not the file names. - If `total_words` > 2,000,000 OR `total_files` > 200: show the warning and the top 5 subdirectories by file count, then ask which subfolder to run on. Wait for the user's answer before proceeding. -- Otherwise: proceed directly to Step 3 — no need to ask anything. +- Otherwise: proceed directly to Step 3 - no need to ask anything. -### Step 3 — Extract entities and relationships +### Step 3 - Extract entities and relationships -**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation — do not lose it. +**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it. This step has two parts: **structural extraction** (deterministic, free) then **semantic extraction** (Claude, costs tokens). -#### Part A — Structural extraction for code files +#### Part A - Structural extraction for code files For any code files detected, run AST extraction first: @@ -118,20 +118,20 @@ if code_files: print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: Path('.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0})) - print('No code files — skipping AST extraction') + print('No code files - skipping AST extraction') " ``` -#### Part B — Semantic extraction (parallel subagents) +#### Part B - Semantic extraction (parallel subagents) -**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden — it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.** +**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.** Before dispatching subagents, print a cost estimate: - Load `total_words` from `.graphify_detect.json` - Estimate: ~(total_words / 750) input tokens per file on average, output ~20% of that - Print: "Semantic extraction: ~N files, estimated ~X input tokens" -**Step B0 — Check extraction cache first** +**Step B0 - Check extraction cache first** Before dispatching any subagents, check which files already have cached extraction results: @@ -155,13 +155,13 @@ print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files n Only dispatch subagents for files listed in `.graphify_uncached.txt`. If all files are cached, skip to Part C directly. -**Step B1 — Split into chunks** +**Step B1 - Split into chunks** Load files from `.graphify_uncached.txt`. Split into chunks of 12-15 files each. Each image gets its own chunk (vision needs separate context). -**Step B2 — Dispatch ALL subagents in a single message** +**Step B2 - Dispatch ALL subagents in a single message** -Call the Agent tool multiple times IN THE SAME RESPONSE — one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose. +Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose. Concrete example for 3 chunks: ``` @@ -175,7 +175,7 @@ Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL ``` You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment. -Output ONLY valid JSON matching the schema below — no explanation, no markdown fences, no preamble. +Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble. Files (chunk CHUNK_NUM of TOTAL_CHUNKS): FILE_LIST @@ -183,12 +183,12 @@ FILE_LIST Rules: - EXTRACTED: relationship explicit in source (import, call, citation, "see §3.2") - INFERRED: reasonable inference (shared data structure, implied dependency) -- AMBIGUOUS: uncertain — flag for review, do not omit +- AMBIGUOUS: uncertain - flag for review, do not omit Code files: focus on semantic edges AST cannot find (call relationships, shared data, arch patterns). - Do not re-extract imports — AST already has those. + Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. -Image files: use vision to understand what the image IS — do not just OCR. +Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. Tweet/post: claim as node, author, concepts mentioned. @@ -196,7 +196,7 @@ Image files: use vision to understand what the image IS — do not just OCR. Research figure: what it demonstrates, method, result. Handwritten/whiteboard: ideas and arrows, mark uncertain readings AMBIGUOUS. -DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges — indirect deps, +DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges - indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, @@ -206,11 +206,11 @@ Output exactly this JSON (no other text): {"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","source_file":"relative/path","source_location":null,"weight":1.0}],"input_tokens":0,"output_tokens":0} ``` -**Step B3 — Collect, cache, and merge** +**Step B3 - Collect, cache, and merge** Wait for all subagents. For each result: - If a subagent returned valid JSON with `nodes` and `edges`, include it and save each file's nodes/edges to the cache -- If a subagent failed or returned invalid JSON, print a warning and skip that chunk — do not abort +- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort If more than half the chunks failed, stop and tell the user. @@ -252,12 +252,12 @@ merged = { 'output_tokens': new.get('output_tokens', 0), } Path('.graphify_semantic.json').write_text(json.dumps(merged, indent=2)) -print(f'Extraction complete — {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)') +print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)') " ``` Clean up temp files: `rm -f .graphify_cached.json .graphify_uncached.txt .graphify_semantic_new.json` -#### Part C — Merge AST + semantic into final extraction +#### Part C - Merge AST + semantic into final extraction ```bash python3 -c " @@ -289,10 +289,10 @@ print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(s " ``` -### Step 4 — Build graph, cluster, analyze, generate outputs +### Step 4 - Build graph, cluster, analyze, generate outputs ```bash -mkdir -p .graphify +mkdir -p graphify-out python3 -c " import sys, json from graphify.build import build_from_json @@ -312,12 +312,12 @@ tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get(' gods = god_nodes(G) surprises = surprising_connections(G, communities) labels = {cid: 'Community ' + str(cid) for cid in communities} -# Placeholder questions — regenerated with real labels in Step 5 +# Placeholder questions - regenerated with real labels in Step 5 questions = suggest_questions(G, communities, labels) report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions) -Path('.graphify/GRAPH_REPORT.md').write_text(report) -to_json(G, communities, '.graphify/graph.json') +Path('graphify-out/GRAPH_REPORT.md').write_text(report) +to_json(G, communities, 'graphify-out/graph.json') analysis = { 'communities': {str(k): v for k, v in communities.items()}, @@ -328,18 +328,18 @@ analysis = { } Path('.graphify_analysis.json').write_text(json.dumps(analysis, indent=2)) if G.number_of_nodes() == 0: - print('ERROR: Graph is empty — extraction produced no nodes.') + print('ERROR: Graph is empty - extraction produced no nodes.') print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.') raise SystemExit(1) print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities') " ``` -If this step prints `ERROR: Graph is empty`, stop and tell the user what happened — do not proceed to labeling or visualization. +If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization. Replace INPUT_PATH with the actual path. -### Step 5 — Label communities +### Step 5 - Label communities Read `.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading"). @@ -363,14 +363,14 @@ communities = {int(k): v for k, v in analysis['communities'].items()} cohesion = {int(k): v for k, v in analysis['cohesion'].items()} tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)} -# LABELS — replace these with the names you chose above +# LABELS - replace these with the names you chose above labels = LABELS_DICT # Regenerate questions with real community labels (labels affect question phrasing) questions = suggest_questions(G, communities, labels) report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions) -Path('.graphify/GRAPH_REPORT.md').write_text(report) +Path('graphify-out/GRAPH_REPORT.md').write_text(report) Path('.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()})) print('Report updated with community labels') " @@ -379,9 +379,9 @@ print('Report updated with community labels') Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`). Replace INPUT_PATH with the actual path. -### Step 6 — Generate Obsidian vault (default) + optional HTML +### Step 6 - Generate Obsidian vault (default) + optional HTML -**Always generate the Obsidian vault** — it is the primary visualization. Skip only if `--no-viz`. +**Always generate the Obsidian vault** - it is the primary visualization. Skip only if `--no-viz`. ```bash python3 -c " @@ -399,16 +399,16 @@ communities = {int(k): v for k, v in analysis['communities'].items()} cohesion = {int(k): v for k, v in analysis['cohesion'].items()} labels = {int(k): v for k, v in labels_raw.items()} -n = to_obsidian(G, communities, '.graphify/obsidian', community_labels=labels or None, cohesion=cohesion) -print(f'Obsidian vault: {n} notes in .graphify/obsidian/') +n = to_obsidian(G, communities, 'graphify-out/obsidian', community_labels=labels or None, cohesion=cohesion) +print(f'Obsidian vault: {n} notes in graphify-out/obsidian/') -to_canvas(G, communities, '.graphify/obsidian/graph.canvas', community_labels=labels or None) -print('Canvas: .graphify/obsidian/graph.canvas — open in Obsidian for structured community layout') +to_canvas(G, communities, 'graphify-out/obsidian/graph.canvas', community_labels=labels or None) +print('Canvas: graphify-out/obsidian/graph.canvas - open in Obsidian for structured community layout') print() -print('Open .graphify/obsidian/ as a vault in Obsidian.') -print(' Graph view — nodes colored by community (set automatically)') -print(' graph.canvas — structured layout with communities as groups') -print(' _COMMUNITY_* — overview notes with cohesion scores and dataview queries') +print('Open graphify-out/obsidian/ as a vault in Obsidian.') +print(' Graph view - nodes colored by community (set automatically)') +print(' graph.canvas - structured layout with communities as groups') +print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries') " ``` @@ -430,16 +430,16 @@ communities = {int(k): v for k, v in analysis['communities'].items()} labels = {int(k): v for k, v in labels_raw.items()} if G.number_of_nodes() > 5000: - print(f'Graph has {G.number_of_nodes()} nodes — too large for pyvis. Use Obsidian vault instead.') + print(f'Graph has {G.number_of_nodes()} nodes - too large for pyvis. Use Obsidian vault instead.') else: - generate_html(G, communities, '.graphify/graph.html', community_labels=labels or None) + generate_html(G, communities, 'graphify-out/graph.html', community_labels=labels or None) print('graph.html written') " ``` -### Step 7 — Neo4j export (only if --neo4j or --neo4j-push flag) +### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag) -**If `--neo4j`** — generate a Cypher file for manual import: +**If `--neo4j`** - generate a Cypher file for manual import: ```bash python3 -c " @@ -449,12 +449,12 @@ from graphify.export import to_cypher from pathlib import Path G = build_from_json(json.loads(Path('.graphify_extract.json').read_text())) -to_cypher(G, '.graphify/cypher.txt') -print('cypher.txt written — import with: cypher-shell < .graphify/cypher.txt') +to_cypher(G, 'graphify-out/cypher.txt') +print('cypher.txt written - import with: cypher-shell < graphify-out/cypher.txt') " ``` -**If `--neo4j-push `** — push directly to a running Neo4j instance. Ask the user for credentials if not provided: +**If `--neo4j-push `** - push directly to a running Neo4j instance. Ask the user for credentials if not provided: ```bash python3 -c " @@ -474,9 +474,9 @@ print(f'Pushed to Neo4j: {result[\"nodes\"]} nodes, {result[\"edges\"]} edges') " ``` -Replace `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` with actual values. Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE — safe to re-run without creating duplicates. +Replace `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` with actual values. Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates. -### Step 7b — SVG export (only if --svg flag) +### Step 7b - SVG export (only if --svg flag) ```bash python3 -c " @@ -493,19 +493,19 @@ G = build_from_json(extraction) communities = {int(k): v for k, v in analysis['communities'].items()} labels = {int(k): v for k, v in labels_raw.items()} -to_svg(G, communities, '.graphify/graph.svg', community_labels=labels or None) -print('graph.svg written — embeds in Obsidian, Notion, GitHub READMEs') +to_svg(G, communities, 'graphify-out/graph.svg', community_labels=labels or None) +print('graph.svg written - embeds in Obsidian, Notion, GitHub READMEs') " ``` -### Step 7c — SVG export already covered in Step 7b above +### Step 7c - SVG export already covered in Step 7b above -_(No separate --obsidian flag — Obsidian vault is always generated in Step 6 by default.)_ +_(No separate --obsidian flag - Obsidian vault is always generated in Step 6 by default.)_ -### Step 7d — MCP server (only if --mcp flag) +### Step 7d - MCP server (only if --mcp flag) ```bash -python3 -m graphify.serve .graphify/graph.json +python3 -m graphify.serve graphify-out/graph.json ``` This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live. @@ -516,13 +516,13 @@ To configure in Claude Desktop, add to `claude_desktop_config.json`: "mcpServers": { "graphify": { "command": "python3", - "args": ["-m", "graphify.serve", "/absolute/path/to/.graphify/graph.json"] + "args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"] } } } ``` -### Step 8 — Save manifest, update cost tracker, clean up, and report +### Step 8 - Save manifest, update cost tracker, clean up, and report ```bash python3 -c " @@ -540,7 +540,7 @@ extract = json.loads(Path('.graphify_extract.json').read_text()) input_tok = extract.get('input_tokens', 0) output_tok = extract.get('output_tokens', 0) -cost_path = Path('.graphify/cost.json') +cost_path = Path('graphify-out/cost.json') if cost_path.exists(): cost = json.loads(cost_path.read_text()) else: @@ -560,18 +560,18 @@ print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json -rm -f .graphify/.needs_update 2>/dev/null || true +rm -f graphify-out/.needs_update 2>/dev/null || true ``` Tell the user: ``` -Graph complete. Outputs in .graphify/ +Graph complete. Outputs in graphify-out/ - obsidian/ — open this folder as a vault in Obsidian to explore interactively - GRAPH_REPORT.md — full audit report (also readable here in Claude) - graph.json — persistent graph, queryable in future sessions with /graphify query + obsidian/ - open this folder as a vault in Obsidian to explore interactively + GRAPH_REPORT.md - full audit report (also readable here in Claude) + graph.json - persistent graph, queryable in future sessions with /graphify query -To explore: open Obsidian → File → Open Vault → select .graphify/obsidian/ +To explore: open Obsidian → File → Open Vault → select graphify-out/obsidian/ ``` Then paste these sections from GRAPH_REPORT.md directly into the chat: @@ -579,13 +579,13 @@ Then paste these sections from GRAPH_REPORT.md directly into the chat: - Surprising Connections - Suggested Questions -Do NOT paste the full report — just those three sections. Keep it concise. +Do NOT paste the full report - just those three sections. Keep it concise. --- ## For --update (incremental re-extraction) -Use when you've added or modified files since the last run. Only re-extracts changed files — saves tokens and time. +Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. ```bash python3 -c " @@ -615,7 +615,7 @@ import networkx as nx from pathlib import Path # Load existing graph -existing_data = json.loads(Path('.graphify/graph.json').read_text()) +existing_data = json.loads(Path('graphify-out/graph.json').read_text()) G_existing = json_graph.node_link_graph(existing_data, edges='links') # Load new extraction @@ -662,14 +662,14 @@ if old_data: " ``` -Before the merge step, save the old graph: `cp .graphify/graph.json .graphify_old.json` +Before the merge step, save the old graph: `cp graphify-out/graph.json .graphify_old.json` Clean up after: `rm -f .graphify_old.json` --- ## For --cluster-only -Skip Steps 1–3. Load the existing graph from `.graphify/graph.json` and re-run clustering: +Skip Steps 1–3. Load the existing graph from `graphify-out/graph.json` and re-run clustering: ```bash python3 -c " @@ -682,7 +682,7 @@ from networkx.readwrite import json_graph import networkx as nx from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') detection = {'total_files': 0, 'total_words': 99999, 'needs_graph': True, 'warning': None, @@ -696,8 +696,8 @@ surprises = surprising_connections(G, communities) labels = {cid: 'Community ' + str(cid) for cid in communities} report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, '.') -Path('.graphify/GRAPH_REPORT.md').write_text(report) -to_json(G, communities, '.graphify/graph.json') +Path('graphify-out/GRAPH_REPORT.md').write_text(report) +to_json(G, communities, 'graphify-out/graph.json') analysis = { 'communities': {str(k): v for k, v in communities.items()}, @@ -716,20 +716,20 @@ Then run Steps 5–8 as normal (label communities, generate viz, clean up, repor ## For /graphify query -Two traversal modes — choose based on the question: +Two traversal modes - choose based on the question: | Mode | Flag | Best for | |------|------|----------| -| BFS (default) | _(none)_ | "What is X connected to?" — broad context, nearest neighbors first | -| DFS | `--dfs` | "How does X reach Y?" — trace a specific chain or dependency path | +| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first | +| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path | -Load `.graphify/graph.json`, then: +Load `graphify-out/graph.json`, then: 1. Find the 1-3 nodes whose label best matches key terms in the question. 2. Run the appropriate traversal from each starting node. -3. Read the subgraph — node labels, edge relations, confidence tags, source locations. +3. Read the subgraph - node labels, edge relations, confidence tags, source locations. 4. Answer using **only** what the graph contains. Quote `source_location` when citing a specific fact. -5. If the graph lacks enough information, say so — do not hallucinate edges. +5. If the graph lacks enough information, say so - do not hallucinate edges. ```bash python3 -c " @@ -738,7 +738,7 @@ from networkx.readwrite import json_graph import networkx as nx from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') question = 'QUESTION' @@ -813,7 +813,7 @@ for u, v in subgraph_edges: output = '\n'.join(lines) if len(output) > char_budget: - output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget — use --budget N for more)' + output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)' print(output) " ``` @@ -829,11 +829,11 @@ from pathlib import Path save_query_result( question='QUESTION', answer='ANSWER', - memory_dir=Path('.graphify/memory'), + memory_dir=Path('graphify-out/memory'), query_type='query', source_nodes=SOURCE_NODES, # list of node labels cited, or [] ) -print('Query result saved to .graphify/memory/') +print('Query result saved to graphify-out/memory/') " ``` @@ -852,7 +852,7 @@ import networkx as nx from networkx.readwrite import json_graph from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') a_term = 'NODE_A' @@ -893,7 +893,7 @@ except nx.NodeNotFound as e: " ``` -Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language — what each hop means, why it's significant. +Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant. After writing the explanation, save it back: @@ -904,11 +904,11 @@ from pathlib import Path save_query_result( question='Path from NODE_A to NODE_B', answer='ANSWER', - memory_dir=Path('.graphify/memory'), + memory_dir=Path('graphify-out/memory'), query_type='path_query', source_nodes=PATH_NODES, # list of node labels on the path ) -print('Path result saved to .graphify/memory/') +print('Path result saved to graphify-out/memory/') " ``` @@ -916,7 +916,7 @@ print('Path result saved to .graphify/memory/') ## For /graphify explain -Give a plain-language explanation of a single node — everything connected to it. +Give a plain-language explanation of a single node - everything connected to it. ```bash python3 -c " @@ -925,7 +925,7 @@ import networkx as nx from networkx.readwrite import json_graph from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') term = 'NODE_NAME' @@ -970,11 +970,11 @@ from pathlib import Path save_query_result( question='Explain NODE_NAME', answer='ANSWER', - memory_dir=Path('.graphify/memory'), + memory_dir=Path('graphify-out/memory'), query_type='explain', source_nodes=['NODE_NAME'], ) -print('Explanation saved to .graphify/memory/') +print('Explanation saved to graphify-out/memory/') " ``` @@ -1002,7 +1002,7 @@ except RuntimeError as e: " ``` -Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong — do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. +Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. Supported URL types (auto-detected): - Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author @@ -1023,7 +1023,7 @@ python3 -m graphify.watch INPUT_PATH --debounce 3 Replace INPUT_PATH with the folder to watch. Every time a supported file is added or modified, graphify waits `debounce` seconds (default 3) after the last change, then runs the `--update` pipeline automatically. Press Ctrl+C to stop. -For the personal inspo use case: leave this running in a terminal. Drop tweets, screenshots, papers, and notes into the folder throughout the day — the graph updates itself. +For the personal inspo use case: leave this running in a terminal. Drop tweets, screenshots, papers, and notes into the folder throughout the day - the graph updates itself. --- @@ -1032,5 +1032,5 @@ For the personal inspo use case: leave this running in a terminal. Drop tweets, - Never invent an edge. If unsure, use AMBIGUOUS. - Never skip the corpus check warning. - Always show token cost in the report. -- Never hide cohesion scores behind symbols — show the raw number. +- Never hide cohesion scores behind symbols - show the raw number. - Never run pyvis on a graph with more than 5,000 nodes without warning the user. diff --git a/graphify/validate.py b/graphify/validate.py index 4029e66ee..39434091c 100644 --- a/graphify/validate.py +++ b/graphify/validate.py @@ -10,7 +10,7 @@ REQUIRED_EDGE_FIELDS = {"source", "target", "relation", "confidence", "source_fi def validate_extraction(data: dict) -> list[str]: """ Validate an extraction JSON dict against the graphify schema. - Returns a list of error strings — empty list means valid. + Returns a list of error strings - empty list means valid. """ if not isinstance(data, dict): return ["Extraction must be a JSON object"] @@ -33,7 +33,7 @@ def validate_extraction(data: dict) -> list[str]: if "file_type" in node and node["file_type"] not in VALID_FILE_TYPES: errors.append( f"Node {i} (id={node.get('id', '?')!r}) has invalid file_type " - f"'{node['file_type']}' — must be one of {sorted(VALID_FILE_TYPES)}" + f"'{node['file_type']}' - must be one of {sorted(VALID_FILE_TYPES)}" ) # Edges @@ -53,7 +53,7 @@ def validate_extraction(data: dict) -> list[str]: if "confidence" in edge and edge["confidence"] not in VALID_CONFIDENCES: errors.append( f"Edge {i} has invalid confidence '{edge['confidence']}' " - f"— must be one of {sorted(VALID_CONFIDENCES)}" + f"- must be one of {sorted(VALID_CONFIDENCES)}" ) if "source" in edge and node_ids and edge["source"] not in node_ids: errors.append(f"Edge {i} source '{edge['source']}' does not match any node id") diff --git a/graphify/watch.py b/graphify/watch.py index d83e3e637..efa3c6fbc 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -14,7 +14,7 @@ _WATCHED_EXTENSIONS = { def _run_update(watch_path: Path) -> None: """Write a flag file and print a notification when files change.""" - flag = watch_path / ".graphify" / "needs_update" + flag = watch_path / "graphify-out" / "needs_update" flag.parent.mkdir(parents=True, exist_ok=True) flag.write_text("1") print(f"\n[graphify watch] New or changed files detected in {watch_path}") @@ -56,8 +56,8 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None: observer.schedule(handler, str(watch_path), recursive=True) observer.start() - print(f"[graphify watch] Watching {watch_path.resolve()} — press Ctrl+C to stop") - print(f"[graphify watch] Debounce: {debounce}s — will update {debounce}s after last change") + print(f"[graphify watch] Watching {watch_path.resolve()} - press Ctrl+C to stop") + print(f"[graphify watch] Debounce: {debounce}s - will update {debounce}s after last change") try: while True: diff --git a/pyproject.toml b/pyproject.toml index d68b1b777..79b0360d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" version = "0.1.3" -description = "Claude Code skill — turn any folder of code, docs, papers, images, or tweets into a queryable knowledge graph" +description = "Claude Code skill - turn any folder of code, docs, papers, images, or tweets into a queryable knowledge graph" readme = "README.md" license = { text = "MIT" } keywords = ["claude", "claude-code", "knowledge-graph", "rag", "graphrag", "obsidian", "community-detection", "tree-sitter", "leiden", "llm"] diff --git a/skills/graphify/skill.md b/skills/graphify/skill.md index cddaa60e8..ee986c8b1 100644 --- a/skills/graphify/skill.md +++ b/skills/graphify/skill.md @@ -14,20 +14,20 @@ Turn any folder of files into a navigable knowledge graph with community detecti /graphify # full pipeline on current directory → Obsidian vault /graphify # full pipeline on specific path /graphify --mode deep # thorough extraction, richer INFERRED edges -/graphify --update # incremental — re-extract only new/changed files +/graphify --update # incremental - re-extract only new/changed files /graphify --cluster-only # rerun clustering on existing graph /graphify --no-viz # skip visualization, just report + JSON /graphify --html # also export graph.html (pyvis, browser-based) /graphify --svg # also export graph.svg (embeds in Notion, GitHub) -/graphify --neo4j # generate .graphify/cypher.txt for Neo4j +/graphify --neo4j # generate graphify-out/cypher.txt for Neo4j /graphify --neo4j-push bolt://localhost:7687 # push directly to Neo4j /graphify --mcp # start MCP stdio server for agent access /graphify --watch # watch folder, notify when files change /graphify add # fetch URL, save to ./raw, update graph /graphify add --author "Name" # tag who wrote it /graphify add --contributor "Name" # tag who added it to the corpus -/graphify query "" # BFS traversal — broad context -/graphify query "" --dfs # DFS — trace a specific path +/graphify query "" # BFS traversal - broad context +/graphify query "" --dfs # DFS - trace a specific path /graphify query "" --budget 1500 # cap answer at N tokens /graphify path "AuthModule" "Database" # shortest path between two concepts /graphify explain "SwinTransformer" # plain-language explanation of a node @@ -35,12 +35,12 @@ Turn any folder of files into a navigable knowledge graph with community detecti ## What graphify is for -graphify is built around Andrej Karpathy's /raw folder workflow: drop anything into a folder — papers, tweets, screenshots, code, notes — and get a structured knowledge graph that shows you what you didn't know was connected. +graphify is built around Andrej Karpathy's /raw folder workflow: drop anything into a folder - papers, tweets, screenshots, code, notes - and get a structured knowledge graph that shows you what you didn't know was connected. Three things it does that Claude alone cannot: -1. **Persistent graph** — relationships are stored in `.graphify/graph.json` and survive across sessions. Ask questions weeks later without re-reading everything. -2. **Honest audit trail** — every edge is tagged EXTRACTED, INFERRED, or AMBIGUOUS. You know what was found vs invented. -3. **Cross-document surprise** — community detection finds connections between concepts in different files that you would never think to ask about directly. +1. **Persistent graph** - relationships are stored in `graphify-out/graph.json` and survive across sessions. Ask questions weeks later without re-reading everything. +2. **Honest audit trail** - every edge is tagged EXTRACTED, INFERRED, or AMBIGUOUS. You know what was found vs invented. +3. **Cross-document surprise** - community detection finds connections between concepts in different files that you would never think to ask about directly. Use it for: - A codebase you're new to (understand architecture before touching anything) @@ -54,7 +54,7 @@ If no path was given, use `.` (current directory). Do not ask the user for a pat Follow these steps in order. Do not skip steps. -### Step 1 — Ensure graphify is installed +### Step 1 - Ensure graphify is installed ```bash python3 -c "import graphify" 2>/dev/null || pip install graphify -q --break-system-packages 2>&1 | tail -3 @@ -62,7 +62,7 @@ python3 -c "import graphify" 2>/dev/null || pip install graphify -q --break-syst If the import succeeds, print nothing and move straight to Step 2. -### Step 2 — Detect files +### Step 2 - Detect files ```bash python3 -c " @@ -74,7 +74,7 @@ print(json.dumps(result)) " > .graphify_detect.json ``` -Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON — read it silently and present a clean summary instead: +Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead: ``` Corpus: X files · ~Y words @@ -88,15 +88,15 @@ Then act on it: - If `total_files` is 0: stop with "No supported files found in [path]." - If `skipped_sensitive` is non-empty: mention file count skipped, not the file names. - If `total_words` > 2,000,000 OR `total_files` > 200: show the warning and the top 5 subdirectories by file count, then ask which subfolder to run on. Wait for the user's answer before proceeding. -- Otherwise: proceed directly to Step 3 — no need to ask anything. +- Otherwise: proceed directly to Step 3 - no need to ask anything. -### Step 3 — Extract entities and relationships +### Step 3 - Extract entities and relationships -**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation — do not lose it. +**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it. This step has two parts: **structural extraction** (deterministic, free) then **semantic extraction** (Claude, costs tokens). -#### Part A — Structural extraction for code files +#### Part A - Structural extraction for code files For any code files detected, run AST extraction first: @@ -118,20 +118,20 @@ if code_files: print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') else: Path('.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0})) - print('No code files — skipping AST extraction') + print('No code files - skipping AST extraction') " ``` -#### Part B — Semantic extraction (parallel subagents) +#### Part B - Semantic extraction (parallel subagents) -**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden — it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.** +**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.** Before dispatching subagents, print a cost estimate: - Load `total_words` from `.graphify_detect.json` - Estimate: ~(total_words / 750) input tokens per file on average, output ~20% of that - Print: "Semantic extraction: ~N files, estimated ~X input tokens" -**Step B0 — Check extraction cache first** +**Step B0 - Check extraction cache first** Before dispatching any subagents, check which files already have cached extraction results: @@ -155,13 +155,13 @@ print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files n Only dispatch subagents for files listed in `.graphify_uncached.txt`. If all files are cached, skip to Part C directly. -**Step B1 — Split into chunks** +**Step B1 - Split into chunks** Load files from `.graphify_uncached.txt`. Split into chunks of 12-15 files each. Each image gets its own chunk (vision needs separate context). -**Step B2 — Dispatch ALL subagents in a single message** +**Step B2 - Dispatch ALL subagents in a single message** -Call the Agent tool multiple times IN THE SAME RESPONSE — one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose. +Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose. Concrete example for 3 chunks: ``` @@ -175,7 +175,7 @@ Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL ``` You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment. -Output ONLY valid JSON matching the schema below — no explanation, no markdown fences, no preamble. +Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble. Files (chunk CHUNK_NUM of TOTAL_CHUNKS): FILE_LIST @@ -183,12 +183,12 @@ FILE_LIST Rules: - EXTRACTED: relationship explicit in source (import, call, citation, "see §3.2") - INFERRED: reasonable inference (shared data structure, implied dependency) -- AMBIGUOUS: uncertain — flag for review, do not omit +- AMBIGUOUS: uncertain - flag for review, do not omit Code files: focus on semantic edges AST cannot find (call relationships, shared data, arch patterns). - Do not re-extract imports — AST already has those. + Do not re-extract imports - AST already has those. Doc/paper files: extract named concepts, entities, citations. -Image files: use vision to understand what the image IS — do not just OCR. +Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. Tweet/post: claim as node, author, concepts mentioned. @@ -196,7 +196,7 @@ Image files: use vision to understand what the image IS — do not just OCR. Research figure: what it demonstrates, method, result. Handwritten/whiteboard: ideas and arrows, mark uncertain readings AMBIGUOUS. -DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges — indirect deps, +DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges - indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, @@ -206,11 +206,11 @@ Output exactly this JSON (no other text): {"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","source_file":"relative/path","source_location":null,"weight":1.0}],"input_tokens":0,"output_tokens":0} ``` -**Step B3 — Collect, cache, and merge** +**Step B3 - Collect, cache, and merge** Wait for all subagents. For each result: - If a subagent returned valid JSON with `nodes` and `edges`, include it and save each file's nodes/edges to the cache -- If a subagent failed or returned invalid JSON, print a warning and skip that chunk — do not abort +- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort If more than half the chunks failed, stop and tell the user. @@ -252,12 +252,12 @@ merged = { 'output_tokens': new.get('output_tokens', 0), } Path('.graphify_semantic.json').write_text(json.dumps(merged, indent=2)) -print(f'Extraction complete — {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)') +print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)') " ``` Clean up temp files: `rm -f .graphify_cached.json .graphify_uncached.txt .graphify_semantic_new.json` -#### Part C — Merge AST + semantic into final extraction +#### Part C - Merge AST + semantic into final extraction ```bash python3 -c " @@ -289,10 +289,10 @@ print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(s " ``` -### Step 4 — Build graph, cluster, analyze, generate outputs +### Step 4 - Build graph, cluster, analyze, generate outputs ```bash -mkdir -p .graphify +mkdir -p graphify-out python3 -c " import sys, json from graphify.build import build_from_json @@ -312,12 +312,12 @@ tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get(' gods = god_nodes(G) surprises = surprising_connections(G, communities) labels = {cid: 'Community ' + str(cid) for cid in communities} -# Placeholder questions — regenerated with real labels in Step 5 +# Placeholder questions - regenerated with real labels in Step 5 questions = suggest_questions(G, communities, labels) report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions) -Path('.graphify/GRAPH_REPORT.md').write_text(report) -to_json(G, communities, '.graphify/graph.json') +Path('graphify-out/GRAPH_REPORT.md').write_text(report) +to_json(G, communities, 'graphify-out/graph.json') analysis = { 'communities': {str(k): v for k, v in communities.items()}, @@ -328,18 +328,18 @@ analysis = { } Path('.graphify_analysis.json').write_text(json.dumps(analysis, indent=2)) if G.number_of_nodes() == 0: - print('ERROR: Graph is empty — extraction produced no nodes.') + print('ERROR: Graph is empty - extraction produced no nodes.') print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.') raise SystemExit(1) print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities') " ``` -If this step prints `ERROR: Graph is empty`, stop and tell the user what happened — do not proceed to labeling or visualization. +If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization. Replace INPUT_PATH with the actual path. -### Step 5 — Label communities +### Step 5 - Label communities Read `.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading"). @@ -363,14 +363,14 @@ communities = {int(k): v for k, v in analysis['communities'].items()} cohesion = {int(k): v for k, v in analysis['cohesion'].items()} tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)} -# LABELS — replace these with the names you chose above +# LABELS - replace these with the names you chose above labels = LABELS_DICT # Regenerate questions with real community labels (labels affect question phrasing) questions = suggest_questions(G, communities, labels) report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions) -Path('.graphify/GRAPH_REPORT.md').write_text(report) +Path('graphify-out/GRAPH_REPORT.md').write_text(report) Path('.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()})) print('Report updated with community labels') " @@ -379,9 +379,9 @@ print('Report updated with community labels') Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`). Replace INPUT_PATH with the actual path. -### Step 6 — Generate Obsidian vault (default) + optional HTML +### Step 6 - Generate Obsidian vault (default) + optional HTML -**Always generate the Obsidian vault** — it is the primary visualization. Skip only if `--no-viz`. +**Always generate the Obsidian vault** - it is the primary visualization. Skip only if `--no-viz`. ```bash python3 -c " @@ -399,16 +399,16 @@ communities = {int(k): v for k, v in analysis['communities'].items()} cohesion = {int(k): v for k, v in analysis['cohesion'].items()} labels = {int(k): v for k, v in labels_raw.items()} -n = to_obsidian(G, communities, '.graphify/obsidian', community_labels=labels or None, cohesion=cohesion) -print(f'Obsidian vault: {n} notes in .graphify/obsidian/') +n = to_obsidian(G, communities, 'graphify-out/obsidian', community_labels=labels or None, cohesion=cohesion) +print(f'Obsidian vault: {n} notes in graphify-out/obsidian/') -to_canvas(G, communities, '.graphify/obsidian/graph.canvas', community_labels=labels or None) -print('Canvas: .graphify/obsidian/graph.canvas — open in Obsidian for structured community layout') +to_canvas(G, communities, 'graphify-out/obsidian/graph.canvas', community_labels=labels or None) +print('Canvas: graphify-out/obsidian/graph.canvas - open in Obsidian for structured community layout') print() -print('Open .graphify/obsidian/ as a vault in Obsidian.') -print(' Graph view — nodes colored by community (set automatically)') -print(' graph.canvas — structured layout with communities as groups') -print(' _COMMUNITY_* — overview notes with cohesion scores and dataview queries') +print('Open graphify-out/obsidian/ as a vault in Obsidian.') +print(' Graph view - nodes colored by community (set automatically)') +print(' graph.canvas - structured layout with communities as groups') +print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries') " ``` @@ -430,16 +430,16 @@ communities = {int(k): v for k, v in analysis['communities'].items()} labels = {int(k): v for k, v in labels_raw.items()} if G.number_of_nodes() > 5000: - print(f'Graph has {G.number_of_nodes()} nodes — too large for pyvis. Use Obsidian vault instead.') + print(f'Graph has {G.number_of_nodes()} nodes - too large for pyvis. Use Obsidian vault instead.') else: - generate_html(G, communities, '.graphify/graph.html', community_labels=labels or None) + generate_html(G, communities, 'graphify-out/graph.html', community_labels=labels or None) print('graph.html written') " ``` -### Step 7 — Neo4j export (only if --neo4j or --neo4j-push flag) +### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag) -**If `--neo4j`** — generate a Cypher file for manual import: +**If `--neo4j`** - generate a Cypher file for manual import: ```bash python3 -c " @@ -449,12 +449,12 @@ from graphify.export import to_cypher from pathlib import Path G = build_from_json(json.loads(Path('.graphify_extract.json').read_text())) -to_cypher(G, '.graphify/cypher.txt') -print('cypher.txt written — import with: cypher-shell < .graphify/cypher.txt') +to_cypher(G, 'graphify-out/cypher.txt') +print('cypher.txt written - import with: cypher-shell < graphify-out/cypher.txt') " ``` -**If `--neo4j-push `** — push directly to a running Neo4j instance. Ask the user for credentials if not provided: +**If `--neo4j-push `** - push directly to a running Neo4j instance. Ask the user for credentials if not provided: ```bash python3 -c " @@ -474,9 +474,9 @@ print(f'Pushed to Neo4j: {result[\"nodes\"]} nodes, {result[\"edges\"]} edges') " ``` -Replace `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` with actual values. Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE — safe to re-run without creating duplicates. +Replace `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` with actual values. Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates. -### Step 7b — SVG export (only if --svg flag) +### Step 7b - SVG export (only if --svg flag) ```bash python3 -c " @@ -493,19 +493,19 @@ G = build_from_json(extraction) communities = {int(k): v for k, v in analysis['communities'].items()} labels = {int(k): v for k, v in labels_raw.items()} -to_svg(G, communities, '.graphify/graph.svg', community_labels=labels or None) -print('graph.svg written — embeds in Obsidian, Notion, GitHub READMEs') +to_svg(G, communities, 'graphify-out/graph.svg', community_labels=labels or None) +print('graph.svg written - embeds in Obsidian, Notion, GitHub READMEs') " ``` -### Step 7c — SVG export already covered in Step 7b above +### Step 7c - SVG export already covered in Step 7b above -_(No separate --obsidian flag — Obsidian vault is always generated in Step 6 by default.)_ +_(No separate --obsidian flag - Obsidian vault is always generated in Step 6 by default.)_ -### Step 7d — MCP server (only if --mcp flag) +### Step 7d - MCP server (only if --mcp flag) ```bash -python3 -m graphify.serve .graphify/graph.json +python3 -m graphify.serve graphify-out/graph.json ``` This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live. @@ -516,13 +516,13 @@ To configure in Claude Desktop, add to `claude_desktop_config.json`: "mcpServers": { "graphify": { "command": "python3", - "args": ["-m", "graphify.serve", "/absolute/path/to/.graphify/graph.json"] + "args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"] } } } ``` -### Step 8 — Save manifest, update cost tracker, clean up, and report +### Step 8 - Save manifest, update cost tracker, clean up, and report ```bash python3 -c " @@ -540,7 +540,7 @@ extract = json.loads(Path('.graphify_extract.json').read_text()) input_tok = extract.get('input_tokens', 0) output_tok = extract.get('output_tokens', 0) -cost_path = Path('.graphify/cost.json') +cost_path = Path('graphify-out/cost.json') if cost_path.exists(): cost = json.loads(cost_path.read_text()) else: @@ -560,32 +560,41 @@ print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens') print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)') " rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json -rm -f .graphify/.needs_update 2>/dev/null || true +rm -f graphify-out/.needs_update 2>/dev/null || true ``` Tell the user: ``` -Graph complete. Outputs in .graphify/ +Graph complete. Outputs are in a hidden folder called graphify-out/ inside the directory you ran this on. - obsidian/ — open this folder as a vault in Obsidian to explore interactively - GRAPH_REPORT.md — full audit report (also readable here in Claude) - graph.json — persistent graph, queryable in future sessions with /graphify query +The folder is hidden (dot prefix) so it won't show in Finder or a normal ls. +To see it: + Mac/Linux: ls -la graphify-out/ + VS Code: the Explorer panel shows hidden files by default + Finder: Cmd+Shift+. to toggle hidden files -To explore: open Obsidian → File → Open Vault → select .graphify/obsidian/ +What's inside: + graphify-out/obsidian/ - open this folder as a vault in Obsidian (File > Open Vault) + graphify-out/GRAPH_REPORT.md - full audit report, also readable here in Claude + graphify-out/graph.json - persistent graph, query it later with /graphify query "..." + +Full path: PATH_TO_DIR/graphify-out/ ``` +Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. + Then paste these sections from GRAPH_REPORT.md directly into the chat: - God Nodes - Surprising Connections - Suggested Questions -Do NOT paste the full report — just those three sections. Keep it concise. +Do NOT paste the full report - just those three sections. Keep it concise. --- ## For --update (incremental re-extraction) -Use when you've added or modified files since the last run. Only re-extracts changed files — saves tokens and time. +Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time. ```bash python3 -c " @@ -615,7 +624,7 @@ import networkx as nx from pathlib import Path # Load existing graph -existing_data = json.loads(Path('.graphify/graph.json').read_text()) +existing_data = json.loads(Path('graphify-out/graph.json').read_text()) G_existing = json_graph.node_link_graph(existing_data, edges='links') # Load new extraction @@ -662,14 +671,14 @@ if old_data: " ``` -Before the merge step, save the old graph: `cp .graphify/graph.json .graphify_old.json` +Before the merge step, save the old graph: `cp graphify-out/graph.json .graphify_old.json` Clean up after: `rm -f .graphify_old.json` --- ## For --cluster-only -Skip Steps 1–3. Load the existing graph from `.graphify/graph.json` and re-run clustering: +Skip Steps 1–3. Load the existing graph from `graphify-out/graph.json` and re-run clustering: ```bash python3 -c " @@ -682,7 +691,7 @@ from networkx.readwrite import json_graph import networkx as nx from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') detection = {'total_files': 0, 'total_words': 99999, 'needs_graph': True, 'warning': None, @@ -696,8 +705,8 @@ surprises = surprising_connections(G, communities) labels = {cid: 'Community ' + str(cid) for cid in communities} report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, '.') -Path('.graphify/GRAPH_REPORT.md').write_text(report) -to_json(G, communities, '.graphify/graph.json') +Path('graphify-out/GRAPH_REPORT.md').write_text(report) +to_json(G, communities, 'graphify-out/graph.json') analysis = { 'communities': {str(k): v for k, v in communities.items()}, @@ -716,20 +725,20 @@ Then run Steps 5–8 as normal (label communities, generate viz, clean up, repor ## For /graphify query -Two traversal modes — choose based on the question: +Two traversal modes - choose based on the question: | Mode | Flag | Best for | |------|------|----------| -| BFS (default) | _(none)_ | "What is X connected to?" — broad context, nearest neighbors first | -| DFS | `--dfs` | "How does X reach Y?" — trace a specific chain or dependency path | +| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first | +| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path | -Load `.graphify/graph.json`, then: +Load `graphify-out/graph.json`, then: 1. Find the 1-3 nodes whose label best matches key terms in the question. 2. Run the appropriate traversal from each starting node. -3. Read the subgraph — node labels, edge relations, confidence tags, source locations. +3. Read the subgraph - node labels, edge relations, confidence tags, source locations. 4. Answer using **only** what the graph contains. Quote `source_location` when citing a specific fact. -5. If the graph lacks enough information, say so — do not hallucinate edges. +5. If the graph lacks enough information, say so - do not hallucinate edges. ```bash python3 -c " @@ -738,7 +747,7 @@ from networkx.readwrite import json_graph import networkx as nx from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') question = 'QUESTION' @@ -813,7 +822,7 @@ for u, v in subgraph_edges: output = '\n'.join(lines) if len(output) > char_budget: - output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget — use --budget N for more)' + output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)' print(output) " ``` @@ -829,11 +838,11 @@ from pathlib import Path save_query_result( question='QUESTION', answer='ANSWER', - memory_dir=Path('.graphify/memory'), + memory_dir=Path('graphify-out/memory'), query_type='query', source_nodes=SOURCE_NODES, # list of node labels cited, or [] ) -print('Query result saved to .graphify/memory/') +print('Query result saved to graphify-out/memory/') " ``` @@ -852,7 +861,7 @@ import networkx as nx from networkx.readwrite import json_graph from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') a_term = 'NODE_A' @@ -893,7 +902,7 @@ except nx.NodeNotFound as e: " ``` -Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language — what each hop means, why it's significant. +Replace `NODE_A` and `NODE_B` with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant. After writing the explanation, save it back: @@ -904,11 +913,11 @@ from pathlib import Path save_query_result( question='Path from NODE_A to NODE_B', answer='ANSWER', - memory_dir=Path('.graphify/memory'), + memory_dir=Path('graphify-out/memory'), query_type='path_query', source_nodes=PATH_NODES, # list of node labels on the path ) -print('Path result saved to .graphify/memory/') +print('Path result saved to graphify-out/memory/') " ``` @@ -916,7 +925,7 @@ print('Path result saved to .graphify/memory/') ## For /graphify explain -Give a plain-language explanation of a single node — everything connected to it. +Give a plain-language explanation of a single node - everything connected to it. ```bash python3 -c " @@ -925,7 +934,7 @@ import networkx as nx from networkx.readwrite import json_graph from pathlib import Path -data = json.loads(Path('.graphify/graph.json').read_text()) +data = json.loads(Path('graphify-out/graph.json').read_text()) G = json_graph.node_link_graph(data, edges='links') term = 'NODE_NAME' @@ -970,11 +979,11 @@ from pathlib import Path save_query_result( question='Explain NODE_NAME', answer='ANSWER', - memory_dir=Path('.graphify/memory'), + memory_dir=Path('graphify-out/memory'), query_type='explain', source_nodes=['NODE_NAME'], ) -print('Explanation saved to .graphify/memory/') +print('Explanation saved to graphify-out/memory/') " ``` @@ -1002,7 +1011,7 @@ except RuntimeError as e: " ``` -Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong — do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. +Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. Supported URL types (auto-detected): - Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author @@ -1023,7 +1032,7 @@ python3 -m graphify.watch INPUT_PATH --debounce 3 Replace INPUT_PATH with the folder to watch. Every time a supported file is added or modified, graphify waits `debounce` seconds (default 3) after the last change, then runs the `--update` pipeline automatically. Press Ctrl+C to stop. -For the personal inspo use case: leave this running in a terminal. Drop tweets, screenshots, papers, and notes into the folder throughout the day — the graph updates itself. +For the personal inspo use case: leave this running in a terminal. Drop tweets, screenshots, papers, and notes into the folder throughout the day - the graph updates itself. --- @@ -1032,5 +1041,5 @@ For the personal inspo use case: leave this running in a terminal. Drop tweets, - Never invent an edge. If unsure, use AMBIGUOUS. - Never skip the corpus check warning. - Always show token cost in the report. -- Never hide cohesion scores behind symbols — show the raw number. +- Never hide cohesion scores behind symbols - show the raw number. - Never run pyvis on a graph with more than 5,000 nodes without warning the user. diff --git a/tests/EVAL_httpx.md b/tests/EVAL_httpx.md index 802cf62ae..66f8f96e5 100644 --- a/tests/EVAL_httpx.md +++ b/tests/EVAL_httpx.md @@ -1,6 +1,6 @@ -# Graphify Evaluation — httpx Corpus (2026-04-03) +# Graphify Evaluation - httpx Corpus (2026-04-03) -**Evaluator:** Claude Sonnet 4.6 (analytical simulation — Bash execution unavailable) +**Evaluator:** Claude Sonnet 4.6 (analytical simulation - Bash execution unavailable) **Corpus:** 6-file synthetic httpx-like Python codebase (~2,800 words) **Pipeline:** graphify AST extractor + graph_builder + Leiden clusterer + analyzer + reporter **Method:** Full deterministic code tracing of every graphify source module against @@ -12,7 +12,7 @@ exact Leiden partition is non-deterministic but the structural analysis is sound ## Full GRAPH_REPORT.md Content ```markdown -# Graph Report — /home/safi/graphify_test/httpx (2026-04-03) +# Graph Report - /home/safi/graphify_test/httpx (2026-04-03) ## Corpus Check - 6 files · ~2,800 words @@ -23,17 +23,17 @@ exact Leiden partition is non-deterministic but the structural analysis is sound - Extraction: ~100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS - Token cost: 0 input · 0 output -## God Nodes (most connected — your core abstractions) -1. `client.py` — ~28 edges -2. `models.py` — ~22 edges -3. `transport.py` — ~20 edges -4. `exceptions.py` — ~18 edges -5. `BaseClient` — ~15 edges -6. `auth.py` — ~14 edges -7. `Response` — ~12 edges -8. `Client` — ~10 edges -9. `AsyncClient` — ~10 edges -10. `utils.py` — ~9 edges +## God Nodes (most connected - your core abstractions) +1. `client.py` - ~28 edges +2. `models.py` - ~22 edges +3. `transport.py` - ~20 edges +4. `exceptions.py` - ~18 edges +5. `BaseClient` - ~15 edges +6. `auth.py` - ~14 edges +7. `Response` - ~12 edges +8. `Client` - ~10 edges +9. `AsyncClient` - ~10 edges +10. `utils.py` - ~9 edges ## Surprising Connections - `BaseClient` ↔ `.auth_flow()` [EXTRACTED] @@ -49,19 +49,19 @@ exact Leiden partition is non-deterministic but the structural analysis is sound ## Communities -### Community 0 — "Core HTTP Client" +### Community 0 - "Core HTTP Client" Cohesion: 0.14 Nodes (12): client.py, BaseClient, Client, AsyncClient, .send(), .request(), .get(), .post(), .close(), .aclose(), Timeout, Limits -### Community 1 — "Request/Response Models" +### Community 1 - "Request/Response Models" Cohesion: 0.18 Nodes (10): models.py, Request, Response, URL, Headers, Cookies, .read(), .json(), .raise_for_status(), .cookies -### Community 2 — "Exception Hierarchy" +### Community 2 - "Exception Hierarchy" Cohesion: 0.10 Nodes (20): exceptions.py, HTTPStatusError, RequestError, TransportError, TimeoutException, ... -### Community 3 — "Transport & Auth" +### Community 3 - "Transport & Auth" Cohesion: 0.08 Nodes (18): transport.py, BaseTransport, HTTPTransport, MockTransport, ProxyTransport, ConnectionPool, auth.py, Auth, BasicAuth, DigestAuth, BearerAuth, NetRCAuth, ... ``` @@ -70,7 +70,7 @@ Nodes (18): transport.py, BaseTransport, HTTPTransport, MockTransport, ProxyTran ## Evaluation Scores -### 1. Node/Edge Quality — Score: 6/10 +### 1. Node/Edge Quality - Score: 6/10 **What's captured well:** - File-level nodes for all 6 files (exceptions, models, auth, utils, client, transport) ✓ @@ -78,42 +78,42 @@ Nodes (18): transport.py, BaseTransport, HTTPTransport, MockTransport, ProxyTran subclasses; URL, Headers, Cookies, Request, Response; Auth, BasicAuth, DigestAuth, BearerAuth, NetRCAuth; BaseClient, Client, AsyncClient; Timeout, Limits; BaseTransport, AsyncBaseTransport, HTTPTransport, AsyncHTTPTransport, MockTransport, ProxyTransport, - ConnectionPool — all captured ✓ + ConnectionPool - all captured ✓ - Module-level functions from utils.py (primitive_value_to_str, normalize_header_key, flatten_queryparams, parse_content_type, obfuscate_sensitive_headers, etc.) ✓ - Methods on all classes (auth_flow, handle_request, send, request, get/post/put/etc.) ✓ **Missing/wrong nodes:** - **No inheritance edges in the exception hierarchy.** The extractor builds inheritance edges - as `_make_id(stem, base_name)` — e.g. `RequestError` inheriting `Exception` produces target + as `_make_id(stem, base_name)` - e.g. `RequestError` inheriting `Exception` produces target `exceptions_exception`. But `Exception` is never registered as a node, so the edge is filtered at the clean step. All 14 inheritance edges in exceptions.py are silently dropped. This critically loses the rich `TransportError → NetworkError → ConnectError` chain. - **No inheritance across files.** `BaseClient` inherits nothing in the graph. `Client(BaseClient)` produces `_make_id("client", "BaseClient")` = `"client_baseclient"`, but `BaseClient`'s node - ID is `_make_id("client", "BaseClient")` = `"client_baseclient"` — this actually SHOULD work + ID is `_make_id("client", "BaseClient")` = `"client_baseclient"` - this actually SHOULD work because both the class definition and the inheritance reference use the same stem ("client"). **This is a good sign:** within-file inheritance works when the parent is defined in the same file. -- **Cross-file inheritance is not captured.** `HTTPTransport(BaseTransport)` — `BaseTransport` +- **Cross-file inheritance is not captured.** `HTTPTransport(BaseTransport)` - `BaseTransport` is defined in `transport.py`, so `_make_id("transport", "BaseTransport")` = `"transport_basetransport"`. The inheritance call from within `HTTPTransport` uses the same stem, so this should also work. - **Property methods lose their property decorator context.** `url`, `content`, `cookies`, - `is_success`, `is_error`, etc. are extracted as ordinary methods — no semantic distinction. -- **`build_auth_header` utility function in auth.py** — captured as a module-level function ✓ + `is_success`, `is_error`, etc. are extracted as ordinary methods - no semantic distinction. +- **`build_auth_header` utility function in auth.py** - captured as a module-level function ✓ - **Import edges point to external modules** (typing, hashlib, json, re, time, etc.) that are never registered as nodes. Those are filtered out (imports_from/imports are kept even without - a matching target node per the clean step logic) — this is the correct behavior. + a matching target node per the clean step logic) - this is the correct behavior. **Summary:** ~85% of meaningful code entities are captured. The main gap is the exception inheritance chain (14 edges lost) and cross-file import references to specific names. --- -### 2. Edge Accuracy — Score: 5/10 +### 2. Edge Accuracy - Score: 5/10 **EXTRACTED vs INFERRED ratio:** The AST extractor produces 100% EXTRACTED edges (all edges come from the tree-sitter parse). There are 0 INFERRED edges. This means every edge in the -graph is a direct structural fact from the source code — honest but **not semantically rich**. +graph is a direct structural fact from the source code - honest but **not semantically rich**. **What's right:** - `contains` edges from file nodes to their class/function children ✓ @@ -124,19 +124,19 @@ graph is a direct structural fact from the source code — honest but **not sema **What's wrong or missing:** - **0% INFERRED edges.** The AST extractor only does structural extraction. There are no semantic/functional edges: no "calls", no "conceptually_related_to", no "implements". - For example, `DigestAuth.auth_flow` calls `Response.status_code` — this relationship is + For example, `DigestAuth.auth_flow` calls `Response.status_code` - this relationship is invisible. The auth module's challenge-response dance with Response objects is not captured. - **Inheritance chain edges dropped (14 edges).** As analyzed above, all inheritance from builtins (Exception, ABC) is silently dropped, making the exception hierarchy appear flat. - **Import edges are present but low-signal.** `client.py imports_from models` is correct but - doesn't say WHICH classes — so the graph can't distinguish that `Client` specifically uses + doesn't say WHICH classes - so the graph can't distinguish that `Client` specifically uses `Request` and `Response`, not just the whole models module. -- **No "calls" relationships.** `Response.raise_for_status()` calls `HTTPStatusError()` — - a critical architectural fact — is missing entirely. +- **No "calls" relationships.** `Response.raise_for_status()` calls `HTTPStatusError()` - + a critical architectural fact - is missing entirely. - **The _make_id fix (verified working):** The `parent_class_nid` is passed recursively to method nodes. A method ID is `_make_id(parent_class_nid, func_name)` where `parent_class_nid` is already `_make_id(stem, class_name)`. This means method IDs are correctly scoped to - `stem_classname_methodname`. Edge cleanup checks `src in valid_ids` — since method nodes ARE + `stem_classname_methodname`. Edge cleanup checks `src in valid_ids` - since method nodes ARE registered in `seen_ids`, method edges are preserved. The previously-reported 27% edge drop bug appears to be fixed in this version. @@ -144,32 +144,32 @@ graph is a direct structural fact from the source code — honest but **not sema - Correct, present: ~115 edges (88%) - Silently dropped (inheritance from builtins): ~14 edges (11%) - False positives: ~2 edges (import edges to nonexistent modules like "socket" kept via - imports exception in clean step — technically correct behavior) + imports exception in clean step - technically correct behavior) - Missing (calls, conceptual): would require LLM or runtime analysis --- -### 3. Community Quality — Score: 6/10 +### 3. Community Quality - Score: 6/10 **Communities make semantic sense?** Largely yes, with one significant problem. -**Community 0 — "Core HTTP Client"** (Client, AsyncClient, BaseClient + methods, Timeout, Limits) +**Community 0 - "Core HTTP Client"** (Client, AsyncClient, BaseClient + methods, Timeout, Limits) - This is semantically tight: all the public API surface of httpx belongs here. -- Cohesion ~0.14: low but expected — client.py's class bodies generate many method nodes +- Cohesion ~0.14: low but expected - client.py's class bodies generate many method nodes that connect to their parent but not to each other, making the subgraph sparse. -**Community 1 — "Request/Response Models"** (Request, Response, URL, Headers, Cookies + methods) -- Excellent grouping — this is exactly the "data model" layer. Cohesion ~0.18 is the highest +**Community 1 - "Request/Response Models"** (Request, Response, URL, Headers, Cookies + methods) +- Excellent grouping - this is exactly the "data model" layer. Cohesion ~0.18 is the highest because methods connect within their parent classes. -**Community 2 — "Exception Hierarchy"** (all 15 exception classes) +**Community 2 - "Exception Hierarchy"** (all 15 exception classes) - Good that exceptions are grouped together. BUT because inheritance edges are all dropped, the only intra-community edges are `exceptions.py contains ExceptionClass`. This means - cohesion is near-zero (0.10 estimated) — the community is held together only by the file + cohesion is near-zero (0.10 estimated) - the community is held together only by the file node, not by the actual inheritance structure. Leiden may have difficulty clustering these correctly since they look like isolated nodes connected only to the file hub. -**Community 3 — "Transport & Auth"** (all transport + auth classes) +**Community 3 - "Transport & Auth"** (all transport + auth classes) - This is the most problematic grouping. Transport (HTTPTransport, ConnectionPool, etc.) and Auth (BasicAuth, DigestAuth, etc.) are bundled together simply because both modules import from models.py and exceptions.py. They are architecturally distinct layers. A developer @@ -182,7 +182,7 @@ real codebase with many cross-cutting concerns. The scores are not artificially --- -### 4. Surprising Connections — Score: 4/10 +### 4. Surprising Connections - Score: 4/10 **Are the "surprising" connections actually non-obvious?** @@ -190,13 +190,13 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev 1. `BaseClient ↔ .auth_flow()` (client.py ↔ auth.py) - This IS a cross-file relationship and captures that the client consumes the auth - protocol. Moderately interesting — but "client uses auth" is not surprising. + protocol. Moderately interesting - but "client uses auth" is not surprising. - Score: Somewhat interesting, but obvious to anyone who reads client.py line 1. 2. `ProxyTransport ↔ TransportError` (transport.py ↔ exceptions.py) - This is within the same file (transport.py imports exceptions at the bottom: `from .exceptions import TransportError`). This is a re-export, not a surprise. - - Score: False positive — this is a completely obvious import. + - Score: False positive - this is a completely obvious import. 3. `ConnectionPool ↔ Request` (transport.py ↔ models.py) - transport.py imports from models. That `ConnectionPool` specifically uses `Request` @@ -206,14 +206,14 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev 4. `DigestAuth ↔ Response` (auth.py ↔ models.py) - This IS genuinely interesting! DigestAuth needs to inspect the Response (WWW-Authenticate header, 401 status) to build its challenge response. The auth layer having a bidirectional - dependency on Response is a real architectural insight — auth is not a pure pre-request + dependency on Response is a real architectural insight - auth is not a pure pre-request decorator but a request-response cycle participant. - Score: Genuinely non-obvious and architecturally significant. 5. `utils.py ↔ Cookies` (utils.py ↔ models.py) - `unset_all_cookies` in utils.py imports `Cookies` from models. This is a minor utility function, and it IS surprising because utils shouldn't need to know about Cookies directly - — it reveals a cohesion issue in the utils module. + - it reveals a cohesion issue in the utils module. - Score: Mildly interesting. **Problems:** @@ -227,18 +227,18 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev --- -### 5. God Nodes — Score: 7/10 +### 5. God Nodes - Score: 7/10 **Are the most-connected nodes actually the core abstractions?** **Very good:** -- `client.py` as #1 god node makes sense — it imports from 5 other modules and contains the +- `client.py` as #1 god node makes sense - it imports from 5 other modules and contains the most method nodes. It is the integration hub of the library. -- `models.py` as #2 is correct — Request, Response, URL, Headers, Cookies are the central +- `models.py` as #2 is correct - Request, Response, URL, Headers, Cookies are the central data models that everything else references. - `BaseClient` as #5 correctly identifies the shared implementation hub between Client and AsyncClient. -- `Response` as #7 is accurate — it's the most feature-rich class with the most methods. +- `Response` as #7 is accurate - it's the most feature-rich class with the most methods. **Problematic:** - File-level nodes (client.py, models.py, transport.py, exceptions.py, auth.py, utils.py) @@ -254,13 +254,13 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev --- -### 6. Overall Usefulness — Score: 6/10 +### 6. Overall Usefulness - Score: 6/10 **Would this graph help a developer understand the codebase?** **Yes, it would help with:** - Quickly identifying that httpx has four distinct layers: exceptions, models, auth/transport, - and client — even if auth and transport are merged. + and client - even if auth and transport are merged. - Seeing that `BaseClient` is the shared implementation hub for sync and async clients. - Identifying `Response` and `Request` as the central data types. - Finding cross-module coupling (e.g., auth's dependency on Response). @@ -270,7 +270,7 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev - Understanding the exception hierarchy (all 14 inheritance edges are dropped). - Understanding call flow (which methods call which). - Understanding that DigestAuth participates in a request/response cycle, not just - pre-request decoration — this architectural insight is present but buried in boring + pre-request decoration - this architectural insight is present but buried in boring EXTRACTED connection #4. - Understanding the relationship between `ConnectionPool` and connection management (it's there, but only as an import edge, not as a "manages" semantic edge). @@ -332,11 +332,11 @@ Even simple name-based heuristics would add INFERRED edges for common patterns. surprising connections. But many cross-file edges are mundane imports. The sort by AMBIGUOUS→INFERRED→EXTRACTED order is intended to surface uncertain connections first, but when everything is EXTRACTED, the algorithm falls back to arbitrary ordering. -**Fix:** Add a "distance" metric — prefer pairs where the source files have no direct +**Fix:** Add a "distance" metric - prefer pairs where the source files have no direct import relationship. A `transport.py → exceptions.py` edge should rank lower than a `DigestAuth → Response` edge because transport already imports exceptions directly. -### Issue 6: _make_id edge fix — CONFIRMED WORKING +### Issue 6: _make_id edge fix - CONFIRMED WORKING **Location:** `ast_extractor.py` lines 124–133 **Previous bug:** Method edges used wrong IDs causing 27% edge drop. **Current code:** Method node ID is `_make_id(parent_class_nid, func_name)` and the @@ -345,7 +345,7 @@ same `parent_class_nid`. Both `parent_class_nid` and `func_nid` are in `seen_ids **Status:** The _make_id fix is correctly implemented. Method edges are preserved. No 27% drop for method edges. ✓ -### Issue 7: Concept node filtering — CONFIRMED WORKING +### Issue 7: Concept node filtering - CONFIRMED WORKING **Location:** `analyzer.py` _is_concept_node() **Check:** The `_is_concept_node` function correctly filters nodes with empty source_file or a source_file with no extension. The AST extractor always sets source_file to the @@ -382,13 +382,13 @@ otherwise be dropped. The fix is confirmed working. The graphify AST extractor is deterministic, fast, and accurate for what it extracts. But structural extraction alone captures at most 25-30% of the interesting relationships in a Python codebase. The skill.md design correctly envisions the Claude LLM doing a -richer extraction pass (Step 3) for document/paper corpora — but for code, the pipeline +richer extraction pass (Step 3) for document/paper corpora - but for code, the pipeline currently relies entirely on tree-sitter, producing a structurally correct but semantically thin graph. ### Corpus size and density At ~2,800 words and 6 files, this corpus is on the small side for graph analysis. -The skill.md correctly warns "Corpus fits in a single context window — you may not need +The skill.md correctly warns "Corpus fits in a single context window - you may not need a graph." A real httpx codebase has 30+ files. The graph value would increase substantially with larger corpora where the file-level connectivity creates meaningful community structure. diff --git a/tests/EVAL_mixed_corpus.md b/tests/EVAL_mixed_corpus.md index 7e822d997..13370b9ab 100644 --- a/tests/EVAL_mixed_corpus.md +++ b/tests/EVAL_mixed_corpus.md @@ -1,4 +1,4 @@ -# Graphify Evaluation — Mixed Corpus (2026-04-04) +# Graphify Evaluation - Mixed Corpus (2026-04-04) **Evaluator:** Claude Sonnet 4.6 (live execution) **Corpus:** 3 Python files + 1 markdown paper + 1 Arabic PNG image @@ -13,7 +13,7 @@ code: [analyze.py, build.py, cluster.py] 3 files paper: [attention_notes.md] 1 file (arxiv signals detected) image: [attention_arabic.png] 1 file total: 5 files · ~4,020 words -warning: fits in a single context window (correct — corpus is small) +warning: fits in a single context window (correct - corpus is small) ``` **Finding:** `attention_notes.md` correctly classified as `paper` (not document) because it @@ -42,12 +42,12 @@ Total: 18 nodes, 19 edges → graph: 20 nodes, 19 edges (2 external deps | 1 | Clustering & Scoring | 0.29 | cluster.py, `cluster()`, `score_all()`, `cohesion_score()`, `build_graph()`, `_split_community()`, graspologic | | 2 | Graph Building | 0.50 | build.py, `build()`, `build_from_json()`, networkx | -**Finding:** Communities are semantically correct — the three graphify modules map cleanly +**Finding:** Communities are semantically correct - the three graphify modules map cleanly to their functional roles. `build.py` has the highest cohesion (0.50) because it's a tight, self-contained module. `analyze.py` is lowest (0.22) because its functions don't call each -other — each is a standalone analysis pass, making the subgraph sparse. +other - each is a standalone analysis pass, making the subgraph sparse. -**Finding:** Zero surprising connections — the three modules are structurally independent +**Finding:** Zero surprising connections - the three modules are structurally independent (no cross-file imports between them). Expected for a cleanly layered codebase. --- @@ -55,10 +55,10 @@ other — each is a standalone analysis pass, making the subgraph sparse. ## 4. Query Tests (live BFS traversal) All three queries ran against the real graph.json, returned relevant subgraphs, and were -saved to `.graphify/memory/`. +saved to `graphify-out/memory/`. ### Q1: "what does cluster do and how does it connect to build?" -- BFS from `cluster()` reached 20 nodes (full graph — small corpus) +- BFS from `cluster()` reached 20 nodes (full graph - small corpus) - `cluster.py` and `build.py` are linked via the `graspologic_partition` external dep node - Saved: `query_..._what_does_cluster_do_and_how_does_it_connect_to_bu.md` @@ -83,19 +83,19 @@ Memory files created: 3 query_..._how_does_score_all...md 1,763 bytes query_..._what_does_cluster...md 1,838 bytes -detect() on eval root with .graphify/memory/ present: +detect() on eval root with graphify-out/memory/ present: Memory files found by next scan: 3 / 3 ✓ ``` **Result: PASS.** All 3 query results appear in the next `detect()` scan. On the next -`--update`, these files will be extracted as nodes in the graph — closing the feedback loop. +`--update`, these files will be extracted as nodes in the graph - closing the feedback loop. The graph grows from what you ask, not just what you add. --- ## 6. Arabic Image OCR (via Claude vision) -**Image:** `attention_arabic.png` — Arabic notes on the Transformer paper +**Image:** `attention_arabic.png` - Arabic notes on the Transformer paper **What graphify extracts (Claude vision reads directly, no reshaper/bidi needed):** @@ -108,17 +108,17 @@ The graph grows from what you ask, not just what you add. | المحول: مكدس من 6 طبقات ترميز و6 طبقات فك ترميز | Transformer: 6 encoder + 6 decoder layers | | الترميز الموضعي | Positional encoding | | التطبيع الطبقي | Layer normalization | -| المصدر: Vaswani et al., 2017 — arXiv: 1706.03762 | Source citation | +| المصدر: Vaswani et al., 2017 - arXiv: 1706.03762 | Source citation | **Nodes graphify would extract:** -- `MultiHeadAttention` (آلية الانتباه) — hyperparameters: h=8, d_model=512, d_k=64 -- `PositionalEncoding` (الترميز الموضعي) — feeds into transformer input -- `LayerNorm` (التطبيع الطبقي) — applied per sublayer -- `Transformer` — 6 encoder + 6 decoder stack +- `MultiHeadAttention` (آلية الانتباه) - hyperparameters: h=8, d_model=512, d_k=64 +- `PositionalEncoding` (الترميز الموضعي) - feeds into transformer input +- `LayerNorm` (التطبيع الطبقي) - applied per sublayer +- `Transformer` - 6 encoder + 6 decoder stack **Key finding:** Arabic text OCR works natively via Claude vision. No preprocessing, no reshaper libraries, no bidi algorithms. The model reads Arabic, Persian, Hebrew, Chinese etc. -identically to English. The image node in graphify is just a path — the vision subagent does +identically to English. The image node in graphify is just a path - the vision subagent does the rest. --- @@ -129,7 +129,7 @@ the rest. `suggest_questions()` requires a `community_labels` dict. When called with auto-generated labels on a small corpus with no AMBIGUOUS edges and no isolated nodes, it returns an empty list. The function requires more signal (AMBIGUOUS edges, bridge nodes, underexplored god nodes) -to generate questions — correct behavior, but the skill should handle the empty case gracefully. +to generate questions - correct behavior, but the skill should handle the empty case gracefully. ### Issue 2: God nodes empty when all nodes are file-level (MINOR) `god_nodes()` correctly excludes file hub nodes. But on a 3-file corpus where the only @@ -138,7 +138,7 @@ degree-ranked nodes manually. Fix: emit a notice ("corpus too small for meaningf rather than silent empty list. ### Issue 3: 0 surprising connections on cleanly-layered code (NOT a bug) -The three modules don't import from each other — they're connected only through external deps +The three modules don't import from each other - they're connected only through external deps (networkx, graspologic). No cross-community edges means no surprises to surface. This is correct. Surprising connections require a less-cleanly-separated codebase. @@ -155,7 +155,7 @@ correct. Surprising connections require a less-cleanly-separated codebase. | Feedback loop | 10/10 | query results appear in next detect() scan, 3/3 | | Arabic OCR | 10/10 | Claude vision reads RTL Arabic natively, no libraries needed | -**Overall: 9.0/10** — strong pass on all dimensions with a small corpus. +**Overall: 9.0/10** - strong pass on all dimensions with a small corpus. Primary gaps are edge-level semantics (no INFERRED edges from AST-only) and god_nodes/ suggest_questions behavior on tiny corpora. @@ -169,8 +169,8 @@ The core pipeline is solid. The three most important findings: the next `detect()` scan and will be extracted into the graph on `--update`. 2. **Arabic OCR requires zero special handling.** PIL creates the image, Claude reads it. - The same applies to any language — no language-specific preprocessing needed. + The same applies to any language - no language-specific preprocessing needed. 3. **The corpus-size warning is working correctly.** At 4,020 words the warning fires: - "fits in a single context window — you may not need a graph." This is honest. + "fits in a single context window - you may not need a graph." This is honest. The graph adds value at scale, not on 5-file repos. diff --git a/tests/GRAPH_REPORT_httpx.md b/tests/GRAPH_REPORT_httpx.md index 4624ba42b..9036b99fa 100644 --- a/tests/GRAPH_REPORT_httpx.md +++ b/tests/GRAPH_REPORT_httpx.md @@ -1,4 +1,4 @@ -# Graph Report — /home/safi/graphify_test/httpx (2026-04-03) +# Graph Report - /home/safi/graphify_test/httpx (2026-04-03) ## Corpus Check - 6 files · ~2,800 words @@ -17,18 +17,18 @@ - Extraction: ~100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS - Token cost: 0 input · 0 output -## God Nodes (most connected — your core abstractions) +## God Nodes (most connected - your core abstractions) -1. `client.py` — ~28 edges -2. `models.py` — ~22 edges -3. `transport.py` — ~20 edges -4. `exceptions.py` — ~18 edges -5. `BaseClient` — ~15 edges -6. `auth.py` — ~14 edges -7. `Response` — ~12 edges -8. `Client` — ~10 edges -9. `AsyncClient` — ~10 edges -10. `utils.py` — ~9 edges +1. `client.py` - ~28 edges +2. `models.py` - ~22 edges +3. `transport.py` - ~20 edges +4. `exceptions.py` - ~18 edges +5. `BaseClient` - ~15 edges +6. `auth.py` - ~14 edges +7. `Response` - ~12 edges +8. `Client` - ~10 edges +9. `AsyncClient` - ~10 edges +10. `utils.py` - ~9 edges ## Surprising Connections (you probably didn't know these) @@ -45,18 +45,18 @@ ## Communities -### Community 0 — "Core HTTP Client" +### Community 0 - "Core HTTP Client" Cohesion: 0.14 Nodes (12): client.py, BaseClient, Client, AsyncClient, .send(), .request(), .get(), .post(), .close(), .aclose(), Timeout, Limits -### Community 1 — "Request/Response Models" +### Community 1 - "Request/Response Models" Cohesion: 0.18 Nodes (10): models.py, Request, Response, URL, Headers, Cookies, .read(), .json(), .raise_for_status(), .cookies -### Community 2 — "Exception Hierarchy" +### Community 2 - "Exception Hierarchy" Cohesion: 0.10 Nodes (20): exceptions.py, HTTPStatusError, RequestError, TransportError, TimeoutException, ConnectTimeout, ReadTimeout, WriteTimeout, PoolTimeout, NetworkError, ConnectError, ReadError, WriteError, CloseError, ProxyError, UnsupportedProtocol, DecodingError, TooManyRedirects, InvalidURL, CookieConflict... -### Community 3 — "Transport & Auth" +### Community 3 - "Transport & Auth" Cohesion: 0.08 Nodes (18): transport.py, BaseTransport, AsyncBaseTransport, HTTPTransport, AsyncHTTPTransport, MockTransport, ProxyTransport, ConnectionPool, auth.py, Auth, BasicAuth, DigestAuth, BearerAuth, NetRCAuth, .handle_request(), .auth_flow(), utils.py, .obfuscate_sensitive_headers()... diff --git a/tests/eval_attention.py b/tests/eval_attention.py index 04831effb..5d55607ae 100644 --- a/tests/eval_attention.py +++ b/tests/eval_attention.py @@ -1,5 +1,5 @@ """ -Graphify evaluation script — Transformer/Attention paper corpus. +Graphify evaluation script - Transformer/Attention paper corpus. Runs the full pipeline with a simulated Claude extraction JSON. """ from __future__ import annotations @@ -40,7 +40,7 @@ simulated_extraction = { {"id": "feed_forward", "label": "FeedForward", "file_type": "paper", "source_file": SOURCE_MD, "source_location": "Sec 3.3"}, {"id": "layer_norm", "label": "LayerNorm", "file_type": "paper", "source_file": SOURCE_MD, "source_location": "Sec 3.1"}, {"id": "positional_encoding", "label": "PositionalEncoding", "file_type": "paper", "source_file": SOURCE_MD, "source_location": "Sec 3.5"}, - # Hyperparameters — from config.md + # Hyperparameters - from config.md {"id": "d_model", "label": "d_model", "file_type": "document", "source_file": SOURCE_CFG, "source_location": "L3"}, {"id": "num_heads", "label": "num_heads", "file_type": "document", "source_file": SOURCE_CFG, "source_location": "L4"}, {"id": "dropout", "label": "dropout", "file_type": "document", "source_file": SOURCE_CFG, "source_location": "L7"}, @@ -59,7 +59,7 @@ simulated_extraction = { {"source": "decoder_layer", "target": "layer_norm", "relation": "applies", "confidence": "EXTRACTED", "source_file": SOURCE_MD, "weight": 1.0}, # MultiHeadAttention implements ScaledDotProduct internally {"source": "multi_head_attention", "target": "scaled_dot_product", "relation": "implements", "confidence": "EXTRACTED", "source_file": SOURCE_MD, "weight": 1.0}, - # Hyperparameter relationships — from config.md to architecture nodes + # Hyperparameter relationships - from config.md to architecture nodes {"source": "multi_head_attention", "target": "d_model", "relation": "parameterized_by", "confidence": "EXTRACTED", "source_file": SOURCE_CFG, "weight": 1.0}, {"source": "multi_head_attention", "target": "num_heads", "relation": "parameterized_by", "confidence": "EXTRACTED", "source_file": SOURCE_CFG, "weight": 1.0}, {"source": "scaled_dot_product", "target": "d_model", "relation": "scales_by", "confidence": "INFERRED", "source_file": SOURCE_MD, "weight": 0.8}, @@ -67,7 +67,7 @@ simulated_extraction = { # Positional encoding connects to transformer input (cross-community link) {"source": "positional_encoding", "target": "transformer", "relation": "feeds_into", "confidence": "EXTRACTED", "source_file": SOURCE_MD, "weight": 1.0}, {"source": "positional_encoding", "target": "d_model", "relation": "dimensioned_by", "confidence": "INFERRED", "source_file": SOURCE_MD, "weight": 0.8}, - # Dropout applied across sub-layers — ambiguous which specific sublayer + # Dropout applied across sub-layers - ambiguous which specific sublayer {"source": "dropout", "target": "multi_head_attention", "relation": "regularizes", "confidence": "AMBIGUOUS", "source_file": SOURCE_CFG, "weight": 0.6}, {"source": "dropout", "target": "feed_forward", "relation": "regularizes", "confidence": "AMBIGUOUS", "source_file": SOURCE_CFG, "weight": 0.6}, # Cross-community bridge: LayerNorm and PositionalEncoding both affect d_model scale diff --git a/tests/fixtures/graphify-out/cache/4722d67ec49f51710650249b1f865b6a748d91fb6805f3d385a99143eb950fe7.json b/tests/fixtures/graphify-out/cache/4722d67ec49f51710650249b1f865b6a748d91fb6805f3d385a99143eb950fe7.json new file mode 100644 index 000000000..1ab032a7c --- /dev/null +++ b/tests/fixtures/graphify-out/cache/4722d67ec49f51710650249b1f865b6a748d91fb6805f3d385a99143eb950fe7.json @@ -0,0 +1 @@ +{"nodes": [{"id": "sample", "label": "sample.ts", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L1"}, {"id": "sample_httpclient", "label": "HttpClient", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L3"}, {"id": "sample_httpclient_constructor", "label": ".constructor()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L6"}, {"id": "sample_httpclient_get", "label": ".get()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L10"}, {"id": "sample_httpclient_post", "label": ".post()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L14"}, {"id": "sample_buildheaders", "label": "buildHeaders()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L19"}], "edges": [{"source": "sample", "target": "models", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L1", "weight": 1.0}, {"source": "sample", "target": "sample_httpclient", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L3", "weight": 1.0}, {"source": "sample_httpclient", "target": "sample_httpclient_constructor", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L6", "weight": 1.0}, {"source": "sample_httpclient", "target": "sample_httpclient_get", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L10", "weight": 1.0}, {"source": "sample_httpclient", "target": "sample_httpclient_post", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L14", "weight": 1.0}, {"source": "sample", "target": "sample_buildheaders", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L19", "weight": 1.0}, {"source": "sample_httpclient_post", "target": "sample_httpclient_get", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample.ts", "source_location": "L15", "weight": 0.8}]} \ No newline at end of file diff --git a/tests/fixtures/graphify-out/cache/6a640d202b5f9a6d68f7b5eb2c05e708d85ba9ee43ad0ff271badfc966a1c06c.json b/tests/fixtures/graphify-out/cache/6a640d202b5f9a6d68f7b5eb2c05e708d85ba9ee43ad0ff271badfc966a1c06c.json new file mode 100644 index 000000000..2a915474d --- /dev/null +++ b/tests/fixtures/graphify-out/cache/6a640d202b5f9a6d68f7b5eb2c05e708d85ba9ee43ad0ff271badfc966a1c06c.json @@ -0,0 +1 @@ +{"nodes": [{"id": "sample", "label": "sample.go", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L1"}, {"id": "sample_server", "label": "Server", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L8"}, {"id": "sample_newserver", "label": "NewServer()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L12"}, {"id": "sample_server_start", "label": ".Start()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L16"}, {"id": "sample_server_stop", "label": ".Stop()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L20"}, {"id": "sample_main", "label": "main()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L24"}], "edges": [{"source": "sample", "target": "fmt", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L4", "weight": 1.0}, {"source": "sample", "target": "http", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L5", "weight": 1.0}, {"source": "sample", "target": "sample_server", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L8", "weight": 1.0}, {"source": "sample", "target": "sample_newserver", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L12", "weight": 1.0}, {"source": "sample_server", "target": "sample_server_start", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L16", "weight": 1.0}, {"source": "sample_server", "target": "sample_server_stop", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L20", "weight": 1.0}, {"source": "sample", "target": "sample_main", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L24", "weight": 1.0}, {"source": "sample_main", "target": "sample_newserver", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L25", "weight": 0.8}, {"source": "sample_main", "target": "sample_server_start", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample.go", "source_location": "L26", "weight": 0.8}]} \ No newline at end of file diff --git a/tests/fixtures/graphify-out/cache/a3c5220ed581781e1dc2f4e9a82eeee366881554ec9fce57823e124f7aecd348.json b/tests/fixtures/graphify-out/cache/a3c5220ed581781e1dc2f4e9a82eeee366881554ec9fce57823e124f7aecd348.json new file mode 100644 index 000000000..bf15ccd9d --- /dev/null +++ b/tests/fixtures/graphify-out/cache/a3c5220ed581781e1dc2f4e9a82eeee366881554ec9fce57823e124f7aecd348.json @@ -0,0 +1 @@ +{"nodes": [{"id": "sample_calls", "label": "sample_calls.py", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L1"}, {"id": "sample_calls_compute_score", "label": "compute_score()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L4"}, {"id": "sample_calls_normalize", "label": "normalize()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L8"}, {"id": "sample_calls_run_analysis", "label": "run_analysis()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L12"}, {"id": "sample_calls_analyzer", "label": "Analyzer", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L17"}, {"id": "sample_calls_analyzer_process", "label": ".process()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L18"}, {"id": "sample_calls_analyzer_score", "label": ".score()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L21"}, {"id": "sample_calls_analyzer_full_pipeline", "label": ".full_pipeline()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L24"}], "edges": [{"source": "sample_calls", "target": "sample_calls_compute_score", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L4", "weight": 1.0}, {"source": "sample_calls", "target": "sample_calls_normalize", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L8", "weight": 1.0}, {"source": "sample_calls", "target": "sample_calls_run_analysis", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L12", "weight": 1.0}, {"source": "sample_calls", "target": "sample_calls_analyzer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L17", "weight": 1.0}, {"source": "sample_calls_analyzer", "target": "sample_calls_analyzer_process", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L18", "weight": 1.0}, {"source": "sample_calls_analyzer", "target": "sample_calls_analyzer_score", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L21", "weight": 1.0}, {"source": "sample_calls_analyzer", "target": "sample_calls_analyzer_full_pipeline", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L24", "weight": 1.0}, {"source": "sample_calls_run_analysis", "target": "sample_calls_compute_score", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L13", "weight": 0.8}, {"source": "sample_calls_run_analysis", "target": "sample_calls_normalize", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L14", "weight": 0.8}, {"source": "sample_calls_analyzer_process", "target": "sample_calls_run_analysis", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L19", "weight": 0.8}, {"source": "sample_calls_analyzer_score", "target": "sample_calls_compute_score", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L22", "weight": 0.8}, {"source": "sample_calls_analyzer_full_pipeline", "target": "sample_calls_analyzer_score", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L25", "weight": 0.8}, {"source": "sample_calls_analyzer_full_pipeline", "target": "sample_calls_normalize", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample_calls.py", "source_location": "L26", "weight": 0.8}]} \ No newline at end of file diff --git a/tests/fixtures/graphify-out/cache/f5916299213779311e7162e90a1613bca095b5372f5d269c5941b5237af2d020.json b/tests/fixtures/graphify-out/cache/f5916299213779311e7162e90a1613bca095b5372f5d269c5941b5237af2d020.json new file mode 100644 index 000000000..e5e0bbb35 --- /dev/null +++ b/tests/fixtures/graphify-out/cache/f5916299213779311e7162e90a1613bca095b5372f5d269c5941b5237af2d020.json @@ -0,0 +1 @@ +{"nodes": [{"id": "sample", "label": "sample.rs", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L1"}, {"id": "sample_graph", "label": "Graph", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L3"}, {"id": "sample_graph_new", "label": ".new()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L8"}, {"id": "sample_graph_add_node", "label": ".add_node()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L12"}, {"id": "sample_graph_add_edge", "label": ".add_edge()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L16"}, {"id": "sample_build_graph", "label": "build_graph()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L21"}], "edges": [{"source": "sample", "target": "hashmap", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L1", "weight": 1.0}, {"source": "sample", "target": "sample_graph", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L3", "weight": 1.0}, {"source": "sample_graph", "target": "sample_graph_new", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L8", "weight": 1.0}, {"source": "sample_graph", "target": "sample_graph_add_node", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L12", "weight": 1.0}, {"source": "sample_graph", "target": "sample_graph_add_edge", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L16", "weight": 1.0}, {"source": "sample", "target": "sample_build_graph", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L21", "weight": 1.0}, {"source": "sample_build_graph", "target": "sample_graph_new", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L22", "weight": 0.8}, {"source": "sample_build_graph", "target": "sample_graph_add_edge", "relation": "calls", "confidence": "INFERRED", "source_file": "/home/safi/graphify/tests/fixtures/sample.rs", "source_location": "L24", "weight": 0.8}]} \ No newline at end of file diff --git a/tests/fixtures/graphify-out/cache/f82cddb8aad2615e0381e57b80857edfd3345213967c815de87e09be80f9f12a.json b/tests/fixtures/graphify-out/cache/f82cddb8aad2615e0381e57b80857edfd3345213967c815de87e09be80f9f12a.json new file mode 100644 index 000000000..3068ada3e --- /dev/null +++ b/tests/fixtures/graphify-out/cache/f82cddb8aad2615e0381e57b80857edfd3345213967c815de87e09be80f9f12a.json @@ -0,0 +1 @@ +{"nodes": [{"id": "sample", "label": "sample.py", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L1"}, {"id": "sample_transformer", "label": "Transformer", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L1"}, {"id": "sample_transformer_init", "label": ".__init__()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L2"}, {"id": "sample_transformer_forward", "label": ".forward()", "file_type": "code", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L5"}], "edges": [{"source": "sample", "target": "sample_transformer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L1", "weight": 1.0}, {"source": "sample_transformer", "target": "sample_transformer_init", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L2", "weight": 1.0}, {"source": "sample_transformer", "target": "sample_transformer_forward", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/safi/graphify/tests/fixtures/sample.py", "source_location": "L5", "weight": 1.0}]} \ No newline at end of file diff --git a/tests/fixtures/sample_calls.py b/tests/fixtures/sample_calls.py index b679b14b2..e4550e296 100644 --- a/tests/fixtures/sample_calls.py +++ b/tests/fixtures/sample_calls.py @@ -1,4 +1,4 @@ -"""Fixture: functions and methods that call each other — for call-graph extraction tests.""" +"""Fixture: functions and methods that call each other - for call-graph extraction tests.""" def compute_score(data): diff --git a/tests/test_cache.py b/tests/test_cache.py index 35e523a70..3375ed722 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -67,8 +67,8 @@ def test_cached_files(tmp_path, cache_root): def test_clear_cache(tmp_file, cache_root): - """clear_cache removes all .json files from .graphify/cache/.""" + """clear_cache removes all .json files from graphify-out/cache/.""" save_cached(tmp_file, {"nodes": [], "edges": []}, root=cache_root) - assert len(list((cache_root / ".graphify" / "cache").glob("*.json"))) > 0 + assert len(list((cache_root / "graphify-out" / "cache").glob("*.json"))) > 0 clear_cache(cache_root) - assert len(list((cache_root / ".graphify" / "cache").glob("*.json"))) == 0 + assert len(list((cache_root / "graphify-out" / "cache").glob("*.json"))) == 0 diff --git a/tests/test_extract.py b/tests/test_extract.py index 2dd2e3e9d..2ae63eaed 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -106,7 +106,7 @@ def test_calls_no_self_loops(): def test_run_analysis_calls_compute_score(): - """run_analysis() calls compute_score() — must appear as a calls edge.""" + """run_analysis() calls compute_score() - must appear as a calls edge.""" result = extract_python(FIXTURES / "sample_calls.py") calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"} node_by_label = {n["label"]: n["id"] for n in result["nodes"]} @@ -127,7 +127,7 @@ def test_run_analysis_calls_normalize(): def test_method_calls_module_function(): - """Analyzer.process() calls run_analysis() — cross class→function calls edge.""" + """Analyzer.process() calls run_analysis() - cross class→function calls edge.""" result = extract_python(FIXTURES / "sample_calls.py") calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"} node_by_label = {n["label"]: n["id"] for n in result["nodes"]} diff --git a/tests/test_security.py b/tests/test_security.py index f3036ca11..dcaeffbbb 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,4 +1,4 @@ -"""Tests for graphify/security.py — URL validation, safe fetch, path guards, label sanitisation.""" +"""Tests for graphify/security.py - URL validation, safe fetch, path guards, label sanitisation.""" from __future__ import annotations import json @@ -47,7 +47,7 @@ def test_validate_url_rejects_empty_scheme(): # --------------------------------------------------------------------------- -# safe_fetch — scheme and redirect guards (mocked network) +# safe_fetch - scheme and redirect guards (mocked network) # --------------------------------------------------------------------------- def _make_mock_response(content: bytes, status: int = 200): @@ -138,7 +138,7 @@ def test_safe_fetch_text_replaces_bad_bytes(): # --------------------------------------------------------------------------- def test_validate_graph_path_allows_inside_base(tmp_path): - base = tmp_path / ".graphify" + base = tmp_path / "graphify-out" base.mkdir() graph = base / "graph.json" graph.write_text("{}") @@ -146,19 +146,19 @@ def test_validate_graph_path_allows_inside_base(tmp_path): assert result == graph.resolve() def test_validate_graph_path_blocks_traversal(tmp_path): - base = tmp_path / ".graphify" + base = tmp_path / "graphify-out" base.mkdir() - evil = tmp_path / ".graphify" / ".." / "etc_passwd" + evil = tmp_path / "graphify-out" / ".." / "etc_passwd" with pytest.raises(ValueError, match="escapes"): validate_graph_path(str(evil), base=base) def test_validate_graph_path_requires_base_exists(tmp_path): - base = tmp_path / ".graphify" # not created + base = tmp_path / "graphify-out" # not created with pytest.raises(ValueError, match="does not exist"): validate_graph_path(str(base / "graph.json"), base=base) def test_validate_graph_path_raises_if_file_missing(tmp_path): - base = tmp_path / ".graphify" + base = tmp_path / "graphify-out" base.mkdir() with pytest.raises(FileNotFoundError): validate_graph_path(str(base / "missing.json"), base=base) diff --git a/tests/test_serve.py b/tests/test_serve.py index 8875cd62f..ed5217653 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1,4 +1,4 @@ -"""Tests for serve.py — MCP graph query helpers (no mcp package required).""" +"""Tests for serve.py - MCP graph query helpers (no mcp package required).""" import json import pytest import networkx as nx @@ -67,7 +67,7 @@ def test_score_nodes_no_match(): def test_score_nodes_source_file_partial(): G = _make_graph() - # "cluster.py" contains "cluster" — should score 0.5 for source match + # "cluster.py" contains "cluster" - should score 0.5 for source match scored = _score_nodes(G, ["cluster"]) nids = [nid for _, nid in scored] assert "n2" in nids @@ -150,7 +150,7 @@ def test_load_graph_roundtrip(tmp_path): assert G2.number_of_edges() == G.number_of_edges() def test_load_graph_missing_file(tmp_path): - graphify_dir = tmp_path / ".graphify" + graphify_dir = tmp_path / "graphify-out" graphify_dir.mkdir() with pytest.raises(SystemExit): _load_graph(str(graphify_dir / "nonexistent.json")) diff --git a/tests/test_watch.py b/tests/test_watch.py index 5492c69ad..12916832e 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -1,4 +1,4 @@ -"""Tests for watch.py — file watcher helpers (no watchdog required).""" +"""Tests for watch.py - file watcher helpers (no watchdog required).""" import time from pathlib import Path import pytest @@ -10,20 +10,20 @@ from graphify.watch import _run_update, _WATCHED_EXTENSIONS def test_run_update_creates_flag(tmp_path): _run_update(tmp_path) - flag = tmp_path / ".graphify" / "needs_update" + flag = tmp_path / "graphify-out" / "needs_update" assert flag.exists() assert flag.read_text() == "1" def test_run_update_creates_flag_dir(tmp_path): - # .graphify dir does not exist yet - assert not (tmp_path / ".graphify").exists() + # graphify-out dir does not exist yet + assert not (tmp_path / "graphify-out").exists() _run_update(tmp_path) - assert (tmp_path / ".graphify").is_dir() + assert (tmp_path / "graphify-out").is_dir() def test_run_update_idempotent(tmp_path): _run_update(tmp_path) _run_update(tmp_path) - flag = tmp_path / ".graphify" / "needs_update" + flag = tmp_path / "graphify-out" / "needs_update" assert flag.read_text() == "1" diff --git a/worked/httpx/GRAPH_REPORT.md b/worked/httpx/GRAPH_REPORT.md index 4624ba42b..9036b99fa 100644 --- a/worked/httpx/GRAPH_REPORT.md +++ b/worked/httpx/GRAPH_REPORT.md @@ -1,4 +1,4 @@ -# Graph Report — /home/safi/graphify_test/httpx (2026-04-03) +# Graph Report - /home/safi/graphify_test/httpx (2026-04-03) ## Corpus Check - 6 files · ~2,800 words @@ -17,18 +17,18 @@ - Extraction: ~100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS - Token cost: 0 input · 0 output -## God Nodes (most connected — your core abstractions) +## God Nodes (most connected - your core abstractions) -1. `client.py` — ~28 edges -2. `models.py` — ~22 edges -3. `transport.py` — ~20 edges -4. `exceptions.py` — ~18 edges -5. `BaseClient` — ~15 edges -6. `auth.py` — ~14 edges -7. `Response` — ~12 edges -8. `Client` — ~10 edges -9. `AsyncClient` — ~10 edges -10. `utils.py` — ~9 edges +1. `client.py` - ~28 edges +2. `models.py` - ~22 edges +3. `transport.py` - ~20 edges +4. `exceptions.py` - ~18 edges +5. `BaseClient` - ~15 edges +6. `auth.py` - ~14 edges +7. `Response` - ~12 edges +8. `Client` - ~10 edges +9. `AsyncClient` - ~10 edges +10. `utils.py` - ~9 edges ## Surprising Connections (you probably didn't know these) @@ -45,18 +45,18 @@ ## Communities -### Community 0 — "Core HTTP Client" +### Community 0 - "Core HTTP Client" Cohesion: 0.14 Nodes (12): client.py, BaseClient, Client, AsyncClient, .send(), .request(), .get(), .post(), .close(), .aclose(), Timeout, Limits -### Community 1 — "Request/Response Models" +### Community 1 - "Request/Response Models" Cohesion: 0.18 Nodes (10): models.py, Request, Response, URL, Headers, Cookies, .read(), .json(), .raise_for_status(), .cookies -### Community 2 — "Exception Hierarchy" +### Community 2 - "Exception Hierarchy" Cohesion: 0.10 Nodes (20): exceptions.py, HTTPStatusError, RequestError, TransportError, TimeoutException, ConnectTimeout, ReadTimeout, WriteTimeout, PoolTimeout, NetworkError, ConnectError, ReadError, WriteError, CloseError, ProxyError, UnsupportedProtocol, DecodingError, TooManyRedirects, InvalidURL, CookieConflict... -### Community 3 — "Transport & Auth" +### Community 3 - "Transport & Auth" Cohesion: 0.08 Nodes (18): transport.py, BaseTransport, AsyncBaseTransport, HTTPTransport, AsyncHTTPTransport, MockTransport, ProxyTransport, ConnectionPool, auth.py, Auth, BasicAuth, DigestAuth, BearerAuth, NetRCAuth, .handle_request(), .auth_flow(), utils.py, .obfuscate_sensitive_headers()... diff --git a/worked/httpx/review.md b/worked/httpx/review.md index 802cf62ae..66f8f96e5 100644 --- a/worked/httpx/review.md +++ b/worked/httpx/review.md @@ -1,6 +1,6 @@ -# Graphify Evaluation — httpx Corpus (2026-04-03) +# Graphify Evaluation - httpx Corpus (2026-04-03) -**Evaluator:** Claude Sonnet 4.6 (analytical simulation — Bash execution unavailable) +**Evaluator:** Claude Sonnet 4.6 (analytical simulation - Bash execution unavailable) **Corpus:** 6-file synthetic httpx-like Python codebase (~2,800 words) **Pipeline:** graphify AST extractor + graph_builder + Leiden clusterer + analyzer + reporter **Method:** Full deterministic code tracing of every graphify source module against @@ -12,7 +12,7 @@ exact Leiden partition is non-deterministic but the structural analysis is sound ## Full GRAPH_REPORT.md Content ```markdown -# Graph Report — /home/safi/graphify_test/httpx (2026-04-03) +# Graph Report - /home/safi/graphify_test/httpx (2026-04-03) ## Corpus Check - 6 files · ~2,800 words @@ -23,17 +23,17 @@ exact Leiden partition is non-deterministic but the structural analysis is sound - Extraction: ~100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS - Token cost: 0 input · 0 output -## God Nodes (most connected — your core abstractions) -1. `client.py` — ~28 edges -2. `models.py` — ~22 edges -3. `transport.py` — ~20 edges -4. `exceptions.py` — ~18 edges -5. `BaseClient` — ~15 edges -6. `auth.py` — ~14 edges -7. `Response` — ~12 edges -8. `Client` — ~10 edges -9. `AsyncClient` — ~10 edges -10. `utils.py` — ~9 edges +## God Nodes (most connected - your core abstractions) +1. `client.py` - ~28 edges +2. `models.py` - ~22 edges +3. `transport.py` - ~20 edges +4. `exceptions.py` - ~18 edges +5. `BaseClient` - ~15 edges +6. `auth.py` - ~14 edges +7. `Response` - ~12 edges +8. `Client` - ~10 edges +9. `AsyncClient` - ~10 edges +10. `utils.py` - ~9 edges ## Surprising Connections - `BaseClient` ↔ `.auth_flow()` [EXTRACTED] @@ -49,19 +49,19 @@ exact Leiden partition is non-deterministic but the structural analysis is sound ## Communities -### Community 0 — "Core HTTP Client" +### Community 0 - "Core HTTP Client" Cohesion: 0.14 Nodes (12): client.py, BaseClient, Client, AsyncClient, .send(), .request(), .get(), .post(), .close(), .aclose(), Timeout, Limits -### Community 1 — "Request/Response Models" +### Community 1 - "Request/Response Models" Cohesion: 0.18 Nodes (10): models.py, Request, Response, URL, Headers, Cookies, .read(), .json(), .raise_for_status(), .cookies -### Community 2 — "Exception Hierarchy" +### Community 2 - "Exception Hierarchy" Cohesion: 0.10 Nodes (20): exceptions.py, HTTPStatusError, RequestError, TransportError, TimeoutException, ... -### Community 3 — "Transport & Auth" +### Community 3 - "Transport & Auth" Cohesion: 0.08 Nodes (18): transport.py, BaseTransport, HTTPTransport, MockTransport, ProxyTransport, ConnectionPool, auth.py, Auth, BasicAuth, DigestAuth, BearerAuth, NetRCAuth, ... ``` @@ -70,7 +70,7 @@ Nodes (18): transport.py, BaseTransport, HTTPTransport, MockTransport, ProxyTran ## Evaluation Scores -### 1. Node/Edge Quality — Score: 6/10 +### 1. Node/Edge Quality - Score: 6/10 **What's captured well:** - File-level nodes for all 6 files (exceptions, models, auth, utils, client, transport) ✓ @@ -78,42 +78,42 @@ Nodes (18): transport.py, BaseTransport, HTTPTransport, MockTransport, ProxyTran subclasses; URL, Headers, Cookies, Request, Response; Auth, BasicAuth, DigestAuth, BearerAuth, NetRCAuth; BaseClient, Client, AsyncClient; Timeout, Limits; BaseTransport, AsyncBaseTransport, HTTPTransport, AsyncHTTPTransport, MockTransport, ProxyTransport, - ConnectionPool — all captured ✓ + ConnectionPool - all captured ✓ - Module-level functions from utils.py (primitive_value_to_str, normalize_header_key, flatten_queryparams, parse_content_type, obfuscate_sensitive_headers, etc.) ✓ - Methods on all classes (auth_flow, handle_request, send, request, get/post/put/etc.) ✓ **Missing/wrong nodes:** - **No inheritance edges in the exception hierarchy.** The extractor builds inheritance edges - as `_make_id(stem, base_name)` — e.g. `RequestError` inheriting `Exception` produces target + as `_make_id(stem, base_name)` - e.g. `RequestError` inheriting `Exception` produces target `exceptions_exception`. But `Exception` is never registered as a node, so the edge is filtered at the clean step. All 14 inheritance edges in exceptions.py are silently dropped. This critically loses the rich `TransportError → NetworkError → ConnectError` chain. - **No inheritance across files.** `BaseClient` inherits nothing in the graph. `Client(BaseClient)` produces `_make_id("client", "BaseClient")` = `"client_baseclient"`, but `BaseClient`'s node - ID is `_make_id("client", "BaseClient")` = `"client_baseclient"` — this actually SHOULD work + ID is `_make_id("client", "BaseClient")` = `"client_baseclient"` - this actually SHOULD work because both the class definition and the inheritance reference use the same stem ("client"). **This is a good sign:** within-file inheritance works when the parent is defined in the same file. -- **Cross-file inheritance is not captured.** `HTTPTransport(BaseTransport)` — `BaseTransport` +- **Cross-file inheritance is not captured.** `HTTPTransport(BaseTransport)` - `BaseTransport` is defined in `transport.py`, so `_make_id("transport", "BaseTransport")` = `"transport_basetransport"`. The inheritance call from within `HTTPTransport` uses the same stem, so this should also work. - **Property methods lose their property decorator context.** `url`, `content`, `cookies`, - `is_success`, `is_error`, etc. are extracted as ordinary methods — no semantic distinction. -- **`build_auth_header` utility function in auth.py** — captured as a module-level function ✓ + `is_success`, `is_error`, etc. are extracted as ordinary methods - no semantic distinction. +- **`build_auth_header` utility function in auth.py** - captured as a module-level function ✓ - **Import edges point to external modules** (typing, hashlib, json, re, time, etc.) that are never registered as nodes. Those are filtered out (imports_from/imports are kept even without - a matching target node per the clean step logic) — this is the correct behavior. + a matching target node per the clean step logic) - this is the correct behavior. **Summary:** ~85% of meaningful code entities are captured. The main gap is the exception inheritance chain (14 edges lost) and cross-file import references to specific names. --- -### 2. Edge Accuracy — Score: 5/10 +### 2. Edge Accuracy - Score: 5/10 **EXTRACTED vs INFERRED ratio:** The AST extractor produces 100% EXTRACTED edges (all edges come from the tree-sitter parse). There are 0 INFERRED edges. This means every edge in the -graph is a direct structural fact from the source code — honest but **not semantically rich**. +graph is a direct structural fact from the source code - honest but **not semantically rich**. **What's right:** - `contains` edges from file nodes to their class/function children ✓ @@ -124,19 +124,19 @@ graph is a direct structural fact from the source code — honest but **not sema **What's wrong or missing:** - **0% INFERRED edges.** The AST extractor only does structural extraction. There are no semantic/functional edges: no "calls", no "conceptually_related_to", no "implements". - For example, `DigestAuth.auth_flow` calls `Response.status_code` — this relationship is + For example, `DigestAuth.auth_flow` calls `Response.status_code` - this relationship is invisible. The auth module's challenge-response dance with Response objects is not captured. - **Inheritance chain edges dropped (14 edges).** As analyzed above, all inheritance from builtins (Exception, ABC) is silently dropped, making the exception hierarchy appear flat. - **Import edges are present but low-signal.** `client.py imports_from models` is correct but - doesn't say WHICH classes — so the graph can't distinguish that `Client` specifically uses + doesn't say WHICH classes - so the graph can't distinguish that `Client` specifically uses `Request` and `Response`, not just the whole models module. -- **No "calls" relationships.** `Response.raise_for_status()` calls `HTTPStatusError()` — - a critical architectural fact — is missing entirely. +- **No "calls" relationships.** `Response.raise_for_status()` calls `HTTPStatusError()` - + a critical architectural fact - is missing entirely. - **The _make_id fix (verified working):** The `parent_class_nid` is passed recursively to method nodes. A method ID is `_make_id(parent_class_nid, func_name)` where `parent_class_nid` is already `_make_id(stem, class_name)`. This means method IDs are correctly scoped to - `stem_classname_methodname`. Edge cleanup checks `src in valid_ids` — since method nodes ARE + `stem_classname_methodname`. Edge cleanup checks `src in valid_ids` - since method nodes ARE registered in `seen_ids`, method edges are preserved. The previously-reported 27% edge drop bug appears to be fixed in this version. @@ -144,32 +144,32 @@ graph is a direct structural fact from the source code — honest but **not sema - Correct, present: ~115 edges (88%) - Silently dropped (inheritance from builtins): ~14 edges (11%) - False positives: ~2 edges (import edges to nonexistent modules like "socket" kept via - imports exception in clean step — technically correct behavior) + imports exception in clean step - technically correct behavior) - Missing (calls, conceptual): would require LLM or runtime analysis --- -### 3. Community Quality — Score: 6/10 +### 3. Community Quality - Score: 6/10 **Communities make semantic sense?** Largely yes, with one significant problem. -**Community 0 — "Core HTTP Client"** (Client, AsyncClient, BaseClient + methods, Timeout, Limits) +**Community 0 - "Core HTTP Client"** (Client, AsyncClient, BaseClient + methods, Timeout, Limits) - This is semantically tight: all the public API surface of httpx belongs here. -- Cohesion ~0.14: low but expected — client.py's class bodies generate many method nodes +- Cohesion ~0.14: low but expected - client.py's class bodies generate many method nodes that connect to their parent but not to each other, making the subgraph sparse. -**Community 1 — "Request/Response Models"** (Request, Response, URL, Headers, Cookies + methods) -- Excellent grouping — this is exactly the "data model" layer. Cohesion ~0.18 is the highest +**Community 1 - "Request/Response Models"** (Request, Response, URL, Headers, Cookies + methods) +- Excellent grouping - this is exactly the "data model" layer. Cohesion ~0.18 is the highest because methods connect within their parent classes. -**Community 2 — "Exception Hierarchy"** (all 15 exception classes) +**Community 2 - "Exception Hierarchy"** (all 15 exception classes) - Good that exceptions are grouped together. BUT because inheritance edges are all dropped, the only intra-community edges are `exceptions.py contains ExceptionClass`. This means - cohesion is near-zero (0.10 estimated) — the community is held together only by the file + cohesion is near-zero (0.10 estimated) - the community is held together only by the file node, not by the actual inheritance structure. Leiden may have difficulty clustering these correctly since they look like isolated nodes connected only to the file hub. -**Community 3 — "Transport & Auth"** (all transport + auth classes) +**Community 3 - "Transport & Auth"** (all transport + auth classes) - This is the most problematic grouping. Transport (HTTPTransport, ConnectionPool, etc.) and Auth (BasicAuth, DigestAuth, etc.) are bundled together simply because both modules import from models.py and exceptions.py. They are architecturally distinct layers. A developer @@ -182,7 +182,7 @@ real codebase with many cross-cutting concerns. The scores are not artificially --- -### 4. Surprising Connections — Score: 4/10 +### 4. Surprising Connections - Score: 4/10 **Are the "surprising" connections actually non-obvious?** @@ -190,13 +190,13 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev 1. `BaseClient ↔ .auth_flow()` (client.py ↔ auth.py) - This IS a cross-file relationship and captures that the client consumes the auth - protocol. Moderately interesting — but "client uses auth" is not surprising. + protocol. Moderately interesting - but "client uses auth" is not surprising. - Score: Somewhat interesting, but obvious to anyone who reads client.py line 1. 2. `ProxyTransport ↔ TransportError` (transport.py ↔ exceptions.py) - This is within the same file (transport.py imports exceptions at the bottom: `from .exceptions import TransportError`). This is a re-export, not a surprise. - - Score: False positive — this is a completely obvious import. + - Score: False positive - this is a completely obvious import. 3. `ConnectionPool ↔ Request` (transport.py ↔ models.py) - transport.py imports from models. That `ConnectionPool` specifically uses `Request` @@ -206,14 +206,14 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev 4. `DigestAuth ↔ Response` (auth.py ↔ models.py) - This IS genuinely interesting! DigestAuth needs to inspect the Response (WWW-Authenticate header, 401 status) to build its challenge response. The auth layer having a bidirectional - dependency on Response is a real architectural insight — auth is not a pure pre-request + dependency on Response is a real architectural insight - auth is not a pure pre-request decorator but a request-response cycle participant. - Score: Genuinely non-obvious and architecturally significant. 5. `utils.py ↔ Cookies` (utils.py ↔ models.py) - `unset_all_cookies` in utils.py imports `Cookies` from models. This is a minor utility function, and it IS surprising because utils shouldn't need to know about Cookies directly - — it reveals a cohesion issue in the utils module. + - it reveals a cohesion issue in the utils module. - Score: Mildly interesting. **Problems:** @@ -227,18 +227,18 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev --- -### 5. God Nodes — Score: 7/10 +### 5. God Nodes - Score: 7/10 **Are the most-connected nodes actually the core abstractions?** **Very good:** -- `client.py` as #1 god node makes sense — it imports from 5 other modules and contains the +- `client.py` as #1 god node makes sense - it imports from 5 other modules and contains the most method nodes. It is the integration hub of the library. -- `models.py` as #2 is correct — Request, Response, URL, Headers, Cookies are the central +- `models.py` as #2 is correct - Request, Response, URL, Headers, Cookies are the central data models that everything else references. - `BaseClient` as #5 correctly identifies the shared implementation hub between Client and AsyncClient. -- `Response` as #7 is accurate — it's the most feature-rich class with the most methods. +- `Response` as #7 is accurate - it's the most feature-rich class with the most methods. **Problematic:** - File-level nodes (client.py, models.py, transport.py, exceptions.py, auth.py, utils.py) @@ -254,13 +254,13 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev --- -### 6. Overall Usefulness — Score: 6/10 +### 6. Overall Usefulness - Score: 6/10 **Would this graph help a developer understand the codebase?** **Yes, it would help with:** - Quickly identifying that httpx has four distinct layers: exceptions, models, auth/transport, - and client — even if auth and transport are merged. + and client - even if auth and transport are merged. - Seeing that `BaseClient` is the shared implementation hub for sync and async clients. - Identifying `Response` and `Request` as the central data types. - Finding cross-module coupling (e.g., auth's dependency on Response). @@ -270,7 +270,7 @@ The 5 reported connections are all EXTRACTED (cross-file import edges). Let's ev - Understanding the exception hierarchy (all 14 inheritance edges are dropped). - Understanding call flow (which methods call which). - Understanding that DigestAuth participates in a request/response cycle, not just - pre-request decoration — this architectural insight is present but buried in boring + pre-request decoration - this architectural insight is present but buried in boring EXTRACTED connection #4. - Understanding the relationship between `ConnectionPool` and connection management (it's there, but only as an import edge, not as a "manages" semantic edge). @@ -332,11 +332,11 @@ Even simple name-based heuristics would add INFERRED edges for common patterns. surprising connections. But many cross-file edges are mundane imports. The sort by AMBIGUOUS→INFERRED→EXTRACTED order is intended to surface uncertain connections first, but when everything is EXTRACTED, the algorithm falls back to arbitrary ordering. -**Fix:** Add a "distance" metric — prefer pairs where the source files have no direct +**Fix:** Add a "distance" metric - prefer pairs where the source files have no direct import relationship. A `transport.py → exceptions.py` edge should rank lower than a `DigestAuth → Response` edge because transport already imports exceptions directly. -### Issue 6: _make_id edge fix — CONFIRMED WORKING +### Issue 6: _make_id edge fix - CONFIRMED WORKING **Location:** `ast_extractor.py` lines 124–133 **Previous bug:** Method edges used wrong IDs causing 27% edge drop. **Current code:** Method node ID is `_make_id(parent_class_nid, func_name)` and the @@ -345,7 +345,7 @@ same `parent_class_nid`. Both `parent_class_nid` and `func_nid` are in `seen_ids **Status:** The _make_id fix is correctly implemented. Method edges are preserved. No 27% drop for method edges. ✓ -### Issue 7: Concept node filtering — CONFIRMED WORKING +### Issue 7: Concept node filtering - CONFIRMED WORKING **Location:** `analyzer.py` _is_concept_node() **Check:** The `_is_concept_node` function correctly filters nodes with empty source_file or a source_file with no extension. The AST extractor always sets source_file to the @@ -382,13 +382,13 @@ otherwise be dropped. The fix is confirmed working. The graphify AST extractor is deterministic, fast, and accurate for what it extracts. But structural extraction alone captures at most 25-30% of the interesting relationships in a Python codebase. The skill.md design correctly envisions the Claude LLM doing a -richer extraction pass (Step 3) for document/paper corpora — but for code, the pipeline +richer extraction pass (Step 3) for document/paper corpora - but for code, the pipeline currently relies entirely on tree-sitter, producing a structurally correct but semantically thin graph. ### Corpus size and density At ~2,800 words and 6 files, this corpus is on the small side for graph analysis. -The skill.md correctly warns "Corpus fits in a single context window — you may not need +The skill.md correctly warns "Corpus fits in a single context window - you may not need a graph." A real httpx codebase has 30+ files. The graph value would increase substantially with larger corpora where the file-level connectivity creates meaningful community structure. diff --git a/worked/karpathy-repos/GRAPH_REPORT.md b/worked/karpathy-repos/GRAPH_REPORT.md index 9b0f80d6b..90018e7c5 100644 --- a/worked/karpathy-repos/GRAPH_REPORT.md +++ b/worked/karpathy-repos/GRAPH_REPORT.md @@ -1,4 +1,4 @@ -# Graph Report — /home/safi/graphify-benchmark (2026-04-04) +# Graph Report - /home/safi/graphify-benchmark (2026-04-04) ## Corpus Check - 49 files · ~92,616 words @@ -9,17 +9,17 @@ - Extraction: 81% EXTRACTED · 19% INFERRED · 0% AMBIGUOUS - Token cost: 6,000 input · 3,500 output -## God Nodes (most connected — your core abstractions) -1. `Value` — 15 edges -2. `Training Script` — 11 edges -3. `GPT` — 9 edges -4. `Layer` — 8 edges -5. `CharDataset` — 7 edges -6. `AdditionDataset` — 7 edges -7. `CfgNode` — 7 edges -8. `Encoder` — 7 edges -9. `Neuron` — 7 edges -10. `FlashAttention Algorithm` — 7 edges +## God Nodes (most connected - your core abstractions) +1. `Value` - 15 edges +2. `Training Script` - 11 edges +3. `GPT` - 9 edges +4. `Layer` - 8 edges +5. `CharDataset` - 7 edges +6. `AdditionDataset` - 7 edges +7. `CfgNode` - 7 edges +8. `Encoder` - 7 edges +9. `Neuron` - 7 edges +10. `FlashAttention Algorithm` - 7 edges ## Surprising Connections (you probably didn't know these) - `from_pretrained()` --calls--> `get_default_config()` [INFERRED] @@ -35,310 +35,310 @@ ## Communities -### Community 0 — "nanoGPT Model Architecture" +### Community 0 - "nanoGPT Model Architecture" Cohesion: 0.11 Nodes (12): dataclasses, inspect, Block, CausalSelfAttention, from_pretrained(), get_default_config(), GPT, GPTConfig (+4 more) -### Community 1 — "minGPT Training + Datasets" +### Community 1 - "minGPT Training + Datasets" Cohesion: 0.12 Nodes (17): batch_end_callback(), eval_split(), get_config(), get_default_config(), get_config(), get_default_config(), collections, mingpt_bpe (+9 more) -### Community 2 — "nanoGPT Training Pipeline" +### Community 2 - "nanoGPT Training Pipeline" Cohesion: 0.13 Nodes (15): get_batch(), contextlib, datasets, math, numpy, os, pickle, tiktoken (+7 more) -### Community 3 — "nanoGPT Config + Data Prep" +### Community 3 - "nanoGPT Config + Data Prep" Cohesion: 0.1 Nodes (22): Benchmarking Script, Config: Finetune GPT-2-XL on Shakespeare, Config: Train GPT-2 (124M), Config: Train Character-Level Shakespeare, Configurator (exec-based Override System), OpenWebText Data Preparation, Shakespeare Char-Level Data Preparation, Shakespeare (BPE) Data Preparation (+14 more) -### Community 4 — "micrograd NN Layer" +### Community 4 - "micrograd NN Layer" Cohesion: 0.13 Nodes (6): micrograd_engine, Layer, MLP, Module, Neuron, random -### Community 5 — "FlashAttention Paper" +### Community 5 - "FlashAttention Paper" Cohesion: 0.12 Nodes (21): FlashAttention Algorithm, GPU HBM vs On-Chip SRAM Memory Hierarchy, FlashAttention: Fast Memory-Efficient Attention, Selective Gradient Checkpointing (Recomputation), Result: 15% faster BERT-large vs MLPerf, Result: 3x GPT-2 training speedup, Tiling for Attention Computation, Self-Attention Mechanism (Q, K, V) (+13 more) -### Community 6 — "BPE Tokenizer" +### Community 6 - "BPE Tokenizer" Cohesion: 0.19 Nodes (8): BPETokenizer, bytes_to_unicode(), Encoder, get_encoder(), get_file(), get_pairs(), regex, requests -### Community 7 — "micrograd Autograd Engine" +### Community 7 - "micrograd Autograd Engine" Cohesion: 0.12 Nodes (1): Value -### Community 8 — "Stdlib + Config Utilities" +### Community 8 - "Stdlib + Config Utilities" Cohesion: 0.18 Nodes (5): ast, json, sys, CfgNode, setup_logging() -### Community 9 — "Addition Dataset" +### Community 9 - "Addition Dataset" Cohesion: 0.15 Nodes (3): AdditionDataset, CharDataset, Dataset -### Community 10 — "micrograd README + Backprop" +### Community 10 - "micrograd README + Backprop" Cohesion: 0.21 Nodes (11): Value (autograd scalar), Value.backward, Micrograd Computation Graph (operations + gradients), Backpropagation / Reverse-Mode Autodiff, Dynamically Built DAG (computation graph), micrograd, GPT.configure_optimizers, GPT.forward (minGPT) (+3 more) -### Community 11 — "Attention Residuals Paper" +### Community 11 - "Attention Residuals Paper" Cohesion: 0.33 -Nodes (7): Block Attention Residuals, Full Attention Residuals, Attention Residuals (AttnRes) — Kimi Team, PreNorm Dilution Problem, Result: AttnRes improves MMLU 73.5→74.6, BBH 76.3→78.0, Result: Block AttnRes matches 1.25x more compute baseline, Residual Connections in Deep Networks +Nodes (7): Block Attention Residuals, Full Attention Residuals, Attention Residuals (AttnRes) - Kimi Team, PreNorm Dilution Problem, Result: AttnRes improves MMLU 73.5→74.6, BBH 76.3→78.0, Result: Block AttnRes matches 1.25x more compute baseline, Residual Connections in Deep Networks -### Community 12 — "Continual LoRA Paper" +### Community 12 - "Continual LoRA Paper" Cohesion: 0.33 Nodes (6): Catastrophic Forgetting Problem, CoLoR Method, Low Rank Adaptation (LoRA), CoLoR: Continual Learning with Low Rank Adaptation, Vision Transformer (ViT-B-16) Backbone, Multi-Head Attention -### Community 13 — "minGPT Trainer Class" +### Community 13 - "minGPT Trainer Class" Cohesion: 0.4 Nodes (1): Trainer -### Community 14 — "NeuralWalker Paper" +### Community 14 - "NeuralWalker Paper" Cohesion: 0.4 Nodes (5): Mamba State Space Model, NeuralWalker Architecture, NeuralWalker: Learning Long Range Dependencies on Graphs, Result: NeuralWalker is strictly more expressive than 1-WL, Result: NeuralWalker +10% PascalVOC-SP, +13% COCO-SP over SOTA -### Community 15 — "Dataset Abstractions" +### Community 15 - "Dataset Abstractions" Cohesion: 0.67 Nodes (3): AdditionDataset, CharDataset, GPT.generate (minGPT) -### Community 16 — "BPETokenizer (minGPT)" +### Community 16 - "BPETokenizer (minGPT)" Cohesion: 1.0 Nodes (2): BPETokenizer, BPE Encoder -### Community 17 — "OpenWebText Dataset" +### Community 17 - "OpenWebText Dataset" Cohesion: 1.0 Nodes (2): OpenWebText Dataset, OpenWebText Dataset (~9B tokens, 17GB, 8M documents) -### Community 18 — "torch.compile Performance" +### Community 18 - "torch.compile Performance" Cohesion: 1.0 Nodes (2): Performance: torch.compile reduces iter time from 250ms to 135ms, torch.compile (PyTorch 2.0) -### Community 19 — "Behavior Token Paper" +### Community 19 - "Behavior Token Paper" Cohesion: 1.0 Nodes (2): Behavior Tokens Concept, LCBM: Large Content and Behavior Model -### Community 20 — "Setup" +### Community 20 - "Setup" Cohesion: 1.0 Nodes (1): setuptools -### Community 21 — "Nanogpt Complexity Metaphor" +### Community 21 - "Nanogpt Complexity Metaphor" Cohesion: 1.0 Nodes (2): GPT Complexity Metaphor: Battleship vs Speedboat, nanogpt_readme_design_simplicity -### Community 22 — "Mingpt Readme Design Education" +### Community 22 - "Mingpt Readme Design Education" Cohesion: 1.0 Nodes (2): Design Decision: minGPT prioritizes education (~300 lines), Design Decision: nanoGPT prioritizes speed over education -### Community 23 — "Mingpt Readme Mingpt" +### Community 23 - "Mingpt Readme Mingpt" Cohesion: 1.0 Nodes (2): mingpt_readme_mingpt, Attention Is All You Need (Transformer Paper) -### Community 24 — "Init" +### Community 24 - "Init" Cohesion: 1.0 Nodes (0): -### Community 25 — "Train Gpt2" +### Community 25 - "Train Gpt2" Cohesion: 1.0 Nodes (0): -### Community 26 — "Eval Gpt2 Xl" +### Community 26 - "Eval Gpt2 Xl" Cohesion: 1.0 Nodes (0): -### Community 27 — "Eval Gpt2" +### Community 27 - "Eval Gpt2" Cohesion: 1.0 Nodes (0): -### Community 28 — "Eval Gpt2 Large" +### Community 28 - "Eval Gpt2 Large" Cohesion: 1.0 Nodes (0): -### Community 29 — "Train Shakespeare Char" +### Community 29 - "Train Shakespeare Char" Cohesion: 1.0 Nodes (0): -### Community 30 — "Eval Gpt2 Medium" +### Community 30 - "Eval Gpt2 Medium" Cohesion: 1.0 Nodes (0): -### Community 31 — "Model Layernorm" +### Community 31 - "Model Layernorm" Cohesion: 1.0 Nodes (1): LayerNorm with Optional Bias -### Community 32 — "Model Meta Pkl Schema" +### Community 32 - "Model Meta Pkl Schema" Cohesion: 1.0 Nodes (1): meta.pkl Vocabulary Schema -### Community 33 — "Config Eval Gpt2" +### Community 33 - "Config Eval Gpt2" Cohesion: 1.0 Nodes (1): Config: Eval GPT-2 (124M) -### Community 34 — "Config Eval Gpt2 Medium" +### Community 34 - "Config Eval Gpt2 Medium" Cohesion: 1.0 Nodes (1): Config: Eval GPT-2 Medium -### Community 35 — "Config Eval Gpt2 Large" +### Community 35 - "Config Eval Gpt2 Large" Cohesion: 1.0 Nodes (1): Config: Eval GPT-2 Large -### Community 36 — "Config Eval Gpt2 Xl" +### Community 36 - "Config Eval Gpt2 Xl" Cohesion: 1.0 Nodes (1): Config: Eval GPT-2 XL -### Community 37 — "Mingpt Model Newgelu" +### Community 37 - "Mingpt Model Newgelu" Cohesion: 1.0 Nodes (1): NewGELU Activation -### Community 38 — "Mingpt Model Gpt From Pretrained" +### Community 38 - "Mingpt Model Gpt From Pretrained" Cohesion: 1.0 Nodes (1): GPT.from_pretrained (minGPT) -### Community 39 — "Mingpt Trainer Trainer" +### Community 39 - "Mingpt Trainer Trainer" Cohesion: 1.0 Nodes (1): Trainer (minGPT) -### Community 40 — "Mingpt Utils Cfgnode" +### Community 40 - "Mingpt Utils Cfgnode" Cohesion: 1.0 Nodes (1): CfgNode Configuration Class -### Community 41 — "Mingpt Utils Set Seed" +### Community 41 - "Mingpt Utils Set Seed" Cohesion: 1.0 Nodes (1): set_seed -### Community 42 — "Mingpt Utils Setup Logging" +### Community 42 - "Mingpt Utils Setup Logging" Cohesion: 1.0 Nodes (1): setup_logging -### Community 43 — "Mingpt Bpe Get Encoder" +### Community 43 - "Mingpt Bpe Get Encoder" Cohesion: 1.0 Nodes (1): get_encoder -### Community 44 — "Mingpt Readme Gpt2 Arch Changes" +### Community 44 - "Mingpt Readme Gpt2 Arch Changes" Cohesion: 1.0 Nodes (1): GPT-2 Architectural Changes: pre-norm LayerNorm, scaled residual init -### Community 45 — "Shakespeare Char Readme Char Dataset" +### Community 45 - "Shakespeare Char Readme Char Dataset" Cohesion: 1.0 Nodes (1): Tiny Shakespeare Char Dataset (1M train tokens) -### Community 46 — "Mingpt Readme Adder Project" +### Community 46 - "Mingpt Readme Adder Project" Cohesion: 1.0 Nodes (1): minGPT Adder Project (GPT trained to add numbers) -### Community 47 — "Chargpt Readme Tiny Shakespeare" +### Community 47 - "Chargpt Readme Tiny Shakespeare" Cohesion: 1.0 Nodes (1): Tiny Shakespeare Dataset -### Community 48 — "2205 14135 Io Awareness" +### Community 48 - "2205 14135 Io Awareness" Cohesion: 1.0 Nodes (1): IO-Aware Attention Computation -### Community 49 — "2205 14135 Result Memory Linear" +### Community 49 - "2205 14135 Result Memory Linear" Cohesion: 1.0 Nodes (1): Result: FlashAttention memory scales linearly -### Community 50 — "2311 17601 Result Domainnet" +### Community 50 - "2311 17601 Result Domainnet" Cohesion: 1.0 Nodes (1): Result: CoLoR 69.7% on DomainNet (+19% over S-Prompts) -### Community 51 — "2309 00359 Result Behavior Sim" +### Community 51 - "2309 00359 Result Behavior Sim" Cohesion: 1.0 Nodes (1): Result: LCBM outperforms GPT-3.5/4 on behavior simulation (10x smaller) -### Community 52 — "Concept Positional Encoding" +### Community 52 - "Concept Positional Encoding" Cohesion: 1.0 Nodes (1): Positional Encoding in Transformers ## Knowledge Gaps - **65 isolated node(s):** `MLP Module`, `LayerNorm with Optional Bias`, `Checkpoint Data Schema (ckpt.pt)`, `meta.pkl Vocabulary Schema`, `Sampling/Inference Script` (+60 more) - These have ≤1 connection — possible missing edges or undocumented components. + These have ≤1 connection - possible missing edges or undocumented components. - **Thin community `BPETokenizer (minGPT)`** (2 nodes): `BPETokenizer`, `BPE Encoder` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `OpenWebText Dataset`** (2 nodes): `OpenWebText Dataset`, `OpenWebText Dataset (~9B tokens, 17GB, 8M documents)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `torch.compile Performance`** (2 nodes): `Performance: torch.compile reduces iter time from 250ms to 135ms`, `torch.compile (PyTorch 2.0)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Behavior Token Paper`** (2 nodes): `Behavior Tokens Concept`, `LCBM: Large Content and Behavior Model` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Setup`** (2 nodes): `setup.py`, `setuptools` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Nanogpt Complexity Metaphor`** (2 nodes): `GPT Complexity Metaphor: Battleship vs Speedboat`, `nanogpt_readme_design_simplicity` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Readme Design Education`** (2 nodes): `Design Decision: minGPT prioritizes education (~300 lines)`, `Design Decision: nanoGPT prioritizes speed over education` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Readme Mingpt`** (2 nodes): `mingpt_readme_mingpt`, `Attention Is All You Need (Transformer Paper)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Init`** (1 nodes): `__init__.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Train Gpt2`** (1 nodes): `train_gpt2.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Eval Gpt2 Xl`** (1 nodes): `eval_gpt2_xl.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Eval Gpt2`** (1 nodes): `eval_gpt2.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Eval Gpt2 Large`** (1 nodes): `eval_gpt2_large.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Train Shakespeare Char`** (1 nodes): `train_shakespeare_char.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Eval Gpt2 Medium`** (1 nodes): `eval_gpt2_medium.py` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Model Layernorm`** (1 nodes): `LayerNorm with Optional Bias` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Model Meta Pkl Schema`** (1 nodes): `meta.pkl Vocabulary Schema` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Config Eval Gpt2`** (1 nodes): `Config: Eval GPT-2 (124M)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Config Eval Gpt2 Medium`** (1 nodes): `Config: Eval GPT-2 Medium` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Config Eval Gpt2 Large`** (1 nodes): `Config: Eval GPT-2 Large` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Config Eval Gpt2 Xl`** (1 nodes): `Config: Eval GPT-2 XL` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Model Newgelu`** (1 nodes): `NewGELU Activation` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Model Gpt From Pretrained`** (1 nodes): `GPT.from_pretrained (minGPT)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Trainer Trainer`** (1 nodes): `Trainer (minGPT)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Utils Cfgnode`** (1 nodes): `CfgNode Configuration Class` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Utils Set Seed`** (1 nodes): `set_seed` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Utils Setup Logging`** (1 nodes): `setup_logging` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Bpe Get Encoder`** (1 nodes): `get_encoder` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Readme Gpt2 Arch Changes`** (1 nodes): `GPT-2 Architectural Changes: pre-norm LayerNorm, scaled residual init` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Shakespeare Char Readme Char Dataset`** (1 nodes): `Tiny Shakespeare Char Dataset (1M train tokens)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Mingpt Readme Adder Project`** (1 nodes): `minGPT Adder Project (GPT trained to add numbers)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Chargpt Readme Tiny Shakespeare`** (1 nodes): `Tiny Shakespeare Dataset` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `2205 14135 Io Awareness`** (1 nodes): `IO-Aware Attention Computation` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `2205 14135 Result Memory Linear`** (1 nodes): `Result: FlashAttention memory scales linearly` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `2311 17601 Result Domainnet`** (1 nodes): `Result: CoLoR 69.7% on DomainNet (+19% over S-Prompts)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `2309 00359 Result Behavior Sim`** (1 nodes): `Result: LCBM outperforms GPT-3.5/4 on behavior simulation (10x smaller)` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Concept Positional Encoding`** (1 nodes): `Positional Encoding in Transformers` - Too small to be a meaningful cluster — may be noise or needs more connections extracted. + Too small to be a meaningful cluster - may be noise or needs more connections extracted. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ - **Why does `Training Script` connect `nanoGPT Config + Data Prep` to `nanoGPT Training Pipeline`?** - _High betweenness centrality (0.176) — this node is a cross-community bridge._ + _High betweenness centrality (0.176) - this node is a cross-community bridge._ - **Why does `GPT Model Class` connect `nanoGPT Config + Data Prep` to `FlashAttention Paper`?** - _High betweenness centrality (0.103) — this node is a cross-community bridge._ + _High betweenness centrality (0.103) - this node is a cross-community bridge._ - **Why does `estimate_loss()` connect `nanoGPT Training Pipeline` to `nanoGPT Config + Data Prep`?** - _High betweenness centrality (0.083) — this node is a cross-community bridge._ + _High betweenness centrality (0.083) - this node is a cross-community bridge._ - **Are the 4 inferred relationships involving `Value` (e.g. with `.__add__()` and `.__mul__()`) actually correct?** - _`Value` has 4 INFERRED edges — model-reasoned connections that need verification._ + _`Value` has 4 INFERRED edges - model-reasoned connections that need verification._ - **Are the 3 inferred relationships involving `Training Script` (e.g. with `GPTConfig Dataclass` and `Performance: ~2.85 val loss in 4 days on 8xA100`) actually correct?** - _`Training Script` has 3 INFERRED edges — model-reasoned connections that need verification._ + _`Training Script` has 3 INFERRED edges - model-reasoned connections that need verification._ - **Are the 2 inferred relationships involving `Layer` (e.g. with `.__init__()` and `.__call__()`) actually correct?** - _`Layer` has 2 INFERRED edges — model-reasoned connections that need verification._ + _`Layer` has 2 INFERRED edges - model-reasoned connections that need verification._ - **What connects `MLP Module`, `LayerNorm with Optional Bias`, `Checkpoint Data Schema (ckpt.pt)` to the rest of the system?** - _65 weakly-connected nodes found — possible documentation gaps or missing edges._ \ No newline at end of file + _65 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file diff --git a/worked/karpathy-repos/review.md b/worked/karpathy-repos/review.md index 3da210005..44dbed048 100644 --- a/worked/karpathy-repos/review.md +++ b/worked/karpathy-repos/review.md @@ -26,7 +26,7 @@ | Average query cost (BFS subgraph) | ~1,726 tokens | | **Reduction ratio** | **71.5x** | -The reduction grows as corpus grows — the BFS subgraph stays roughly constant (~1,700 tokens) while naive stuffing scales linearly with corpus size. +The reduction grows as corpus grows - the BFS subgraph stays roughly constant (~1,700 tokens) while naive stuffing scales linearly with corpus size. ### Per-question breakdown (full corpus) @@ -54,15 +54,15 @@ The "attention mechanism" question returns a larger subgraph (2,836 tokens) beca | Community | Nodes | What it found | |-----------|-------|---------------| -| 0 (30 nodes) | nanoGPT Model Architecture | `Block`, `forward()`, `dataclasses` — transformer architecture | +| 0 (30 nodes) | nanoGPT Model Architecture | `Block`, `forward()`, `dataclasses` - transformer architecture | | 1 (24 nodes) | minGPT Training + Datasets | `batch_end_callback`, `eval_split`, `get_config`, `CharDataset`, `chargpt` | -| 2 (23 nodes) | nanoGPT Training Pipeline | `get_batch`, `bench.py`, config files — data + training loop | +| 2 (23 nodes) | nanoGPT Training Pipeline | `get_batch`, `bench.py`, config files - data + training loop | | 3 (22 nodes) | nanoGPT Config + Data Prep | `configurator`, config scripts, `data/openwebtext/prepare.py` | | 4 (21 nodes) | micrograd NN Layer | `Layer`, `__call__`, `__init__`, `MLP` | | 5 (21 nodes) | FlashAttention Paper | `IO-awareness`, `HBM/SRAM`, `recomputation`, BERT/GPT-2 benchmarks | | 6 (17 nodes) | BPE Tokenizer | `BPETokenizer`, `decode`, `bytes_to_unicode`, full tokenisation logic | -| 7 (16 nodes) | micrograd Autograd Engine | `Value`, `backward`, `__add__`, `__mul__` — the autograd core | -| 8 (14 nodes) | Stdlib + Config Utilities | `ast`, `json`, `CfgNode` — supporting infrastructure | +| 7 (16 nodes) | micrograd Autograd Engine | `Value`, `backward`, `__add__`, `__mul__` - the autograd core | +| 8 (14 nodes) | Stdlib + Config Utilities | `ast`, `json`, `CfgNode` - supporting infrastructure | | 9 (13 nodes) | Addition Dataset | `AdditionDataset`, `get_block_size`, `get_vocab_size` | | 10 (12 nodes) | micrograd README + Backprop | README concepts, backprop explanation, computation graph | | 11 (7 nodes) | Attention Residuals Paper | Kimi model, pre-norm dilution, MMLU scaling | @@ -74,10 +74,10 @@ The "attention mechanism" question returns a larger subgraph (2,836 tokens) beca | Node | Edges | Why central | |------|-------|-------------| -| `Value` (micrograd) | 15 | The autograd primitive — everything math-related connects through it | +| `Value` (micrograd) | 15 | The autograd primitive - everything math-related connects through it | | `Training Script` (nanoGPT) | 11 | Orchestrates model + data + optimizer | -| `GPT` (nanoGPT) | 9 | Main model class — Block, attention, config all flow through here | -| `Layer` (micrograd nn) | 8 | The neural net abstraction — connects engine to high-level API | +| `GPT` (nanoGPT) | 9 | Main model class - Block, attention, config all flow through here | +| `Layer` (micrograd nn) | 8 | The neural net abstraction - connects engine to high-level API | --- @@ -85,23 +85,23 @@ The "attention mechanism" question returns a larger subgraph (2,836 tokens) beca ### What the graph got right -- **micrograd split correctly into two communities** — engine (Value + autograd) and nn (Layer + MLP) are separate communities, matching the intended architecture split in the repo. -- **nanoGPT model vs training separation** — communities 0 and 2 correctly separate model definition from training loop. Different concerns in different files; Leiden found the boundary. -- **BPETokenizer isolated** — `bpe.py` forms its own cluster, correctly identified as standalone rather than merged with model or trainer. -- **Cross-repo connections found** — the graph found that nanoGPT `Block` and minGPT `Block` share structural similarity (same class name, similar methods), creating a cross-repo INFERRED edge. This is genuine: both implement the same GPT block pattern. -- **Paper → code connections** — FlashAttention paper cluster (Community 5) connects to `CausalSelfAttention` in both nanoGPT and minGPT. NeuralWalker paper connects to graph structural concepts in micrograd. -- **Images correctly identified** — `gpt2_124M_loss.png` extracted as "val_loss=2.905 at step 399"; `gout.svg` recognized as micrograd computation graph; `moon_mlp.png` as MLP decision boundary. +- **micrograd split correctly into two communities** - engine (Value + autograd) and nn (Layer + MLP) are separate communities, matching the intended architecture split in the repo. +- **nanoGPT model vs training separation** - communities 0 and 2 correctly separate model definition from training loop. Different concerns in different files; Leiden found the boundary. +- **BPETokenizer isolated** - `bpe.py` forms its own cluster, correctly identified as standalone rather than merged with model or trainer. +- **Cross-repo connections found** - the graph found that nanoGPT `Block` and minGPT `Block` share structural similarity (same class name, similar methods), creating a cross-repo INFERRED edge. This is genuine: both implement the same GPT block pattern. +- **Paper → code connections** - FlashAttention paper cluster (Community 5) connects to `CausalSelfAttention` in both nanoGPT and minGPT. NeuralWalker paper connects to graph structural concepts in micrograd. +- **Images correctly identified** - `gpt2_124M_loss.png` extracted as "val_loss=2.905 at step 399"; `gout.svg` recognized as micrograd computation graph; `moon_mlp.png` as MLP decision boundary. ### What the graph missed or got wrong -- **Stdlib imports create 94 validation warnings** — `setuptools`, `os`, `math`, `sys` emit "target does not match any node" warnings. The AST extractor emits import edges to stdlib names before the validator can prune them. These are discarded but inflate edge count before pruning. -- **Config-only files become isolates** — `eval_gpt2.py`, `eval_gpt2_large.py` etc. are config scripts with no functions; they land as single-node communities. Expected, but adds ~36 trivial communities. -- **53 communities from 285 nodes** — the isolate problem means ~36 of 53 communities are single nodes. The "17 major communities" number from the code-only run was cleaner. The isolate handling is correct but visually noisy. -- **Papers not deep-linked to implementation** — the FlashAttention paper cluster knows about "3x GPT-2 speedup" but the graph doesn't directly link that claim to the specific `CausalSelfAttention` implementation that would benefit. That would require `--mode deep` on the paper extraction pass. +- **Stdlib imports create 94 validation warnings** - `setuptools`, `os`, `math`, `sys` emit "target does not match any node" warnings. The AST extractor emits import edges to stdlib names before the validator can prune them. These are discarded but inflate edge count before pruning. +- **Config-only files become isolates** - `eval_gpt2.py`, `eval_gpt2_large.py` etc. are config scripts with no functions; they land as single-node communities. Expected, but adds ~36 trivial communities. +- **53 communities from 285 nodes** - the isolate problem means ~36 of 53 communities are single nodes. The "17 major communities" number from the code-only run was cleaner. The isolate handling is correct but visually noisy. +- **Papers not deep-linked to implementation** - the FlashAttention paper cluster knows about "3x GPT-2 speedup" but the graph doesn't directly link that claim to the specific `CausalSelfAttention` implementation that would benefit. That would require `--mode deep` on the paper extraction pass. ### Surprising connections -- `micrograd/engine.py::Value.backward()` → `minGPT/mingpt/trainer.py::Trainer.run()` — both implement the foundational forward/backward pattern at different scales. The graph surfaces this cross-repo connection without being asked. +- `micrograd/engine.py::Value.backward()` → `minGPT/mingpt/trainer.py::Trainer.run()` - both implement the foundational forward/backward pattern at different scales. The graph surfaces this cross-repo connection without being asked. - `FlashAttention paper` (Community 5) bridges into `CausalSelfAttention` nodes in both nanoGPT and minGPT, creating the only paper→code cross-community edges in the graph. - `nanoGPT/train.py` and `minGPT/mingpt/trainer.py` land in the same community (Community 2) despite being in different repos and never importing each other. Leiden found the structural similarity through shared vocabulary (optimizer, scheduler, gradient clipping). @@ -109,7 +109,7 @@ The "attention mechanism" question returns a larger subgraph (2,836 tokens) beca ## Verdict -**71.5x token reduction** on a 92k-word mixed corpus. The reduction grows as corpus grows — on a 500k-word research library the same BFS subgraph stays ~2k tokens while naive stuffing hits 670k tokens. +**71.5x token reduction** on a 92k-word mixed corpus. The reduction grows as corpus grows - on a 500k-word research library the same BFS subgraph stays ~2k tokens while naive stuffing hits 670k tokens. Graph quality: high for code structure, strong for paper-to-concept connections (semantic extraction found the FlashAttention→CausalSelfAttention bridge), weaker on direct paper-to-implementation links (need `--mode deep` with explicit cross-file context). diff --git a/worked/mixed-corpus/review.md b/worked/mixed-corpus/review.md index 7e822d997..13370b9ab 100644 --- a/worked/mixed-corpus/review.md +++ b/worked/mixed-corpus/review.md @@ -1,4 +1,4 @@ -# Graphify Evaluation — Mixed Corpus (2026-04-04) +# Graphify Evaluation - Mixed Corpus (2026-04-04) **Evaluator:** Claude Sonnet 4.6 (live execution) **Corpus:** 3 Python files + 1 markdown paper + 1 Arabic PNG image @@ -13,7 +13,7 @@ code: [analyze.py, build.py, cluster.py] 3 files paper: [attention_notes.md] 1 file (arxiv signals detected) image: [attention_arabic.png] 1 file total: 5 files · ~4,020 words -warning: fits in a single context window (correct — corpus is small) +warning: fits in a single context window (correct - corpus is small) ``` **Finding:** `attention_notes.md` correctly classified as `paper` (not document) because it @@ -42,12 +42,12 @@ Total: 18 nodes, 19 edges → graph: 20 nodes, 19 edges (2 external deps | 1 | Clustering & Scoring | 0.29 | cluster.py, `cluster()`, `score_all()`, `cohesion_score()`, `build_graph()`, `_split_community()`, graspologic | | 2 | Graph Building | 0.50 | build.py, `build()`, `build_from_json()`, networkx | -**Finding:** Communities are semantically correct — the three graphify modules map cleanly +**Finding:** Communities are semantically correct - the three graphify modules map cleanly to their functional roles. `build.py` has the highest cohesion (0.50) because it's a tight, self-contained module. `analyze.py` is lowest (0.22) because its functions don't call each -other — each is a standalone analysis pass, making the subgraph sparse. +other - each is a standalone analysis pass, making the subgraph sparse. -**Finding:** Zero surprising connections — the three modules are structurally independent +**Finding:** Zero surprising connections - the three modules are structurally independent (no cross-file imports between them). Expected for a cleanly layered codebase. --- @@ -55,10 +55,10 @@ other — each is a standalone analysis pass, making the subgraph sparse. ## 4. Query Tests (live BFS traversal) All three queries ran against the real graph.json, returned relevant subgraphs, and were -saved to `.graphify/memory/`. +saved to `graphify-out/memory/`. ### Q1: "what does cluster do and how does it connect to build?" -- BFS from `cluster()` reached 20 nodes (full graph — small corpus) +- BFS from `cluster()` reached 20 nodes (full graph - small corpus) - `cluster.py` and `build.py` are linked via the `graspologic_partition` external dep node - Saved: `query_..._what_does_cluster_do_and_how_does_it_connect_to_bu.md` @@ -83,19 +83,19 @@ Memory files created: 3 query_..._how_does_score_all...md 1,763 bytes query_..._what_does_cluster...md 1,838 bytes -detect() on eval root with .graphify/memory/ present: +detect() on eval root with graphify-out/memory/ present: Memory files found by next scan: 3 / 3 ✓ ``` **Result: PASS.** All 3 query results appear in the next `detect()` scan. On the next -`--update`, these files will be extracted as nodes in the graph — closing the feedback loop. +`--update`, these files will be extracted as nodes in the graph - closing the feedback loop. The graph grows from what you ask, not just what you add. --- ## 6. Arabic Image OCR (via Claude vision) -**Image:** `attention_arabic.png` — Arabic notes on the Transformer paper +**Image:** `attention_arabic.png` - Arabic notes on the Transformer paper **What graphify extracts (Claude vision reads directly, no reshaper/bidi needed):** @@ -108,17 +108,17 @@ The graph grows from what you ask, not just what you add. | المحول: مكدس من 6 طبقات ترميز و6 طبقات فك ترميز | Transformer: 6 encoder + 6 decoder layers | | الترميز الموضعي | Positional encoding | | التطبيع الطبقي | Layer normalization | -| المصدر: Vaswani et al., 2017 — arXiv: 1706.03762 | Source citation | +| المصدر: Vaswani et al., 2017 - arXiv: 1706.03762 | Source citation | **Nodes graphify would extract:** -- `MultiHeadAttention` (آلية الانتباه) — hyperparameters: h=8, d_model=512, d_k=64 -- `PositionalEncoding` (الترميز الموضعي) — feeds into transformer input -- `LayerNorm` (التطبيع الطبقي) — applied per sublayer -- `Transformer` — 6 encoder + 6 decoder stack +- `MultiHeadAttention` (آلية الانتباه) - hyperparameters: h=8, d_model=512, d_k=64 +- `PositionalEncoding` (الترميز الموضعي) - feeds into transformer input +- `LayerNorm` (التطبيع الطبقي) - applied per sublayer +- `Transformer` - 6 encoder + 6 decoder stack **Key finding:** Arabic text OCR works natively via Claude vision. No preprocessing, no reshaper libraries, no bidi algorithms. The model reads Arabic, Persian, Hebrew, Chinese etc. -identically to English. The image node in graphify is just a path — the vision subagent does +identically to English. The image node in graphify is just a path - the vision subagent does the rest. --- @@ -129,7 +129,7 @@ the rest. `suggest_questions()` requires a `community_labels` dict. When called with auto-generated labels on a small corpus with no AMBIGUOUS edges and no isolated nodes, it returns an empty list. The function requires more signal (AMBIGUOUS edges, bridge nodes, underexplored god nodes) -to generate questions — correct behavior, but the skill should handle the empty case gracefully. +to generate questions - correct behavior, but the skill should handle the empty case gracefully. ### Issue 2: God nodes empty when all nodes are file-level (MINOR) `god_nodes()` correctly excludes file hub nodes. But on a 3-file corpus where the only @@ -138,7 +138,7 @@ degree-ranked nodes manually. Fix: emit a notice ("corpus too small for meaningf rather than silent empty list. ### Issue 3: 0 surprising connections on cleanly-layered code (NOT a bug) -The three modules don't import from each other — they're connected only through external deps +The three modules don't import from each other - they're connected only through external deps (networkx, graspologic). No cross-community edges means no surprises to surface. This is correct. Surprising connections require a less-cleanly-separated codebase. @@ -155,7 +155,7 @@ correct. Surprising connections require a less-cleanly-separated codebase. | Feedback loop | 10/10 | query results appear in next detect() scan, 3/3 | | Arabic OCR | 10/10 | Claude vision reads RTL Arabic natively, no libraries needed | -**Overall: 9.0/10** — strong pass on all dimensions with a small corpus. +**Overall: 9.0/10** - strong pass on all dimensions with a small corpus. Primary gaps are edge-level semantics (no INFERRED edges from AST-only) and god_nodes/ suggest_questions behavior on tiny corpora. @@ -169,8 +169,8 @@ The core pipeline is solid. The three most important findings: the next `detect()` scan and will be extracted into the graph on `--update`. 2. **Arabic OCR requires zero special handling.** PIL creates the image, Claude reads it. - The same applies to any language — no language-specific preprocessing needed. + The same applies to any language - no language-specific preprocessing needed. 3. **The corpus-size warning is working correctly.** At 4,020 words the warning fires: - "fits in a single context window — you may not need a graph." This is honest. + "fits in a single context window - you may not need a graph." This is honest. The graph adds value at scale, not on 5-file repos.