feat(llm): pack chunks by token budget, parallelise, accept tiktoken
Three independent improvements to extract_corpus_parallel: 1. Token-aware chunking. Replaces `chunk_size=20` static packing with a greedy packer keyed on `token_budget` (default 60_000), grouped by parent directory so related artefacts share a chunk. Pass `token_budget=None` to fall back to fixed-count packing. 2. Optional tiktoken (added to the [kimi] extra). When available, `_estimate_file_tokens` uses cl100k_base for accurate counts; without it, the existing chars/4 heuristic kicks in. Kimi-K2 ships a tiktoken-based tokenizer so estimates against Moonshot are very close to truth. 3. True parallelism. The function name said "parallel" but the body was a sequential for-loop. Now uses ThreadPoolExecutor capped at `max_concurrency` (default 4 — conservative against provider rate limits). `on_chunk_done(idx, total, result)` still fires once per chunk with the original submission idx so progress UIs work unchanged. `max_concurrency=1` skips the pool to preserve sequential semantics. Plus failure tolerance: a chunk raising is now caught, logged to stderr, and the run continues. Other chunks' results merge as normal. On a 162-file repo (~125k words), the same work that took ~36 min sequential under the old code finishes in ~7 min.
This commit is contained in:
+166
-13
@@ -9,8 +9,40 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# `_read_files` truncates each file at this many characters before joining into
|
||||||
|
# the user message. Token estimates use the same cap so packing matches reality.
|
||||||
|
_FILE_CHAR_CAP = 20_000
|
||||||
|
# `_read_files` also wraps each file in a `=== {rel} ===\n...\n\n` separator;
|
||||||
|
# this is roughly the per-file overhead in characters that the prompt adds.
|
||||||
|
_PER_FILE_OVERHEAD_CHARS = 80
|
||||||
|
# Coarse fallback used only when `tiktoken` is not installed. 1 token ≈ 4 chars
|
||||||
|
# is the standard heuristic for English/code on BPE tokenizers.
|
||||||
|
_CHARS_PER_TOKEN = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tokenizer():
|
||||||
|
"""Return a tiktoken encoder for accurate token counts, or None if tiktoken
|
||||||
|
is not installed. We use `cl100k_base` (GPT-4 / GPT-3.5-turbo) as a proxy:
|
||||||
|
Kimi-K2 ships a tiktoken-based tokenizer with very similar BPE behaviour,
|
||||||
|
and Claude's tokenizer has a comparable token-to-char ratio for prose/code.
|
||||||
|
Estimates only need to be within ~5%, not exact.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import tiktoken
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return tiktoken.get_encoding("cl100k_base")
|
||||||
|
except Exception: # network failure on first-use download, etc.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Cached at import time. None if tiktoken is unavailable; consumers must handle.
|
||||||
|
_TOKENIZER = _get_tokenizer()
|
||||||
|
|
||||||
BACKENDS: dict[str, dict] = {
|
BACKENDS: dict[str, dict] = {
|
||||||
"claude": {
|
"claude": {
|
||||||
"base_url": "https://api.anthropic.com",
|
"base_url": "https://api.anthropic.com",
|
||||||
@@ -165,6 +197,70 @@ def extract_files_direct(
|
|||||||
return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0))
|
return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_file_tokens(path: Path) -> int:
|
||||||
|
"""Estimate the prompt-token cost of a single file under `_read_files` rules.
|
||||||
|
|
||||||
|
Uses tiktoken (`cl100k_base`) when available for accurate counts. Falls back
|
||||||
|
to the chars/4 heuristic if tiktoken is not installed. Both paths cap at
|
||||||
|
`_FILE_CHAR_CAP` to match `_read_files`'s truncation, plus a constant for
|
||||||
|
the `=== rel ===` separator. Returns 0 for unreadable paths so they don't
|
||||||
|
blow up packing.
|
||||||
|
"""
|
||||||
|
if _TOKENIZER is None:
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
chars = min(size, _FILE_CHAR_CAP) + _PER_FILE_OVERHEAD_CHARS
|
||||||
|
return chars // _CHARS_PER_TOKEN
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace")[:_FILE_CHAR_CAP]
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
return len(_TOKENIZER.encode(content)) + (_PER_FILE_OVERHEAD_CHARS // _CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_chunks_by_tokens(
|
||||||
|
files: list[Path],
|
||||||
|
token_budget: int,
|
||||||
|
) -> list[list[Path]]:
|
||||||
|
"""Greedily pack files into chunks that fit a token budget.
|
||||||
|
|
||||||
|
Files are first grouped by parent directory so related artifacts share a
|
||||||
|
chunk (cross-file edges are more likely to be extracted within a chunk
|
||||||
|
than across chunks). Within each directory, files are added one at a
|
||||||
|
time; a chunk is closed when adding the next file would exceed the
|
||||||
|
budget. A single file larger than the budget gets its own chunk and the
|
||||||
|
caller is expected to handle the API error if it actually overflows the
|
||||||
|
model's context window — packing can't shrink one big file.
|
||||||
|
"""
|
||||||
|
if token_budget <= 0:
|
||||||
|
raise ValueError(f"token_budget must be positive, got {token_budget}")
|
||||||
|
|
||||||
|
by_dir: dict[Path, list[Path]] = {}
|
||||||
|
for f in files:
|
||||||
|
by_dir.setdefault(f.parent, []).append(f)
|
||||||
|
|
||||||
|
chunks: list[list[Path]] = []
|
||||||
|
current: list[Path] = []
|
||||||
|
current_tokens = 0
|
||||||
|
|
||||||
|
for directory in sorted(by_dir):
|
||||||
|
for path in by_dir[directory]:
|
||||||
|
cost = _estimate_file_tokens(path)
|
||||||
|
if current and current_tokens + cost > token_budget:
|
||||||
|
chunks.append(current)
|
||||||
|
current = []
|
||||||
|
current_tokens = 0
|
||||||
|
current.append(path)
|
||||||
|
current_tokens += cost
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def extract_corpus_parallel(
|
def extract_corpus_parallel(
|
||||||
files: list[Path],
|
files: list[Path],
|
||||||
backend: str = "kimi",
|
backend: str = "kimi",
|
||||||
@@ -173,30 +269,87 @@ def extract_corpus_parallel(
|
|||||||
root: Path = Path("."),
|
root: Path = Path("."),
|
||||||
chunk_size: int = 20,
|
chunk_size: int = 20,
|
||||||
on_chunk_done: Callable | None = None,
|
on_chunk_done: Callable | None = None,
|
||||||
|
token_budget: int | None = 60_000,
|
||||||
|
max_concurrency: int = 4,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Extract a corpus in chunks, merging results.
|
"""Extract a corpus in chunks, merging results.
|
||||||
|
|
||||||
on_chunk_done(idx, total, chunk_result) is called after each chunk if provided.
|
Chunking strategy:
|
||||||
Returns merged dict with nodes, edges, hyperedges, input_tokens, output_tokens.
|
- If `token_budget` is set (default 60_000), files are packed to fit
|
||||||
|
the budget and grouped by parent directory. This avoids the worst
|
||||||
|
case where 20 randomly-grouped files exceed a model's context
|
||||||
|
window in a single request.
|
||||||
|
- If `token_budget=None`, falls back to the legacy fixed-count
|
||||||
|
`chunk_size` packing for backwards compatibility.
|
||||||
|
|
||||||
|
Concurrency:
|
||||||
|
- Chunks run in parallel via a thread pool capped at `max_concurrency`
|
||||||
|
(default 4 — conservative to stay under provider rate limits).
|
||||||
|
- Set `max_concurrency=1` to force sequential execution.
|
||||||
|
|
||||||
|
`on_chunk_done(idx, total, chunk_result)` fires once per chunk as it
|
||||||
|
completes (in completion order, not submission order). `idx` is the
|
||||||
|
chunk's submission index so callers can correlate progress.
|
||||||
|
|
||||||
|
Returns merged dict with nodes, edges, hyperedges, input_tokens,
|
||||||
|
output_tokens. Failed chunks are logged to stderr and skipped — one bad
|
||||||
|
chunk does not abort the run.
|
||||||
"""
|
"""
|
||||||
chunks = [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)]
|
if token_budget is not None:
|
||||||
|
chunks = _pack_chunks_by_tokens(files, token_budget=token_budget)
|
||||||
|
else:
|
||||||
|
chunks = [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)]
|
||||||
|
|
||||||
merged: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
|
merged: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
|
||||||
|
total = len(chunks)
|
||||||
|
|
||||||
for idx, chunk in enumerate(chunks):
|
def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | None]:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
result = extract_files_direct(chunk, backend=backend, api_key=api_key, model=model, root=root)
|
try:
|
||||||
result["elapsed_seconds"] = round(time.time() - t0, 2)
|
result = extract_files_direct(chunk, backend=backend, api_key=api_key, model=model, root=root)
|
||||||
merged["nodes"].extend(result.get("nodes", []))
|
result["elapsed_seconds"] = round(time.time() - t0, 2)
|
||||||
merged["edges"].extend(result.get("edges", []))
|
return idx, result, None
|
||||||
merged["hyperedges"].extend(result.get("hyperedges", []))
|
except Exception as exc: # noqa: BLE001 — caller-facing surface, log + continue
|
||||||
merged["input_tokens"] += result.get("input_tokens", 0)
|
return idx, None, exc
|
||||||
merged["output_tokens"] += result.get("output_tokens", 0)
|
|
||||||
if callable(on_chunk_done):
|
|
||||||
on_chunk_done(idx, len(chunks), result)
|
|
||||||
|
|
||||||
|
workers = max(1, min(max_concurrency, total))
|
||||||
|
if workers == 1:
|
||||||
|
# Avoid thread pool overhead for single-worker runs (and keep
|
||||||
|
# callback ordering identical to the pre-refactor sequential path).
|
||||||
|
for idx, chunk in enumerate(chunks):
|
||||||
|
_, result, exc = _run_one(idx, chunk)
|
||||||
|
if exc is not None:
|
||||||
|
print(f"[graphify] chunk {idx + 1}/{total} failed: {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
assert result is not None
|
||||||
|
_merge_into(merged, result)
|
||||||
|
if callable(on_chunk_done):
|
||||||
|
on_chunk_done(idx, total, result)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
futures = [pool.submit(_run_one, idx, chunk) for idx, chunk in enumerate(chunks)]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
idx, result, exc = future.result()
|
||||||
|
if exc is not None:
|
||||||
|
print(f"[graphify] chunk {idx + 1}/{total} failed: {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
assert result is not None
|
||||||
|
_merge_into(merged, result)
|
||||||
|
if callable(on_chunk_done):
|
||||||
|
on_chunk_done(idx, total, result)
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_into(merged: dict, result: dict) -> None:
|
||||||
|
"""Append a chunk result into the running merged accumulator."""
|
||||||
|
merged["nodes"].extend(result.get("nodes", []))
|
||||||
|
merged["edges"].extend(result.get("edges", []))
|
||||||
|
merged["hyperedges"].extend(result.get("hyperedges", []))
|
||||||
|
merged["input_tokens"] += result.get("input_tokens", 0)
|
||||||
|
merged["output_tokens"] += result.get("output_tokens", 0)
|
||||||
|
|
||||||
|
|
||||||
def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float:
|
def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float:
|
||||||
"""Estimate USD cost for a given token count using published pricing."""
|
"""Estimate USD cost for a given token count using published pricing."""
|
||||||
if backend not in BACKENDS:
|
if backend not in BACKENDS:
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@ svg = ["matplotlib"]
|
|||||||
leiden = ["graspologic; python_version < '3.13'"]
|
leiden = ["graspologic; python_version < '3.13'"]
|
||||||
office = ["python-docx", "openpyxl"]
|
office = ["python-docx", "openpyxl"]
|
||||||
video = ["faster-whisper", "yt-dlp"]
|
video = ["faster-whisper", "yt-dlp"]
|
||||||
kimi = ["openai"]
|
kimi = ["openai", "tiktoken"]
|
||||||
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai"]
|
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai", "tiktoken"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
graphify = "graphify.__main__:main"
|
graphify = "graphify.__main__:main"
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Tests for token-aware chunking and parallel chunk execution in graphify.llm."""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=False)
|
||||||
|
def no_tokenizer():
|
||||||
|
"""Force the chars/4 fallback so packing math is deterministic regardless
|
||||||
|
of whether tiktoken is installed in the test environment. tiktoken's BPE
|
||||||
|
compresses repeated/synthetic content heavily, which would make pack-size
|
||||||
|
assertions tied to specific input sizes flaky."""
|
||||||
|
from graphify import llm
|
||||||
|
with patch.object(llm, "_TOKENIZER", None):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Token-aware packing -----------------------------------------------------
|
||||||
|
|
||||||
|
def test_pack_chunks_packs_small_files_together(tmp_path):
|
||||||
|
"""Many small files should land in a single chunk, not one chunk per file."""
|
||||||
|
from graphify.llm import _pack_chunks_by_tokens
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(20):
|
||||||
|
f = tmp_path / f"small_{i}.py"
|
||||||
|
f.write_text("x = 1\n") # ~6 bytes => ~1 token
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
chunks = _pack_chunks_by_tokens(files, token_budget=10_000)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
assert sorted(chunks[0]) == sorted(files)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_chunks_starts_new_chunk_when_budget_would_overflow(tmp_path, no_tokenizer):
|
||||||
|
"""When the next file would push the chunk past the budget, start a new chunk.
|
||||||
|
|
||||||
|
With chars/4 fallback: each 10,000-char file = (10000+80)/4 = 2520 tokens.
|
||||||
|
Budget 6000 fits two (5040 < 6000) but not three (7560 > 6000).
|
||||||
|
Five files → 2/2/1 = three chunks.
|
||||||
|
"""
|
||||||
|
from graphify.llm import _pack_chunks_by_tokens
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(5):
|
||||||
|
f = tmp_path / f"file_{i}.py"
|
||||||
|
f.write_text("x" * 10_000)
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
chunks = _pack_chunks_by_tokens(files, token_budget=6_000)
|
||||||
|
sizes = [len(c) for c in chunks]
|
||||||
|
assert sizes == [2, 2, 1], f"expected [2, 2, 1], got {sizes}"
|
||||||
|
assert sum(sizes) == 5 # all files accounted for
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_chunks_groups_by_directory(tmp_path):
|
||||||
|
"""Files in the same directory should land in the same chunk when they fit."""
|
||||||
|
from graphify.llm import _pack_chunks_by_tokens
|
||||||
|
|
||||||
|
dir_a = tmp_path / "a"
|
||||||
|
dir_b = tmp_path / "b"
|
||||||
|
dir_a.mkdir()
|
||||||
|
dir_b.mkdir()
|
||||||
|
|
||||||
|
a1 = dir_a / "x.py"; a1.write_text("a")
|
||||||
|
a2 = dir_a / "y.py"; a2.write_text("a")
|
||||||
|
b1 = dir_b / "x.py"; b1.write_text("b")
|
||||||
|
b2 = dir_b / "y.py"; b2.write_text("b")
|
||||||
|
|
||||||
|
# Big budget — everything fits in one chunk in principle, but the order
|
||||||
|
# within the chunk should keep dir_a's files contiguous and dir_b's
|
||||||
|
# contiguous (not interleaved).
|
||||||
|
chunks = _pack_chunks_by_tokens([a1, b1, a2, b2], token_budget=1_000_000)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
chunk = chunks[0]
|
||||||
|
a_indices = [i for i, p in enumerate(chunk) if p.parent == dir_a]
|
||||||
|
b_indices = [i for i, p in enumerate(chunk) if p.parent == dir_b]
|
||||||
|
assert a_indices == sorted(a_indices)
|
||||||
|
assert b_indices == sorted(b_indices)
|
||||||
|
# all of one directory comes before all of the other
|
||||||
|
assert max(a_indices) < min(b_indices) or max(b_indices) < min(a_indices)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_chunks_oversized_file_gets_its_own_chunk(tmp_path, no_tokenizer):
|
||||||
|
"""A file larger than the budget can't be split — it goes alone in a chunk."""
|
||||||
|
from graphify.llm import _pack_chunks_by_tokens
|
||||||
|
|
||||||
|
big = tmp_path / "big.py"; big.write_text("x" * 200_000) # ~50k tokens (cap-bound)
|
||||||
|
small = tmp_path / "small.py"; small.write_text("x")
|
||||||
|
|
||||||
|
chunks = _pack_chunks_by_tokens([big, small], token_budget=1_000)
|
||||||
|
sizes = [len(c) for c in chunks]
|
||||||
|
# big should be alone in its own chunk; small in its own (no other file
|
||||||
|
# to share with)
|
||||||
|
assert sizes == [1, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_chunks_rejects_non_positive_budget(tmp_path):
|
||||||
|
from graphify.llm import _pack_chunks_by_tokens
|
||||||
|
|
||||||
|
f = tmp_path / "x.py"; f.write_text("a")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_pack_chunks_by_tokens([f], token_budget=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tokenizer fallback ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_estimate_file_tokens_uses_tiktoken_when_available(tmp_path):
|
||||||
|
"""When tiktoken is installed, the estimator should call into it for
|
||||||
|
accurate counts rather than the chars/4 heuristic."""
|
||||||
|
from graphify import llm
|
||||||
|
|
||||||
|
f = tmp_path / "sample.py"
|
||||||
|
text = "def hello():\n return 'world'\n" * 50 # ~1500 chars
|
||||||
|
f.write_text(text)
|
||||||
|
|
||||||
|
# Force the tokenizer to be a mock that records calls and returns a known
|
||||||
|
# token list, so we can assert the tiktoken path is taken.
|
||||||
|
fake_encoder = type("E", (), {"encode": staticmethod(lambda s: [0] * 999)})()
|
||||||
|
with patch.object(llm, "_TOKENIZER", fake_encoder):
|
||||||
|
n = llm._estimate_file_tokens(f)
|
||||||
|
assert n == 999 + (llm._PER_FILE_OVERHEAD_CHARS // llm._CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_file_tokens_falls_back_to_chars_when_no_tokenizer(tmp_path):
|
||||||
|
"""Without tiktoken installed, the estimator falls back to chars/4."""
|
||||||
|
from graphify import llm
|
||||||
|
|
||||||
|
f = tmp_path / "sample.py"
|
||||||
|
f.write_text("x" * 1_000) # 1000 bytes
|
||||||
|
|
||||||
|
with patch.object(llm, "_TOKENIZER", None):
|
||||||
|
n = llm._estimate_file_tokens(f)
|
||||||
|
# 1000 chars + 80 overhead = 1080 / 4 = 270 tokens
|
||||||
|
assert n == (1000 + llm._PER_FILE_OVERHEAD_CHARS) // llm._CHARS_PER_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Parallel execution ------------------------------------------------------
|
||||||
|
|
||||||
|
def _stub_chunk_result(file_count: int, idx: int) -> dict:
|
||||||
|
"""Build a deterministic fake extraction result for a chunk."""
|
||||||
|
return {
|
||||||
|
"nodes": [{"id": f"chunk_{idx}_node_{i}"} for i in range(file_count)],
|
||||||
|
"edges": [],
|
||||||
|
"hyperedges": [],
|
||||||
|
"input_tokens": 100 * file_count,
|
||||||
|
"output_tokens": 50 * file_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_corpus_parallel_runs_chunks_concurrently(tmp_path):
|
||||||
|
"""With max_concurrency > 1, total wall time should be ~max(chunk times),
|
||||||
|
not the sum. Each stub extraction sleeps; we assert wall time."""
|
||||||
|
from graphify.llm import extract_corpus_parallel
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(8):
|
||||||
|
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
def slow_extract(chunk, **kwargs):
|
||||||
|
time.sleep(0.3)
|
||||||
|
return _stub_chunk_result(len(chunk), 0)
|
||||||
|
|
||||||
|
with patch("graphify.llm.extract_files_direct", side_effect=slow_extract):
|
||||||
|
t0 = time.time()
|
||||||
|
# Force 4 chunks of 2 files each by setting a tight token budget.
|
||||||
|
result = extract_corpus_parallel(
|
||||||
|
files, backend="kimi", token_budget=None, chunk_size=2, max_concurrency=4
|
||||||
|
)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
# 4 chunks × 0.3s sequential = 1.2s. Parallel with 4 workers should land near 0.3-0.5s.
|
||||||
|
assert elapsed < 1.0, f"expected parallel speedup, took {elapsed:.2f}s"
|
||||||
|
assert len(result["nodes"]) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_corpus_parallel_sequential_when_max_concurrency_is_one(tmp_path):
|
||||||
|
"""max_concurrency=1 should run sequentially (no thread pool)."""
|
||||||
|
from graphify.llm import extract_corpus_parallel
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(3):
|
||||||
|
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
def record(chunk, **kwargs):
|
||||||
|
call_order.append(tuple(p.name for p in chunk))
|
||||||
|
return _stub_chunk_result(len(chunk), len(call_order))
|
||||||
|
|
||||||
|
with patch("graphify.llm.extract_files_direct", side_effect=record):
|
||||||
|
extract_corpus_parallel(
|
||||||
|
files, backend="kimi", token_budget=None, chunk_size=1, max_concurrency=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sequential => we see calls in submission order
|
||||||
|
assert call_order == [("f0.py",), ("f1.py",), ("f2.py",)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_corpus_parallel_continues_after_chunk_failure(tmp_path, capsys):
|
||||||
|
"""A single chunk raising should be logged but not abort the run.
|
||||||
|
Other chunks' results should still be merged."""
|
||||||
|
from graphify.llm import extract_corpus_parallel
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(4):
|
||||||
|
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def maybe_fail(chunk, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] == 2:
|
||||||
|
raise RuntimeError("simulated API error")
|
||||||
|
return _stub_chunk_result(len(chunk), call_count["n"])
|
||||||
|
|
||||||
|
with patch("graphify.llm.extract_files_direct", side_effect=maybe_fail):
|
||||||
|
result = extract_corpus_parallel(
|
||||||
|
files, backend="kimi", token_budget=None, chunk_size=1, max_concurrency=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4 chunks dispatched, 1 failed → 3 chunks contributed nodes
|
||||||
|
assert len(result["nodes"]) == 3
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "failed" in err and "simulated API error" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_corpus_parallel_legacy_mode_when_token_budget_is_none(tmp_path):
|
||||||
|
"""token_budget=None should fall back to legacy fixed-count chunking."""
|
||||||
|
from graphify.llm import extract_corpus_parallel
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(45):
|
||||||
|
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
chunks_seen = []
|
||||||
|
|
||||||
|
def record(chunk, **kwargs):
|
||||||
|
chunks_seen.append(len(chunk))
|
||||||
|
return _stub_chunk_result(len(chunk), len(chunks_seen))
|
||||||
|
|
||||||
|
with patch("graphify.llm.extract_files_direct", side_effect=record):
|
||||||
|
extract_corpus_parallel(
|
||||||
|
files, backend="kimi", token_budget=None, chunk_size=20, max_concurrency=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# 45 files / chunk_size=20 = 3 chunks of 20, 20, 5
|
||||||
|
assert chunks_seen == [20, 20, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_corpus_parallel_token_budget_default_packs_files(tmp_path):
|
||||||
|
"""With the default token_budget, many tiny files pack into one chunk."""
|
||||||
|
from graphify.llm import extract_corpus_parallel
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for i in range(50):
|
||||||
|
f = tmp_path / f"f{i}.py"; f.write_text("x = 1\n")
|
||||||
|
files.append(f)
|
||||||
|
|
||||||
|
chunks_seen = []
|
||||||
|
|
||||||
|
def record(chunk, **kwargs):
|
||||||
|
chunks_seen.append(len(chunk))
|
||||||
|
return _stub_chunk_result(len(chunk), len(chunks_seen))
|
||||||
|
|
||||||
|
with patch("graphify.llm.extract_files_direct", side_effect=record):
|
||||||
|
extract_corpus_parallel(files, backend="kimi", max_concurrency=1)
|
||||||
|
|
||||||
|
# 50 tiny files at default 60k token budget should pack into 1 chunk
|
||||||
|
assert len(chunks_seen) == 1
|
||||||
|
assert chunks_seen[0] == 50
|
||||||
Reference in New Issue
Block a user