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:
Safi
2026-05-02 16:46:37 +01:00
co-authored by Jason Matthew Claude Sonnet 4.6
3 changed files with 724 additions and 15 deletions
+270 -13
View File
@@ -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
View File
@@ -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"
+452
View File
@@ -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