merge PR #625: token-aware chunking with split-and-retry on truncation
Co-Authored-By: Jason Matthew <jasonm4130@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+270
-13
@@ -9,8 +9,40 @@ import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
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] = {
|
||||
"claude": {
|
||||
"base_url": "https://api.anthropic.com",
|
||||
@@ -110,6 +142,10 @@ def _call_openai_compat(
|
||||
result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0
|
||||
result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0
|
||||
result["model"] = model
|
||||
# `finish_reason == "length"` means the model hit max_completion_tokens
|
||||
# mid-generation. The JSON we got back is truncated; callers should
|
||||
# treat this as a signal to retry with smaller input.
|
||||
result["finish_reason"] = resp.choices[0].finish_reason
|
||||
return result
|
||||
|
||||
|
||||
@@ -134,6 +170,10 @@ def _call_claude(api_key: str, model: str, user_message: str) -> dict:
|
||||
result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0
|
||||
result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0
|
||||
result["model"] = model
|
||||
# Normalise Anthropic's `stop_reason` to the OpenAI-compat `finish_reason`
|
||||
# vocabulary so the adaptive-retry layer doesn't have to know which
|
||||
# backend produced the result.
|
||||
result["finish_reason"] = "length" if resp.stop_reason == "max_tokens" else "stop"
|
||||
return result
|
||||
|
||||
|
||||
@@ -168,6 +208,147 @@ def extract_files_direct(
|
||||
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_with_adaptive_retry(
|
||||
chunk: list[Path],
|
||||
backend: str,
|
||||
api_key: str | None,
|
||||
model: str | None,
|
||||
root: Path,
|
||||
max_depth: int,
|
||||
_depth: int = 0,
|
||||
) -> dict:
|
||||
"""Extract a chunk; if the response is truncated (`finish_reason="length"`),
|
||||
split the chunk in half and recurse.
|
||||
|
||||
The signal driving the retry is the API's own `finish_reason` — `"length"`
|
||||
means the model hit `max_completion_tokens` mid-output. The truncated JSON
|
||||
has nothing useful in it (parse fails partway through a string or array),
|
||||
so we discard it and re-extract on smaller inputs that produce shorter
|
||||
outputs.
|
||||
|
||||
Recursion is capped at `max_depth` to bound worst-case cost. A chunk of N
|
||||
files can split into up to 2**max_depth pieces — at depth=3 that's 8x. If
|
||||
still truncated at the cap, we surface the (likely empty) result with a
|
||||
warning rather than infinite-loop.
|
||||
|
||||
A single-file chunk that truncates is unrecoverable here — we can't make
|
||||
one file smaller than itself, so we return what we got and warn.
|
||||
"""
|
||||
result = extract_files_direct(
|
||||
chunk, backend=backend, api_key=api_key, model=model, root=root
|
||||
)
|
||||
|
||||
if result.get("finish_reason") != "length":
|
||||
return result
|
||||
|
||||
if len(chunk) <= 1:
|
||||
print(
|
||||
f"[graphify] single-file chunk {chunk[0]} truncated at "
|
||||
f"max_completion_tokens — partial result kept",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return result
|
||||
|
||||
if _depth >= max_depth:
|
||||
print(
|
||||
f"[graphify] chunk of {len(chunk)} still truncated at recursion "
|
||||
f"depth {_depth} (max {max_depth}) — partial result kept",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return result
|
||||
|
||||
print(
|
||||
f"[graphify] chunk of {len(chunk)} truncated at depth {_depth}, "
|
||||
f"splitting into halves of {len(chunk) // 2} and "
|
||||
f"{len(chunk) - len(chunk) // 2}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
mid = len(chunk) // 2
|
||||
left = _extract_with_adaptive_retry(
|
||||
chunk[:mid], backend, api_key, model, root, max_depth, _depth + 1
|
||||
)
|
||||
right = _extract_with_adaptive_retry(
|
||||
chunk[mid:], backend, api_key, model, root, max_depth, _depth + 1
|
||||
)
|
||||
|
||||
return {
|
||||
"nodes": left.get("nodes", []) + right.get("nodes", []),
|
||||
"edges": left.get("edges", []) + right.get("edges", []),
|
||||
"hyperedges": left.get("hyperedges", []) + right.get("hyperedges", []),
|
||||
"input_tokens": left.get("input_tokens", 0) + right.get("input_tokens", 0),
|
||||
"output_tokens": left.get("output_tokens", 0) + right.get("output_tokens", 0),
|
||||
"model": result.get("model"),
|
||||
# Both halves either succeeded or have already surfaced their own
|
||||
# truncation warning; the merged result is no longer truncated as a
|
||||
# logical unit.
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
|
||||
|
||||
def extract_corpus_parallel(
|
||||
files: list[Path],
|
||||
backend: str = "kimi",
|
||||
@@ -176,30 +357,106 @@ def extract_corpus_parallel(
|
||||
root: Path = Path("."),
|
||||
chunk_size: int = 20,
|
||||
on_chunk_done: Callable | None = None,
|
||||
token_budget: int | None = 60_000,
|
||||
max_concurrency: int = 4,
|
||||
max_retry_depth: int = 3,
|
||||
) -> dict:
|
||||
"""Extract a corpus in chunks, merging results.
|
||||
|
||||
on_chunk_done(idx, total, chunk_result) is called after each chunk if provided.
|
||||
Returns merged dict with nodes, edges, hyperedges, input_tokens, output_tokens.
|
||||
Chunking strategy:
|
||||
- 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.
|
||||
|
||||
Adaptive retry on truncation:
|
||||
- When the LLM returns `finish_reason="length"` (output truncated at
|
||||
`max_completion_tokens`), the chunk is split in half and each half
|
||||
re-extracted recursively, up to `max_retry_depth` levels deep
|
||||
(default 3 → max 8x expansion of one chunk).
|
||||
- This is signal-driven: chunks too dense to fit in one response
|
||||
self-heal by splitting until they do, while well-sized chunks pay
|
||||
no extra cost. Set `max_retry_depth=0` to disable retries.
|
||||
|
||||
`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. The
|
||||
callback fires once per top-level chunk; recursive splits are merged
|
||||
transparently before the callback is invoked.
|
||||
|
||||
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}
|
||||
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()
|
||||
result = extract_files_direct(chunk, backend=backend, api_key=api_key, model=model, root=root)
|
||||
result["elapsed_seconds"] = round(time.time() - t0, 2)
|
||||
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)
|
||||
if callable(on_chunk_done):
|
||||
on_chunk_done(idx, len(chunks), result)
|
||||
try:
|
||||
result = _extract_with_adaptive_retry(
|
||||
chunk,
|
||||
backend=backend,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
root=root,
|
||||
max_depth=max_retry_depth,
|
||||
)
|
||||
result["elapsed_seconds"] = round(time.time() - t0, 2)
|
||||
return idx, result, None
|
||||
except Exception as exc: # noqa: BLE001 — caller-facing surface, log + continue
|
||||
return idx, None, exc
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Estimate USD cost for a given token count using published pricing."""
|
||||
if backend not in BACKENDS:
|
||||
|
||||
+2
-2
@@ -50,10 +50,10 @@ svg = ["matplotlib"]
|
||||
leiden = ["graspologic; python_version < '3.13'"]
|
||||
office = ["python-docx", "openpyxl"]
|
||||
video = ["faster-whisper", "yt-dlp"]
|
||||
kimi = ["openai"]
|
||||
kimi = ["openai", "tiktoken"]
|
||||
sql = ["tree-sitter-sql"]
|
||||
vbnet = ["tree-sitter-vbnet"]
|
||||
all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai", "tree-sitter-sql", "tree-sitter-vbnet"]
|
||||
all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai", "tiktoken", "tree-sitter-sql", "tree-sitter-vbnet"]
|
||||
|
||||
[project.scripts]
|
||||
graphify = "graphify.__main__:main"
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ---- Adaptive retry on truncation -------------------------------------------
|
||||
|
||||
def _stub_with_finish(file_count: int, finish_reason: str = "stop") -> dict:
|
||||
"""Build a stub extraction result with a controllable finish_reason."""
|
||||
return {
|
||||
"nodes": [{"id": f"n_{i}"} for i in range(file_count)],
|
||||
"edges": [],
|
||||
"hyperedges": [],
|
||||
"input_tokens": 100 * file_count,
|
||||
"output_tokens": 50 * file_count,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
|
||||
|
||||
def test_adaptive_retry_returns_directly_when_not_truncated(tmp_path):
|
||||
"""No retry when finish_reason='stop' — single call, result passes through."""
|
||||
from graphify.llm import _extract_with_adaptive_retry
|
||||
|
||||
files = [tmp_path / f"f{i}.py" for i in range(4)]
|
||||
for f in files:
|
||||
f.write_text("x")
|
||||
|
||||
calls = []
|
||||
|
||||
def stub(chunk, **kwargs):
|
||||
calls.append(len(chunk))
|
||||
return _stub_with_finish(len(chunk), finish_reason="stop")
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=stub):
|
||||
result = _extract_with_adaptive_retry(
|
||||
files, backend="kimi", api_key=None, model=None, root=tmp_path, max_depth=3
|
||||
)
|
||||
|
||||
assert calls == [4], f"expected 1 call of 4 files, got {calls}"
|
||||
assert len(result["nodes"]) == 4
|
||||
|
||||
|
||||
def test_adaptive_retry_splits_when_finish_reason_length(tmp_path):
|
||||
"""finish_reason='length' triggers split-in-half. Both halves succeed
|
||||
on the second try (mocked) and results merge."""
|
||||
from graphify.llm import _extract_with_adaptive_retry
|
||||
|
||||
files = [tmp_path / f"f{i}.py" for i in range(4)]
|
||||
for f in files:
|
||||
f.write_text("x")
|
||||
|
||||
calls = []
|
||||
|
||||
def stub(chunk, **kwargs):
|
||||
calls.append(len(chunk))
|
||||
finish = "length" if len(chunk) == 4 else "stop"
|
||||
return _stub_with_finish(len(chunk), finish_reason=finish)
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=stub):
|
||||
result = _extract_with_adaptive_retry(
|
||||
files, backend="kimi", api_key=None, model=None, root=tmp_path, max_depth=3
|
||||
)
|
||||
|
||||
assert calls == [4, 2, 2], f"expected [4, 2, 2], got {calls}"
|
||||
assert len(result["nodes"]) == 4
|
||||
assert result["finish_reason"] == "stop"
|
||||
|
||||
|
||||
def test_adaptive_retry_recurses_for_persistent_truncation(tmp_path):
|
||||
"""When even the half-chunk truncates, split again. With 8 files and a
|
||||
truncation cutoff at >2 files, splits 8 → 4 → 2 (4 leaves of 2)."""
|
||||
from graphify.llm import _extract_with_adaptive_retry
|
||||
|
||||
files = [tmp_path / f"f{i}.py" for i in range(8)]
|
||||
for f in files:
|
||||
f.write_text("x")
|
||||
|
||||
calls = []
|
||||
|
||||
def stub(chunk, **kwargs):
|
||||
calls.append(len(chunk))
|
||||
finish = "length" if len(chunk) > 2 else "stop"
|
||||
return _stub_with_finish(len(chunk), finish_reason=finish)
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=stub):
|
||||
result = _extract_with_adaptive_retry(
|
||||
files, backend="kimi", api_key=None, model=None, root=tmp_path, max_depth=3
|
||||
)
|
||||
|
||||
# Tree: 8 (trunc) → 4 + 4 (both trunc) → 2+2+2+2 (all stop)
|
||||
# Total calls: 1 + 2 + 4 = 7
|
||||
assert sorted(calls) == [2, 2, 2, 2, 4, 4, 8]
|
||||
assert len(result["nodes"]) == 8
|
||||
|
||||
|
||||
def test_adaptive_retry_caps_at_max_depth(tmp_path, capsys):
|
||||
"""If everything truncates, retries stop at max_depth — partial result
|
||||
kept with a warning, no infinite loop."""
|
||||
from graphify.llm import _extract_with_adaptive_retry
|
||||
|
||||
files = [tmp_path / f"f{i}.py" for i in range(8)]
|
||||
for f in files:
|
||||
f.write_text("x")
|
||||
|
||||
calls = []
|
||||
|
||||
def always_truncate(chunk, **kwargs):
|
||||
calls.append(len(chunk))
|
||||
return _stub_with_finish(len(chunk), finish_reason="length")
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=always_truncate):
|
||||
_extract_with_adaptive_retry(
|
||||
files, backend="kimi", api_key=None, model=None, root=tmp_path, max_depth=2
|
||||
)
|
||||
|
||||
# max_depth=2 bounds the tree: root + 2 + 4 = 7 calls maximum
|
||||
assert len(calls) <= 7, f"recursion not bounded — {len(calls)} calls"
|
||||
err = capsys.readouterr().err
|
||||
assert "still truncated" in err
|
||||
|
||||
|
||||
def test_adaptive_retry_single_file_truncation_does_not_recurse(tmp_path, capsys):
|
||||
"""A single file that truncates can't be split further — surface a
|
||||
warning and return what we got. No infinite loop."""
|
||||
from graphify.llm import _extract_with_adaptive_retry
|
||||
|
||||
f = tmp_path / "huge.py"; f.write_text("x")
|
||||
|
||||
calls = []
|
||||
|
||||
def stub(chunk, **kwargs):
|
||||
calls.append(len(chunk))
|
||||
return _stub_with_finish(len(chunk), finish_reason="length")
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=stub):
|
||||
_extract_with_adaptive_retry(
|
||||
[f], backend="kimi", api_key=None, model=None, root=tmp_path, max_depth=3
|
||||
)
|
||||
|
||||
assert calls == [1], f"single-file chunk recursed; calls = {calls}"
|
||||
err = capsys.readouterr().err
|
||||
assert "single-file chunk" in err and "truncated" in err
|
||||
|
||||
|
||||
def test_corpus_parallel_uses_adaptive_retry(tmp_path):
|
||||
"""End-to-end: extract_corpus_parallel routes through adaptive retry,
|
||||
so a chunk that truncates gets split and merged transparently before
|
||||
on_chunk_done fires."""
|
||||
from graphify.llm import extract_corpus_parallel
|
||||
|
||||
files = [tmp_path / f"f{i}.py" for i in range(4)]
|
||||
for f in files:
|
||||
f.write_text("x")
|
||||
|
||||
calls = []
|
||||
|
||||
def stub(chunk, **kwargs):
|
||||
calls.append(len(chunk))
|
||||
finish = "length" if len(chunk) == 4 else "stop"
|
||||
return _stub_with_finish(len(chunk), finish_reason=finish)
|
||||
|
||||
chunk_done_args = []
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=stub):
|
||||
result = extract_corpus_parallel(
|
||||
files,
|
||||
backend="kimi",
|
||||
token_budget=None,
|
||||
chunk_size=4,
|
||||
max_concurrency=1,
|
||||
on_chunk_done=lambda i, t, r: chunk_done_args.append((i, t, len(r["nodes"]))),
|
||||
)
|
||||
|
||||
# Adaptive retry runs INSIDE _run_one: 4 → 2 + 2 = 3 underlying API calls
|
||||
assert calls == [4, 2, 2]
|
||||
# User-visible: 1 chunk completion (the merged result)
|
||||
assert len(chunk_done_args) == 1
|
||||
assert chunk_done_args[0] == (0, 1, 4)
|
||||
assert len(result["nodes"]) == 4
|
||||
Reference in New Issue
Block a user