Skip to content

feat: add an opt-in raise mode for LLM errors in the pipeline stages - #63

Merged
radum2275 merged 1 commit into
IBM:mainfrom
Thanatos9404:raise-batch-errors
Oct 15, 2026
Merged

radum2275 merged 1 commit into
IBM:mainfrom
Thanatos9404:raise-batch-errors

Conversation

@Thanatos9404

Copy link
Copy Markdown
Contributor

Fixes #61

Related: #62 (stage speedups) touches the same files; happy to rebase on whichever lands first.

Problem

When an LLM call fails, each stage prints the error and carries on with a default result:

Stage Printed line Result used instead
Atomizer [Atomizer] Generation failed: ... / Batch item failed: ... {} (no atoms)
Reviser [Reviser] Batch item failed: ... the atom, unrevised
NLIExtractor [NLI] Generation failed: ... / Batch item failed: ... neutral, probability 1.0
ContextSummarizer [Summarizer] Batch item failed: ... empty summary, so the context is dropped

AtomizerReviser and NLIAdapterExtractor do the same. Nothing reaches the caller, so a script cannot tell that a score was computed from fallbacks, or retry.

Change

  • New keyword-only argument raise_errors: bool = False on Atomizer, Reviser, NLIExtractor, ContextSummarizer and AtomizerReviser. NLIAdapterExtractor passes it through its **kwargs.
  • One helper, handle_generation_error() in fact_reasoner/utils.py, replaces the print plus count_fallback pair in all seven places. By default it prints the same line and counts the same generation_error fallback as today. With raise_errors=True it re-raises the exception it was given. It is the same object, so the type, traceback and __cause__ are kept and except openai.APIConnectionError (or whatever the backend raised) works.
  • Stage batches: run_throttled is unchanged and still collects every item, then the stage raises the error of the first failed item in input order. I did not add early cancellation there, so run_throttled (also used by the LCS miner and NLI training) stays as it is. Happy to add it if you prefer failing fast.
  • ContextRetriever: in the runner pipeline, contexts are summarized inside the retrieval workers, and retrieve_all logs any worker error as "Retrieval failed" and skips the atom, which would swallow the summarizer's error again. When the summarizer's raise_errors is True, its error is now handed back to retrieve_all, which cancels the atoms still queued (so they make no more search, fetch or summarizer calls) and re-raises it; atoms already running finish first. Retrieval errors, and summarizer errors in any other mode, keep their log-and-skip behaviour.
  • FactualityRunner(raise_errors=...) passes the option to the atomizer, reviser, NLI and summarizer stages, the same way show_progress is passed. The CLI gets --raise-errors because assess_file resumes by skipping inputs already in its output file, including ones scored from fallbacks during an outage; with the flag the run stops instead, and the item is retried on restart. The README has a new row under "Common options" and a short "Handling LLM errors" section.

Only errors raised by the backend call are affected. Unsuccessful sampling (validation) and unparsable output keep their fallbacks in both modes, and the default behaviour, printed lines and telemetry counts do not change.

runner = FactualityRunner(backend, pipeline="factscore", raise_errors=True)
try:
    results = runner.assess(query=query, response=response)
except openai.APIConnectionError:
    ...  # retry, skip the item or stop

Known limits:

  • QueryBuilder.run has the same print-and-fallback pattern (it returns the original text). I left it out to keep this to the stages in the issue; it is a small change with the same helper if you want it.
  • With nli_method="simbauq", if every sample fails inside the backend's _generate_from_context, the SIMBA-UQ strategy skips them and the caller gets RuntimeError("No successful samples were generated.") without the backend error as its cause. Mellea's OpenAI-compatible backends send the request after _generate_from_context returns and raise when the output is awaited, which is outside that guard, so their errors still propagate.

The name raise_errors is my choice since the issue does not name one; easy to rename.

Tests

New tests/core/test_raise_errors.py uses a real Mellea backend (a DummyBackend subclass) whose _generate_from_context raises a fresh ConnectionError("Connection error.") on every call, so the full Mellea path runs without a network. For each of 10 entry points (run and run_batch of Atomizer, Reviser, NLIExtractor and ContextSummarizer, plus NLIAdapterExtractor.run and AtomizerReviser.run):

  • default mode returns today's fallback, prints today's line (for example [Summarizer] Batch item failed: Connection error., as in the issue) and counts one (component, "generation_error") fallback per failed call;
  • raise_errors=True raises a ConnectionError that is one of the exact objects the backend raised, whose traceback still reaches _generate_from_context, and prints and counts no fallback.

Five ContextRetriever.retrieve_all tests: by default the unsummarized contexts are dropped; in raise mode the summarizer's error is re-raised; a summarizer whose raise_errors is not exactly True (a plain mock) keeps the old log-and-skip path; raise mode cancels the queued atoms (8 atoms, 1 worker: fewer than 8 searches run); a search error is still skipped in raise mode. tests/test_runner.py and tests/test_cli.py check that the option defaults to False and reaches the four stages (with and without the LoRA adapter backends) and that --raise-errors reaches the runner.

# new and changed test files against main's src/ and README (f0e77c6)
uv run pytest tests/core/test_raise_errors.py tests/test_runner.py tests/test_cli.py
18 failed, 90 passed    # the 18 raise-mode and option tests; the default-mode tests pass on main too

# same command on this branch
108 passed

uv run pytest tests/core     # 351 passed (326 on main)
uv run pytest                # 11 failed, 863 passed, 5 skipped (main: 11 failed, 833 passed, 5 skipped)

The 11 full-suite failures are the same tests on main (Windows has no os.killpg and locks the temp SQLite file; two need mellea-ibm). ruff check . and mypy -p fact_reasoner report the same findings as on main, none new, and ruff format is clean for the new test file and every touched file that was clean before.

When an LLM call fails, the Atomizer, Reviser, NLI extractor and
Summarizer print the error and fall back to a default result, so a
caller cannot tell that a run was computed from fallbacks or handle the
error itself.

Add a raise_errors keyword (default False) to Atomizer, Reviser,
NLIExtractor, ContextSummarizer and the AtomizerReviser and
NLIAdapterExtractor adapters. A shared handle_generation_error helper in
fact_reasoner.utils keeps today's printed message and fallback by
default, and re-raises the backend's own exception (same object, type,
traceback and cause) when the option is on. In a stage batch, the error
of the first failed item in input order is raised once the batch has
finished.

ContextRetriever summarizes contexts inside its retrieval workers and
logs any worker error as a failed retrieval, which would swallow the
summarizer's error again. When the summarizer's raise_errors is True,
its error is now handed back to retrieve_all, which cancels the atoms
still queued and re-raises it. Retrieval errors, and summarizer errors
in any other mode, keep their existing log-and-skip behaviour.

FactualityRunner takes raise_errors and passes it to the atomizer,
reviser, NLI and summarizer stages, the CLI exposes it as
--raise-errors, and the README documents both. QueryBuilder is not
covered.

Fixes IBM#61

Assisted-by: Claude Code
Signed-off-by: Yashvardhan Thanvi <yashvt9404@gmail.com>
@radum2275
radum2275 merged commit f8b0362 into IBM:main Oct 15, 2026
1 check passed
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.

Errors are swallowed and printed, so callers can't catch them

2 participants