Skip to content

fix(llm): bound extract retry cascade to one timeout budget per chunk - #3157

Open
Ashfaqbs wants to merge 3 commits into
Graphify-Labs:v8from
Ashfaqbs:fix/extract-timeout-subtree-budget
Open

Ashfaqbs wants to merge 3 commits into
Graphify-Labs:v8from
Ashfaqbs:fix/extract-timeout-subtree-budget

Conversation

@Ashfaqbs

Copy link
Copy Markdown

Fixes #3142.

Bug

_extract_with_adaptive_retry bisects a chunk on TimeoutExpired (and other
recognized timeout errors) and retries each half. Every retry re-pays the
full GRAPHIFY_API_TIMEOUT from scratch, so a chunk that keeps timing out
across the whole cascade can burn up to 2**max_depth full timeouts —
600s x (1+2+4+8) = 9,000s (~2.5h) at the default timeout and max_depth=3.
This is most visible on --backend claude-cli, where each call spawns a full
nested claude -p session and is far slower than a plain HTTP completion, so
the default 600s is exceeded in normal use rather than as an edge case. It
reads as a hang: the only signal between "chunk N/M done" and the eventual
give-up is silence.

The recursion depth was already capped, so this isn't unbounded — it's a
bounded cascade whose bound is much larger than anyone intends.

Fix

Give the whole split subtree for one original chunk a single shared
wall-clock deadline (anchored to one GRAPHIFY_API_TIMEOUT allowance),
instead of handing every split a fresh full timeout:

  • The top-level call computes _deadline = time.monotonic() + GRAPHIFY_API_TIMEOUT
    and threads it through every recursive call in the subtree (both the
    timeout-bisection path and the truncation/length-retry path, so a later
    timeout deeper in an already-truncating subtree still respects it).
  • When a timeout fires and the shared deadline has already passed, the
    cascade gives up immediately instead of committing to another full-length
    attempt — same outcome as hitting max_depth, just reached by budget
    instead of depth.
  • Non-timeout paths (context-exceeded, hollow responses) are untouched —
    this only changes what happens after a TimeoutExpired-class exception.

Worst case is now bounded by roughly one timeout's worth of wall time per
original chunk instead of 2**max_depth timeouts.

Testing

  • Added test_adaptive_retry_stops_when_timeout_budget_exhausted: mocks
    time.monotonic() to simulate the deadline being exceeded right after the
    first timeout, and asserts the cascade gives up after exactly 1 attempt
    (previously it would have kept splitting and retrying).
  • Ran the full tests/test_llm_backends.py suite (104 tests, including the
    4 existing timeout/adaptive-retry tests) — all pass unchanged, since those
    fakes complete instantly and never approach the default 600s budget.
  • ruff check on both changed files — clean.
  • Note: I could not run the full repo test suite end-to-end in my sandbox —
    a large, pre-existing batch of unrelated failures there trace back to a
    missing tree-sitter install in my environment, reproducible on a clean
    checkout of v8 with no changes applied, not to this diff.

I used AI-assisted tooling to help navigate the codebase and draft this
change, but the diagnosis of the fix approach and the change itself were
reviewed and verified by me before opening this PR.

…hunk (Graphify-Labs#3142)

A chunk that times out at every recursion depth used to re-pay the full
GRAPHIFY_API_TIMEOUT on each of up to 2**max_depth attempts (600s x 15
attempts = up to 2.5h at the default settings for claude-cli).

Track a shared wall-clock deadline for the whole split subtree instead of
granting each split a fresh full timeout. Once the deadline passes, a
further timeout gives up immediately rather than committing to another
full-length attempt.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a shared wall-clock deadline to _extract_with_adaptive_retry so a subtree of timeout-driven splits shares one GRAPHIFY_API_TIMEOUT budget anchored at the top-level call rather than each split re-paying the full timeout. Once that deadline passes, a further timeout now gives up on the remaining chunk immediately (returning empty results with finish_reason="stop" and a stderr warning) instead of bisecting into up to 2**max_depth more full-length attempts.

Worth a look

  • Deadline anchored to _resolve_api_timeout() but each split re-pays a full timeout, so budget may never be exceeded before first splitgraphify/llm.py:2378 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 823 functions depend on the 297 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 53 callers, 13 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: _extract_with_adaptive_retry() — 23 callers, 11 callees
  • new: dispatch_command() — 2 callers, 122 callees
  • new: _call_llm() — 11 callers, 18 callees
  • …and 16 more — each is listed as a finding

Verification — 823 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 532 function(s) in the blast radius were not formally verified this run

· 24 more finding(s) on lines outside this diff (see the check run).

@Ashfaqbs
Ashfaqbs force-pushed the fix/extract-timeout-subtree-budget branch from 43c71c3 to 24e84c4 Compare August 28, 2026 06:27

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a shared wall-clock deadline to _extract_with_adaptive_retry so a chunk that keeps timing out no longer re-pays the full GRAPHIFY_API_TIMEOUT on every bisection: the top-level call anchors one budget (time.monotonic() + _resolve_api_timeout()) and all splits inherit the same absolute deadline. Once that budget is spent, a further timeout gives up on the remaining chunk — returning an empty result with finish_reason="stop" and a stderr warning — instead of spawning up to 2**max_depth more full-length attempts (previously up to ~2.5h for the 600s default at max_depth=3). Context-exceeded splits are unaffected; only timeouts honor the deadline.

Worth a look

  • Deadline check gives up before ever splitting when timeout exceeds budgetgraphify/llm.py:2378 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Expired shared deadline still allows sibling retry to startgraphify/llm.py:2422 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Expired timeout budget still allows right sibling extractiongraphify/llm.py:2422 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 823 functions depend on the 297 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 53 callers, 13 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: _extract_with_adaptive_retry() — 23 callers, 11 callees
  • new: dispatch_command() — 2 callers, 122 callees
  • new: _call_llm() — 11 callers, 18 callees
  • …and 16 more — each is listed as a finding

Verification — 823 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 532 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_extract\_with\_adaptive\_retry.

The verifier did not have enough to check \_extract\_with\_adaptive\_retry, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

· 24 more finding(s) on lines outside this diff (see the check run).

Graphify's own review bot flagged this PR: the shared subtree deadline
was only checked reactively, after a timeout occurred. A sibling split
reached after the budget was already spent elsewhere in the same
subtree would still start (and pay for) its own fresh full-length
attempt before its own reactive check caught up and gave up on it --
weakening the "one shared budget per subtree" guarantee this PR set
out to establish.

Add a proactive check at the top of each split: if the shared deadline
has already passed before this split's own attempt has even started,
skip the attempt outright instead of starting a new one the budget can
no longer afford.

(A related finding, that the very first depth-0 attempt "gives up
before ever splitting" when it alone consumes the whole budget, turned
out on investigation to be inherent to the single-timeout-budget design
rather than separately fixable: if the original attempt's own
client-side timeout reaches the full shared budget, there is by
definition no time left for any further split regardless of where in
the call tree that's decided. Not changed.)
@Ashfaqbs

Ashfaqbs commented Sep 2, 2026

Copy link
Copy Markdown
Author

Addressed the review bot's 3 findings, in 7cc0f14.

Findings #2/#3 ("expired shared deadline still allows sibling retry to start" / "right sibling extraction") — real gap, fixed. The deadline was only checked reactively, after a timeout occurred. A sibling split reached after the budget was already spent elsewhere in the same subtree would still start (and pay for) its own fresh full-length attempt before its own reactive check caught up. Added a proactive check at the top of each split: if the deadline has already passed before this split's own attempt has even started, it's skipped outright.

Finding #1 ("deadline check gives up before ever splitting when timeout exceeds budget") — investigated, but this turns out to be inherent to the single-shared-budget design rather than a separately fixable defect. If the depth-0 attempt's own client-side timeout reaches the full shared budget (which is the common case — a timeout fires at essentially its full configured duration, and the client timeout and the deadline both derive from the same GRAPHIFY_API_TIMEOUT), there is, by definition, no budget left for any further split — regardless of whether that's decided immediately at depth 0 or one level deeper. I initially implemented a depth-0 exemption to let the first attempt always try splitting, but tracing it through showed the children's own (correct) proactive checks would immediately self-terminate anyway in that exact scenario, since real wall-clock time has necessarily already reached the deadline by the time they start — so the exemption changed nothing observable, just added complexity. Left as-is.

Added test_adaptive_retry_skips_sibling_attempt_after_budget_exhausted_mid_tree, verified RED against pre-fix code (right sibling still attempts and the test's clock mock runs out, proving the old code let it through) and GREEN with the fix. Full test_llm_backends.py suite (105 tests) and ruff check clean.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a shared subtree wall-clock timeout budget to _extract_with_adaptive_retry: the top-level call anchors one GRAPHIFY_API_TIMEOUT deadline that every recursive split inherits, instead of each attempt re-paying the full timeout. Once the deadline passes, a timeout gives up on the chunk reactively (after its attempt) and a not-yet-started split is skipped proactively (before its attempt), both returning an empty result and a stderr warning. This caps the worst case for a chunk that keeps timing out at roughly one timeout rather than up to 2**max_depth of them (up to 2.5h for the 600s default at max_depth=3).

Worth a look

  • Adaptive retry deadline skips splits even without a timeoutgraphify/llm.py:2326 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Expired timeout deadline skips splits even when no timeout occurredgraphify/llm.py:2328 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 826 functions depend on the 300 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 53 callers, 13 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: _extract_with_adaptive_retry() — 24 callers, 11 callees
  • new: dispatch_command() — 2 callers, 122 callees
  • new: _call_llm() — 11 callers, 18 callees
  • …and 16 more — each is listed as a finding

Verification — 826 functions in the blast radius were not formally verified this run (proofs are advisory here).

Health delta baseline: last indexed commit 33362d9 (diverged from this PR's base — delta is approximate).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 535 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_extract\_with\_adaptive\_retry.

The verifier did not have enough to check \_extract\_with\_adaptive\_retry, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

· 24 more finding(s) on lines outside this diff (see the check run).

The proactive budget check in _extract_with_adaptive_retry skipped a
not-yet-started split purely based on wall-clock time (time.monotonic()
>= _deadline), regardless of whether any real timeout had ever happened
in the subtree. A context-exceeded or truncation ("length") split could
therefore be skipped -- returning an empty result instead of recovering
via bisection -- just because enough time had elapsed since the deadline
was anchored, contradicting the documented intent that only a real
timeout should spend the shared budget.

Thread a _timeout_hit flag (a one-element list shared by reference
across the recursion) that is only set True when a real timeout
exception is observed, and gate the proactive skip on it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhuCVHf7XKmFmUMULkczFa
@Ashfaqbs

Copy link
Copy Markdown
Author

Pushed 4b37e4d addressing the Graphify bot's advisory findings (deadline check skipping splits without a real timeout, at llm.py:2326/2328/2378/2422 across the three review passes).

Root cause: the proactive budget check (elif _depth > 0 and time.monotonic() >= _deadline) was purely wall-clock-based — it didn't verify a real timeout had ever occurred in the subtree, so a context-exceeded or truncation (length) split could be skipped outright just because enough time had elapsed since the deadline was anchored, contradicting the docstring's own claim that only real timeouts should spend the budget.

Fix: thread a _timeout_hit flag (one-element list shared by reference across the recursion) that's only set True when a real timeout exception is actually observed, and gate the proactive skip on it. Added test_adaptive_retry_deadline_only_skips_after_a_real_timeout, which fails against the pre-fix code (confirmed locally) and passes with the fix. All 137 tests in test_llm_backends.py/test_chunking.py pass.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Anchors the adaptive-retry timeout to a single shared wall-clock budget per subtree so a repeatedly-timing-out chunk can no longer re-pay the full GRAPHIFY_API_TIMEOUT on each of up to 2**max_depth splits (up to ~2.5h at the 600s/depth-3 defaults). After an attempt times out and the budget is spent, _extract_with_adaptive_retry gives up and returns an empty result instead of bisecting further; a not-yet-started sibling split is also skipped proactively once a real timeout has been observed elsewhere in the subtree (tracked via a shared _timeout_hit flag). Ordinary elapsed time from successful calls, context-exceeded splits, or truncation splits never trips the skip on its own.

No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 829 functions depend on the 303 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 53 callers, 13 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: _extract_with_adaptive_retry() — 25 callers, 11 callees
  • new: dispatch_command() — 2 callers, 122 callees
  • new: _call_llm() — 11 callers, 18 callees
  • …and 16 more — each is listed as a finding

Verification — 829 functions in the blast radius were not formally verified this run (proofs are advisory here).

Health delta baseline: last indexed commit fe66389 (diverged from this PR's base — delta is approximate).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 538 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

35 of 232 test file(s) selected (15%) via static blast radius.

  • tests/test_backend_extras.py — impact
  • tests/test_build.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_claude_cli_backend.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_file_slice.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_hyperedge_member_shapes.py — impact
  • tests/test_image_vision.py — impact
  • tests/test_label_retry.py — impact
  • tests/test_labeling.py — impact
  • tests/test_llm_backends.py — impact, changed-test
  • tests/test_llm_parser.py — impact
  • tests/test_llm_parser_reasoning.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_non_string_node_ids.py — impact
  • tests/test_ollama.py — impact
  • tests/test_ollama_retry_cap.py — impact
  • tests/test_oversized_document_slicing.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_pdf_slicing.py — impact
  • tests/test_pdf_token_estimate.py — impact
  • tests/test_provider_registry.py — impact
  • tests/test_prs.py — impact
  • tests/test_prune_sweeps_orphans.py — impact
  • tests/test_semantic_fragment_sanitize.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_extract\_with\_adaptive\_retry.

The verifier did not have enough to check \_extract\_with\_adaptive\_retry, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

· 24 more finding(s) on lines outside this diff (see the check run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

extract: a timed-out chunk re-pays the full timeout on every split, so one chunk can burn ~2.5h (claude-cli)

1 participant