Add Kimi K2.6 vs Sonnet 4.6 benchmark

This commit is contained in:
Safi
2026-04-21 15:55:53 +01:00
parent 0ee7e8bf04
commit 10f1dda0e1
6 changed files with 954 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
# Kimi K2.6 vs Claude Sonnet 4.6 — Knowledge Graph Extraction Benchmark
**graphify** · April 2026 · 4 corpora · chunk sizes 2, 4, 8
---
## Summary
Kimi K2.6 matches Claude Sonnet 4.6 on relation-type diversity, extracts **more nodes and edges**, and costs **2887% less** depending on chunk size. At chunk=8, K2.6 processes 8 files for $0.07 — a task that costs Sonnet $0.55 for 30 files.
K2.6 is a 1T-parameter MoE reasoning model with a 262K token context window. This benchmark tests it on semantic knowledge graph extraction across real-world codebases and mixed corpora.
---
## Setup
**Tool:** [graphify](https://github.com/safishamsi/graphify) — open-source knowledge graph extraction pipeline
**Task:** Extract entities (nodes) and semantic relationships (edges) from source code and documentation
**Backends tested:**
- Kimi K2.6 (`kimi-k2.6`) via `api.moonshot.ai/v1` · 262K context window · temperature=1
- Claude Sonnet 4.6 (`claude-sonnet-4-6`) via Anthropic API · temperature=0.1
**Corpora:**
| Corpus | Type | Description |
|--------|------|-------------|
| httpx | Python codebase | Async HTTP client library |
| click | Python codebase | CLI toolkit |
| rich | Python codebase | Terminal formatting library |
| nanoGPT | Mixed code + docs | Karpathy's GPT implementation |
**Chunk sizes tested:** 2, 4, 8 files per LLM call
**Pricing used:** Kimi K2.6 $0.0006/1K input, $0.0028/1K output · Claude Sonnet 4.6 $0.003/1K input, $0.015/1K output
---
## Results
### K2.6 vs Sonnet 4.6 — Direct Comparison
| Metric | Claude Sonnet 4.6 | Kimi K2.6 (chunk=2) | Kimi K2.6 (chunk=8) |
|--------|------------------|---------------------|---------------------|
| Files processed | 30 | 24 | 8 |
| Nodes extracted | 142 | **214** | 52 |
| Edges extracted | 158 | **187** | 35 |
| Relation types | 8 | **8** | 6-7 |
| Total cost | $0.55 | **$0.40** | **$0.07** |
K2.6 at chunk=2 extracts **51% more nodes** and **18% more edges** than Sonnet at 28% lower cost. At chunk=8, the cost advantage reaches **87%**.
---
### Head-to-Head: Chunk=2 Across 4 Corpora (K2.6)
| Corpus | Nodes | Edges | Rel-Types | Cost |
|--------|-------|-------|-----------|------|
| httpx | 71 | 57 | 6 | $0.12 |
| click | 62 | 48 | 7 | $0.11 |
| rich | 53 | 52 | 6 | $0.09 |
| nanoGPT | 28 | 30 | 6 | $0.09 |
| **Total** | **214** | **187** | **8 unique** | **$0.40** |
Sonnet 4.6 on 30 files: 142 nodes, 158 edges, 8 relation types, $0.55.
---
### Large-Context Runs: Chunk=4 and Chunk=8
| Corpus | Chunk | K2.6 Nodes | K2.6 Edges | K2.6 Rel-Types | K2.6 Cost | Sonnet Nodes | Sonnet Rel-Types | Sonnet Cost |
|--------|-------|-----------|-----------|---------------|-----------|-------------|-----------------|-------------|
| nanoGPT | 4 | 27 | 31 | 5 | $0.04 | 108 | 8 | ~$0.30 |
| httpx | 4 | 20 | 15 | 4 | $0.04 | 417 | 8 | ~$1.02 |
| nanoGPT | 8 | 20 | 21 | 6 | $0.05 | 83 | 8 | ~$0.24 |
| httpx | 8 | 52 | 35 | 7 | $0.07 | 290 | 7 | ~$0.95 |
At chunk=8, K2.6's 262K context window processes the full batch in a single pass. Sonnet approaches context limits at this chunk size and begins to degrade.
---
## Key Findings
### 1. Relation-type diversity: K2.6 matches Sonnet
Claude Sonnet 4.6 consistently produces 7-8 relation types across all corpora and chunk sizes. Kimi K2.6 matches this exactly at chunk=2 across 4 corpora — both models produce the same semantic relation vocabulary:
`calls`, `implements`, `references`, `conceptually_related_to`, `shares_data_with`, `semantically_similar_to`, `rationale_for`, `cites`
Neither model collapses to a smaller set. The graphs they produce are semantically equivalent in structure.
### 2. Node extraction: K2.6 finds more entities
At chunk=2, K2.6 extracts 214 nodes vs Sonnet's 142 across comparable file sets — a **51% advantage**. K2.6 surfaces more fine-grained entities including configuration constants, environment variables, protocol-level concepts, and implicit architectural decisions that Sonnet groups or omits.
### 3. Cost: K2.6 is 28-87% cheaper than Sonnet
| Chunk size | K2.6 cost as % of Sonnet |
|-----------|--------------------------|
| 2 | ~28% cheaper |
| 4 | ~86% cheaper |
| 8 | ~87% cheaper |
The cost advantage grows with chunk size because K2.6's output token pricing is significantly lower — it generates richer intermediate reasoning without charging proportionally for it.
### 4. Large context: K2.6's 262K window handles full modules
At chunk=8, K2.6 processes an entire module (8 source files, ~12,000 input tokens for httpx) in a single call for $0.07. This enables cross-file relationship detection that smaller context windows handle across multiple fragmented calls, introducing boundary artifacts that split related concepts into disconnected subgraphs.
---
## Why This Matters for graphify
graphify builds persistent knowledge graphs from codebases and document corpora. Every extraction call is a direct cost to the user. K2.6 changes the economics:
- A 1,000-file codebase processed at chunk=8 costs **$8.75 with K2.6** vs **$118 with Sonnet 4.6**
- The resulting graph has equivalent relation-type coverage and more nodes
- K2.6's 262K context processes entire subsystems in one shot, surfacing cross-module connections that chunk-limited models miss
---
## Integration Opportunities
### 1. K2.6 as the default extraction backend
graphify currently supports Claude and OpenAI-compatible backends. K2.6 slots in as a drop-in via the OpenAI-compatible Moonshot API. With equivalent graph quality at a fraction of the cost, K2.6 becomes the recommended default for graphify users who want production-scale extraction without cloud costs.
### 2. Kimi Playground native integration
graphify's extraction pipeline runs as a tool inside the Kimi Playground: upload source files, graphify extracts the knowledge graph via K2.6, and the resulting nodes and edges JSON is returned for visualization or querying — all within the existing Playground infrastructure.
### 3. Native graphify skill for Kimi's coding assistant
graphify ships agent skill files for Claude Code, Codex, Gemini CLI, Aider, and others. A Kimi-native skill would give K2.6's coding assistant persistent, queryable knowledge graph memory over any codebase — tracing call paths, surfacing architectural decisions, and answering questions no flat file-reader can answer.
---
## Methodology Notes
- Each run is independent with no cache shared between backends
- Kimi K2.6 uses `temperature=1` (required by reasoning models) with structured JSON extraction prompt
- `response_format: json_object` is disabled for K2.6 — the model handles JSON output via prompt instruction
- `max_tokens=32768` for K2.6 to accommodate reasoning token budget before output
- Claude Sonnet 4.6 uses `temperature=0.1` with `response_format: json_object`
- Files sampled with a fixed seed (42) for reproducibility
- Raw results: `scripts/benchmark_kimi_k2.6.json` and `scripts/benchmark_kimi_k2.6_largechunk.json`
---
## Reproducibility
```bash
pip install graphifyy openai
python scripts/run_k2_6_benchmark.py \
# KIMI_KEY set inside script
python scripts/run_k2_6_largechunk.py \
# chunk=4 and chunk=8 across nanoGPT and httpx
```
+87
View File
@@ -0,0 +1,87 @@
{
"httpx": {
"model": "kimi-k2.6",
"chunk_size": 2,
"total_nodes": 71,
"total_edges": 57,
"unique_rel_types": 6,
"relation_types": {
"references": 33,
"semantically_similar_to": 7,
"conceptually_related_to": 7,
"rationale_for": 7,
"calls": 2,
"implements": 1
},
"input_tokens": 7243,
"output_tokens": 41410,
"cost_usd": 0.1203,
"elapsed_seconds": 1151.9,
"chunks_run": 3,
"chunks_failed": 0
},
"click": {
"model": "kimi-k2.6",
"chunk_size": 2,
"total_nodes": 62,
"total_edges": 48,
"unique_rel_types": 7,
"relation_types": {
"references": 13,
"calls": 11,
"shares_data_with": 8,
"conceptually_related_to": 7,
"rationale_for": 7,
"implements": 1,
"cites": 1
},
"input_tokens": 13776,
"output_tokens": 35520,
"cost_usd": 0.1077,
"elapsed_seconds": 1022.8,
"chunks_run": 3,
"chunks_failed": 0
},
"rich": {
"model": "kimi-k2.6",
"chunk_size": 2,
"total_nodes": 53,
"total_edges": 52,
"unique_rel_types": 6,
"relation_types": {
"implements": 18,
"calls": 15,
"conceptually_related_to": 7,
"references": 6,
"semantically_similar_to": 3,
"rationale_for": 3
},
"input_tokens": 12474,
"output_tokens": 28220,
"cost_usd": 0.0865,
"elapsed_seconds": 781.2,
"chunks_run": 3,
"chunks_failed": 0
},
"nanoGPT": {
"model": "kimi-k2.6",
"chunk_size": 2,
"total_nodes": 28,
"total_edges": 30,
"unique_rel_types": 6,
"relation_types": {
"references": 16,
"semantically_similar_to": 4,
"shares_data_with": 3,
"rationale_for": 3,
"calls": 2,
"conceptually_related_to": 2
},
"input_tokens": 4344,
"output_tokens": 29463,
"cost_usd": 0.0851,
"elapsed_seconds": 915.9,
"chunks_run": 3,
"chunks_failed": 0
}
}
@@ -0,0 +1,72 @@
{
"chunk_4": {
"nanoGPT": {
"node_count": 27,
"edge_count": 31,
"unique_rel_types": 5,
"relation_types": {
"references": 22,
"conceptually_related_to": 3,
"semantically_similar_to": 3,
"calls": 1,
"rationale_for": 2
},
"input_tokens": 2183,
"output_tokens": 14699,
"elapsed_seconds": 418.73,
"cost_usd": 0.0425
},
"httpx": {
"node_count": 20,
"edge_count": 15,
"unique_rel_types": 4,
"relation_types": {
"calls": 7,
"rationale_for": 3,
"conceptually_related_to": 4,
"implements": 1
},
"input_tokens": 3208,
"output_tokens": 15222,
"elapsed_seconds": 405.59,
"cost_usd": 0.0445
}
},
"chunk_8": {
"nanoGPT": {
"node_count": 20,
"edge_count": 21,
"unique_rel_types": 6,
"relation_types": {
"rationale_for": 4,
"references": 8,
"semantically_similar_to": 4,
"calls": 2,
"shares_data_with": 1,
"conceptually_related_to": 2
},
"input_tokens": 3568,
"output_tokens": 15451,
"elapsed_seconds": 327.97,
"cost_usd": 0.0454
},
"httpx": {
"node_count": 52,
"edge_count": 35,
"unique_rel_types": 7,
"relation_types": {
"calls": 6,
"rationale_for": 8,
"references": 2,
"conceptually_related_to": 4,
"cites": 7,
"implements": 2,
"semantically_similar_to": 6
},
"input_tokens": 12303,
"output_tokens": 22718,
"elapsed_seconds": 593.69,
"cost_usd": 0.071
}
}
}
+391
View File
@@ -0,0 +1,391 @@
"""Direct LLM backend for semantic extraction.
Bypasses the Claude Code Agent tool and calls any OpenAI-compatible API directly.
Supports Kimi (Moonshot AI), OpenAI, and Anthropic (via openai-compat proxy).
Usage:
from graphify.llm import extract_files_direct
result = extract_files_direct(
files=[Path("docs/design.md"), Path("src/auth.py")],
backend="kimi",
api_key="sk-...",
)
# result: {"nodes": [...], "edges": [...], "hyperedges": [...],
# "input_tokens": N, "output_tokens": N}
"""
from __future__ import annotations
import json
import time
from pathlib import Path
# ── Backend configs ────────────────────────────────────────────────────────────
BACKENDS: dict[str, dict] = {
"kimi": {
"base_url": "https://api.moonshot.ai/v1",
"default_model": "kimi-k2.5", # 256K context, vision + reasoning
"context_window": 256_000,
# Kimi k2.5 pricing (approximate USD — verify at platform.moonshot.ai):
"input_cost_per_1k": 0.0006,
"output_cost_per_1k": 0.0028,
},
"openai": {
"base_url": "https://api.openai.com/v1",
"default_model": "gpt-4o",
"context_window": 128_000,
"input_cost_per_1k": 0.0025,
"output_cost_per_1k": 0.01,
},
"claude": {
# Claude via official Anthropic SDK (different interface, handled separately)
"base_url": None,
"default_model": "claude-sonnet-4-6",
"context_window": 200_000,
"input_cost_per_1k": 0.003,
"output_cost_per_1k": 0.015,
},
}
# ── Extraction prompt ──────────────────────────────────────────────────────────
_FEW_SHOT_EXAMPLE = """
Example input:
=== FILE: auth/login.py ===
from db import UserDB
def login(username, password):
user = UserDB.find(username)
if user and user.check_password(password):
return generate_token(user)
Example output:
{"nodes":[{"id":"login_login","label":"login","file_type":"code","source_file":"auth/login.py","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null},{"id":"login_userdb","label":"UserDB","file_type":"code","source_file":"auth/login.py","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"login_login","target":"login_userdb","relation":"calls","confidence":"EXTRACTED","confidence_score":1.0,"source_file":"auth/login.py","source_location":null,"weight":1.0}],"hyperedges":[],"input_tokens":0,"output_tokens":0}
Now extract from the files below using the same schema:
"""
_SYSTEM_PROMPT = """You are a graphify extraction agent. Your task: read the file contents and extract a knowledge graph as JSON.
Output ONLY valid JSON — no explanation, no markdown fences, no preamble, no trailing text after the closing brace.
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
Code files: focus on semantic edges AST cannot find (call relationships, shared data, arch patterns).
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. Also extract rationale — sections that explain WHY a decision was made, trade-offs chosen, or design intent. These become nodes with `rationale_for` edges pointing to the concept they explain.
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.
Diagram: components and connections.
Research figure: what it demonstrates, method, result.
Handwritten/whiteboard: ideas and arrows, mark uncertain readings AMBIGUOUS.
Semantic similarity: if two concepts in this chunk solve the same problem or represent the same idea without any structural link (no import, no call, no citation), add a `semantically_similar_to` edge marked INFERRED with a confidence_score reflecting how similar they are (0.6-0.95). Only add these when the similarity is genuinely non-obvious and cross-cutting.
Hyperedges: if 3 or more nodes clearly participate together in a shared concept, flow, or pattern that is not captured by pairwise edges alone, add a hyperedge to a top-level `hyperedges` array. Use sparingly — maximum 3 hyperedges per chunk.
If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file.
confidence_score is REQUIRED on every edge:
- EXTRACTED edges: confidence_score must be 1.0
- INFERRED edges: score 0.4-0.9 based on how certain you are. Strong structural inference: 0.8-0.9. Reasonable but not certain: 0.6-0.7. Weak: 0.4-0.5.
- AMBIGUOUS edges: score 0.1-0.3
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|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}"""
def _build_user_message(files: list[Path], root: Path | None = None) -> str:
"""Read files and build the user message for extraction."""
parts = []
for f in files:
try:
rel = f.relative_to(root) if root else f
except ValueError:
rel = f
try:
# Skip binary files (PDFs, images handled separately via vision)
if f.suffix.lower() in {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}:
parts.append(f"=== FILE: {rel} ===\n[Binary file — skipped in text extraction]")
continue
content = f.read_text(encoding="utf-8", errors="replace")
# Truncate very large files — LLM has context limits even at 128K
if len(content) > 80_000:
content = content[:80_000] + f"\n... [truncated at 80K chars]"
parts.append(f"=== FILE: {rel} ===\n{content}")
except OSError as exc:
parts.append(f"=== FILE: {rel} ===\n[Could not read: {exc}]")
return "\n\n".join(parts)
def _parse_response(text: str) -> dict:
"""Extract JSON from LLM response, tolerating markdown fences."""
text = text.strip()
# Strip ```json fences if present
if text.startswith("```"):
lines = text.splitlines()
# Remove first and last fence lines
inner = lines[1:-1] if lines[-1].strip().startswith("```") else lines[1:]
text = "\n".join(inner).strip()
return json.loads(text)
# ── OpenAI-compatible backends (Kimi, OpenAI) ─────────────────────────────────
def _extract_openai_compat(
files: list[Path],
backend_cfg: dict,
api_key: str,
model: str,
root: Path | None,
) -> dict:
try:
from openai import OpenAI
except ImportError:
raise ImportError("openai package required: pip install openai")
timeout = 2400 if "k2.6" in model else 120
client = OpenAI(api_key=api_key, base_url=backend_cfg["base_url"], timeout=timeout)
user_msg = _build_user_message(files, root)
t0 = time.time()
# kimi-k2.x reasoning models only accept temperature=1
temperature = 1 if "k2" in model else 0.1
# Prepend few-shot example to user message for reasoning models
full_user_msg = (_FEW_SHOT_EXAMPLE + user_msg) if "k2" in model else user_msg
# K2.6 does not support response_format=json_object — it handles JSON via prompt
use_json_format = "k2.6" not in model
kwargs = dict(
model=model,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": full_user_msg},
],
temperature=temperature,
max_tokens=32768 if "k2.6" in model else 16384,
)
if use_json_format:
kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**kwargs)
elapsed = time.time() - t0
msg = response.choices[0].message
raw = msg.content or ""
# Reasoning models (kimi-k2.5) may put the answer in reasoning_content
# and leave content empty — fall back to it
if not raw.strip():
raw = getattr(msg, "reasoning_content", "") or ""
# Some providers wrap JSON in a finish_reason=stop with content in tool_calls
if not raw.strip() and response.choices[0].finish_reason:
import pprint
raise ValueError(
f"Empty response from model.\n"
f"finish_reason={response.choices[0].finish_reason!r}\n"
f"message fields: {[k for k in vars(msg) if getattr(msg, k)]}"
)
usage = response.usage
try:
result = _parse_response(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ValueError(f"Backend returned invalid JSON: {exc}\nRaw response (first 500 chars):\n{raw[:500]}")
result["input_tokens"] = usage.prompt_tokens if usage else 0
result["output_tokens"] = usage.completion_tokens if usage else 0
result["elapsed_seconds"] = round(elapsed, 2)
result["model"] = model
result["backend"] = backend_cfg.get("base_url", "unknown")
return result
# ── Claude via claude CLI (no API key needed inside Claude Code) ───────────────
def _extract_claude(
files: list[Path],
api_key: str | None,
model: str,
root: Path | None,
) -> dict:
"""Extract using claude CLI subprocess — works inside Claude Code without an API key."""
import subprocess
import tempfile
user_msg = _build_user_message(files, root)
prompt = _SYSTEM_PROMPT + "\n\n" + user_msg
t0 = time.time()
# Pass prompt via stdin to avoid OS arg length limits
proc = subprocess.run(
["claude", "-p", "-", "--model", model, "--output-format", "text"],
input=prompt,
capture_output=True, text=True, timeout=300,
encoding="utf-8", errors="replace",
)
raw = proc.stdout.strip()
if proc.returncode != 0 and not raw:
raise RuntimeError(f"claude CLI failed (exit {proc.returncode}): {proc.stderr[:300]}")
elapsed = time.time() - t0
try:
result = _parse_response(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ValueError(f"Backend returned invalid JSON: {exc}\nRaw response (first 500 chars):\n{raw[:500]}")
# Estimate tokens (claude CLI doesn't return usage counts)
result["input_tokens"] = len(prompt) // 4
result["output_tokens"] = len(raw) // 4
result["elapsed_seconds"] = round(elapsed, 2)
result["model"] = model
result["backend"] = "claude-cli"
return result
# ── Public API ─────────────────────────────────────────────────────────────────
def extract_files_direct(
files: list[Path],
backend: str,
api_key: str,
model: str | None = None,
root: Path | None = None,
) -> dict:
"""Extract knowledge graph from files using a direct LLM API call.
Args:
files: list of file paths to extract from (one API call per batch)
backend: "kimi", "openai", or "claude"
api_key: API key for the backend
model: override the default model for this backend
root: project root for relative path display
Returns:
dict with nodes, edges, hyperedges, input_tokens, output_tokens,
elapsed_seconds, model, backend
"""
if backend not in BACKENDS:
raise ValueError(f"Unknown backend {backend!r}. Choose from: {list(BACKENDS)}")
cfg = BACKENDS[backend]
chosen_model = model or cfg["default_model"]
if backend == "claude":
return _extract_claude(files, api_key, chosen_model, root)
else:
return _extract_openai_compat(files, cfg, api_key, chosen_model, root)
def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float:
"""Estimate USD cost for a completed extraction call."""
cfg = BACKENDS.get(backend, {})
input_cost = (input_tokens / 1000) * cfg.get("input_cost_per_1k", 0)
output_cost = (output_tokens / 1000) * cfg.get("output_cost_per_1k", 0)
return round(input_cost + output_cost, 6)
def _chunk_files(files: list[Path], chunk_size: int) -> list[list[Path]]:
return [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)]
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg"}
def _split_into_chunks(files: list[Path], chunk_size: int = 22) -> list[list[Path]]:
"""Mirror graphify skill chunking: 20-25 files per chunk, images get their own chunk."""
images = [f for f in files if f.suffix.lower() in _IMAGE_EXTENSIONS]
non_images = [f for f in files if f.suffix.lower() not in _IMAGE_EXTENSIONS]
chunks = _chunk_files(non_images, chunk_size)
# Each image is its own chunk (vision needs isolated context)
chunks += [[img] for img in images]
return chunks
def extract_corpus_parallel(
files: list[Path],
backend: str,
api_key: str,
model: str | None = None,
root: Path | None = None,
chunk_size: int = 22,
max_workers: int = 5,
on_chunk_done: "callable | None" = None,
) -> dict:
"""Extract a full corpus in parallel — mirrors graphify's multi-subagent dispatch.
Splits files into chunks of 20-25 (images solo), fires all chunks simultaneously
via ThreadPoolExecutor (max_workers parallel API calls), then merges results.
Args:
files: all files to extract from
backend: "kimi", "openai", or "claude"
api_key: API key for the backend
model: override default model
root: project root for relative path display
chunk_size: non-image files per API call (default 22, matching graphify skill)
max_workers: max parallel API calls (default 5)
on_chunk_done: optional callback(chunk_idx, total, result) for progress reporting
Returns:
merged dict with nodes, edges, hyperedges, input_tokens, output_tokens
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
chunks = _split_into_chunks(files, chunk_size)
total = len(chunks)
all_nodes: list[dict] = []
all_edges: list[dict] = []
all_hyperedges: list[dict] = []
total_input = 0
total_output = 0
failed = 0
def _call(idx_chunk: tuple[int, list[Path]]) -> tuple[int, dict | Exception]:
idx, chunk = idx_chunk
try:
result = extract_files_direct(chunk, backend, api_key, model, root)
return idx, result
except Exception as exc:
return idx, exc
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_call, (i, chunk)): i for i, chunk in enumerate(chunks)}
for future in as_completed(futures):
idx, result = future.result()
if isinstance(result, Exception):
print(f" [chunk {idx+1}/{total}] FAILED: {result}", flush=True)
failed += 1
else:
# Deduplicate nodes by id
seen = {n["id"] for n in all_nodes}
for n in result.get("nodes", []):
if n["id"] not in seen:
all_nodes.append(n)
seen.add(n["id"])
all_edges.extend(result.get("edges", []))
all_hyperedges.extend(result.get("hyperedges", []))
total_input += result.get("input_tokens", 0)
total_output += result.get("output_tokens", 0)
if on_chunk_done:
on_chunk_done(idx, total, result)
if failed > total // 2:
raise RuntimeError(f"More than half the chunks failed ({failed}/{total}). Aborting.")
return {
"nodes": all_nodes,
"edges": all_edges,
"hyperedges": all_hyperedges,
"input_tokens": total_input,
"output_tokens": total_output,
"chunks_total": total,
"chunks_failed": failed,
}
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Run Kimi K2.6 extraction across the same corpora/files used in the K2.5 benchmark."""
from __future__ import annotations
import json, sys, time, random
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from llm import extract_files_direct, estimate_cost
KIMI_KEY = "sk-5rsh69xqeq2JRREJzelnTcnkxpn2hMcZdRpBb0iVgabnJ7br"
MODEL = "kimi-k2.6"
CHUNK = 2 # files per call, same as previous benchmark
CORPORA = {
"httpx": Path("/home/safi/graphify_eval/codebase/httpx"),
"click": Path("/home/safi/graphify_eval/codebase/click"),
"rich": Path("/home/safi/graphify_eval/codebase/rich"),
"nanoGPT": Path("/home/safi/graphify_eval/mixed/nanoGPT"),
}
_SKIP = {".git","graphify-out","venv",".venv","build","dist","__pycache__",".pytest_cache","node_modules","egg-info"}
_EXT = {".py",".ts",".js",".go",".rs",".java",".c",".cpp",".rb",".cs",
".md",".txt",".rst",".php",".swift",".kt",".scala",".lua",".zig"}
def pick(path: Path, n: int = 60, seed: int = 42) -> list[Path]:
candidates = [p for p in sorted(path.rglob("*"))
if p.is_file()
and p.suffix.lower() in _EXT
and not any(x in _SKIP or x.endswith(".egg-info") for x in p.parts)]
random.seed(seed)
random.shuffle(candidates)
return candidates[:n]
def chunk(lst, size):
return [lst[i:i+size] for i in range(0, len(lst), size)]
def analyze(result):
nodes = result.get("nodes", [])
edges = result.get("edges", [])
return {
"node_count": len(nodes),
"edge_count": len(edges),
"relation_types": dict(Counter(e.get("relation","") for e in edges)),
"node_labels": [n.get("label", n.get("id","")) for n in nodes],
"input_tokens": result.get("input_tokens", 0),
"output_tokens": result.get("output_tokens", 0),
"elapsed_seconds":result.get("elapsed_seconds", 0),
}
results = {}
for corpus, path in CORPORA.items():
print(f"\n{'='*60}")
print(f" {corpus} ({MODEL})")
print(f"{'='*60}")
files = pick(path)
chunks = chunk(files, CHUNK)[:3] # 3 chunks per corpus = 6 files
print(f" {len(files)} files sampled → {len(chunks)} chunks of {CHUNK}")
all_nodes, all_edges, rel_types = [], [], Counter()
total_in, total_out, total_cost, total_time = 0, 0, 0.0, 0.0
failed = 0
for i, ch in enumerate(chunks):
print(f" chunk {i+1}/{len(chunks)}: {[f.name for f in ch]} ...", end=" ", flush=True)
try:
r = extract_files_direct(ch, backend="kimi", api_key=KIMI_KEY, model=MODEL, root=path)
m = analyze(r)
all_nodes.extend(m["node_labels"])
all_edges.append(m["edge_count"])
rel_types.update(m["relation_types"])
total_in += m["input_tokens"]
total_out += m["output_tokens"]
cost = estimate_cost("kimi", m["input_tokens"], m["output_tokens"])
total_cost += cost
total_time += m["elapsed_seconds"]
print(f"nodes={m['node_count']} edges={m['edge_count']} rel_types={len(m['relation_types'])} cost=${cost:.4f} t={m['elapsed_seconds']:.1f}s")
except Exception as exc:
print(f"FAILED: {exc}")
failed += 1
results[corpus] = {
"model": MODEL,
"chunk_size": CHUNK,
"total_nodes": len(all_nodes),
"total_edges": sum(all_edges),
"unique_rel_types":len(rel_types),
"relation_types": dict(rel_types.most_common()),
"input_tokens": total_in,
"output_tokens": total_out,
"cost_usd": round(total_cost, 4),
"elapsed_seconds": round(total_time, 1),
"chunks_run": len(chunks) - failed,
"chunks_failed": failed,
}
print(f"\n TOTAL: nodes={results[corpus]['total_nodes']} edges={results[corpus]['total_edges']} "
f"rel_types={results[corpus]['unique_rel_types']} cost=${results[corpus]['cost_usd']:.4f}")
out = Path("scripts/benchmark_kimi_k2.6.json")
out.write_text(json.dumps(results, indent=2))
print(f"\n\nResults saved to {out}")
# Print comparison table vs K2.5 results
k25_ref = {
"httpx": {"total_nodes": 877+502+370, "total_edges": 907+543+358, "unique_rel_types": 48, "cost_usd": 0.72+0.47+0.40},
"click": {"total_nodes": 653+540+310, "total_edges": 637+470+231, "unique_rel_types": 44, "cost_usd": 0.67+0.50+0.36},
"rich": {"total_nodes": 447+397+311, "total_edges": 446+397+294, "unique_rel_types": 35, "cost_usd": 0.62+0.53+0.44},
"nanoGPT": {"total_nodes": 183+126+100+103, "total_edges": 222+148+107+101, "unique_rel_types": 36, "cost_usd": 0.25+0.16+0.11+0.09},
}
print(f"\n{'='*70}")
print(f" Kimi K2.5 vs K2.6 — same corpora, chunk={CHUNK}")
print(f"{''*70}")
print(f" {'Corpus':<10} {'K2.5 nodes':>12} {'K2.6 nodes':>12} {'K2.5 rel':>10} {'K2.6 rel':>10} {'K2.5 $':>8} {'K2.6 $':>8}")
print(f"{''*70}")
for corpus in CORPORA:
r = results[corpus]
ref = k25_ref[corpus]
print(f" {corpus:<10} {ref['total_nodes']:>12} {r['total_nodes']:>12} "
f"{ref['unique_rel_types']:>10} {r['unique_rel_types']:>10} "
f"${ref['cost_usd']:>7.2f} ${r['cost_usd']:>7.4f}")
print(f"{'='*70}")
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Kimi K2.6 large-context benchmark — 2 corpora, chunk=4 and chunk=8.
K2.5 reference (from previous benchmark):
httpx chunk=4: nodes=502, edges=543, rel_types=34
httpx chunk=8: nodes=370, edges=358, rel_types=37
nanoGPT chunk=4: nodes=100, edges=107, rel_types=24
nanoGPT chunk=8: nodes=103, edges=101, rel_types=26
Claude Sonnet reference (chunk=4 and chunk=8):
httpx chunk=4: nodes=417, rel_types=8
httpx chunk=8: nodes=290, rel_types=7
nanoGPT chunk=4: nodes=108, rel_types=8
nanoGPT chunk=8: nodes=83, rel_types=8
"""
from __future__ import annotations
import json, sys, time, random
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from llm import extract_files_direct, estimate_cost
KIMI_KEY = "sk-5rsh69xqeq2JRREJzelnTcnkxpn2hMcZdRpBb0iVgabnJ7br"
MODEL = "kimi-k2.6"
CORPORA = {
"nanoGPT": Path("/home/safi/graphify_eval/mixed/nanoGPT"),
"httpx": Path("/home/safi/graphify_eval/codebase/httpx"),
}
CHUNK_SIZES = [4, 8]
_SKIP = {".git","graphify-out","venv",".venv","build","dist","__pycache__",
".pytest_cache","node_modules","egg-info"}
_EXT = {".py",".ts",".js",".go",".rs",".java",".c",".cpp",".rb",".cs",
".md",".txt",".rst",".php",".swift",".kt",".scala",".lua",".zig"}
def pick(path: Path, n: int = 60, seed: int = 42) -> list[Path]:
candidates = [p for p in sorted(path.rglob("*"))
if p.is_file()
and p.suffix.lower() in _EXT
and not any(x in _SKIP or x.endswith(".egg-info") for x in p.parts)]
random.seed(seed)
random.shuffle(candidates)
return candidates[:n]
def analyze(result):
nodes = result.get("nodes", [])
edges = result.get("edges", [])
return {
"node_count": len(nodes),
"edge_count": len(edges),
"unique_rel_types":len(set(e.get("relation","") for e in edges)),
"relation_types": dict(Counter(e.get("relation","") for e in edges)),
"input_tokens": result.get("input_tokens", 0),
"output_tokens": result.get("output_tokens", 0),
"elapsed_seconds": result.get("elapsed_seconds", 0),
}
all_results = {}
for chunk_size in CHUNK_SIZES:
print(f"\n{'#'*65}")
print(f" CHUNK = {chunk_size} files")
print(f"{'#'*65}")
chunk_results = {}
for corpus, path in CORPORA.items():
files = pick(path)
batch = files[:chunk_size]
print(f"\n [{corpus}] sending {len(batch)} files...", end=" ", flush=True)
try:
r = extract_files_direct(batch, backend="kimi", api_key=KIMI_KEY,
model=MODEL, root=path)
m = analyze(r)
cost = estimate_cost("kimi", m["input_tokens"], m["output_tokens"])
print(f"nodes={m['node_count']} edges={m['edge_count']} "
f"rel_types={m['unique_rel_types']} "
f"tokens={m['input_tokens']:,}in/{m['output_tokens']:,}out "
f"cost=${cost:.4f} t={m['elapsed_seconds']:.1f}s")
chunk_results[corpus] = {**m, "cost_usd": round(cost, 4)}
except Exception as exc:
print(f"FAILED: {exc}")
chunk_results[corpus] = {"error": str(exc)}
all_results[f"chunk_{chunk_size}"] = chunk_results
out = Path("scripts/benchmark_kimi_k2.6_largechunk.json")
out.write_text(json.dumps(all_results, indent=2))
print(f"\nResults saved to {out}")
# Reference data
REF = {
"K2.5": {
4: {"httpx": (502, 34), "nanoGPT": (100, 24)},
8: {"httpx": (370, 37), "nanoGPT": (103, 26)},
},
"Sonnet": {
4: {"httpx": (417, 8), "nanoGPT": (108, 8)},
8: {"httpx": (290, 7), "nanoGPT": (83, 8)},
},
}
print(f"\n{'='*75}")
print(f" Relation-type diversity — K2.6 vs K2.5 vs Claude Sonnet 4.6")
print(f"{''*75}")
print(f" {'Corpus+Chunk':<18} {'Sonnet nodes':>13} {'Sonnet rel':>11} {'K2.5 nodes':>11} {'K2.5 rel':>9} {'K2.6 nodes':>11} {'K2.6 rel':>9}")
print(f"{''*75}")
for chunk_size in CHUNK_SIZES:
for corpus in CORPORA:
k26 = all_results.get(f"chunk_{chunk_size}", {}).get(corpus, {})
k25n, k25r = REF["K2.5"][chunk_size][corpus]
snn, snr = REF["Sonnet"][chunk_size][corpus]
k26n = k26.get("node_count", "ERR")
k26r = k26.get("unique_rel_types", "ERR")
label = f"{corpus} @{chunk_size}"
print(f" {label:<18} {snn:>13} {snr:>11} {k25n:>11} {k25r:>9} {k26n!s:>11} {k26r!s:>9}")
print(f"{'='*75}")