Skip to content

fix(benchmark): bound the LLM-judge call with --timeout - #155

Open
addyCooks wants to merge 3 commits into
Nano-Collective:mainfrom
addyCooks:fix/132-bound-judge-call-with-timeout
Open

fix(benchmark): bound the LLM-judge call with --timeout#155
addyCooks wants to merge 3 commits into
Nano-Collective:mainfrom
addyCooks:fix/132-bound-judge-call-with-timeout

Conversation

@addyCooks

Copy link
Copy Markdown
Contributor

Description

--timeout is documented as the timeout per test and is enforced for inference,
but not for the LLM-judge call that decides pass/fail. The AbortController the
benchmark loop builds is cleared in a finally that runs before callJudge is
reached, and callJudge took no signal to forward into generateText anyway. A
judge provider that accepts the connection and never answers therefore hangs the
whole benchmark on one test, with no way out but killing the process — and the AI
SDK's default retry policy sits on top of that, re-issuing a request nobody is
timing.

callJudge now takes an optional abortSignal and forwards it into
generateText. One signal covers the retries too: the SDK cancels the in-flight
fetch, rethrows an abort without retrying it, and interrupts the backoff delay
between attempts, so no maxRetries override is needed.

Judging gets its own budget of timeout rather than sharing the inference one.
It is a second model call, and the loop already spends one budget per call (with
--samples n a test may take up to n timeouts). Extending the inference timer
instead would leave the judge whatever generation didn't use, so a slow-but-fine
generation could starve the judge and flip a passing test to failed.

A judge that never answered is not a verdict: the sample fails and the run moves
on, exactly as an inference timeout already does a few lines up. judgeScore is
left unset and the reason recorded in judgeReasoning ("Judge timed out after
Nms"), so the report doesn't read as the model having scored 0.

Closes #132

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Testing

Automated Tests

  • All existing tests pass (pnpm test:all completes successfully)
  • New tests added for new functionality (if applicable)

Manual Testing

  • Tested nanotune init
  • Tested nanotune data commands (add/import/list/validate)
  • Tested nanotune train
  • Tested nanotune export
  • Tested nanotune benchmark

Checklist

  • Code follows project style guidelines (pnpm format)
  • Self-review completed
  • Documentation updated (if needed)
  • No breaking changes (or clearly documented)

`--timeout` is documented as the timeout per test and is enforced for
inference, but not for the judge call that decides pass/fail. The
AbortController the loop builds is cleared in a `finally` that runs before
`callJudge` is ever reached, and `callJudge` took no signal to forward
into `generateText` anyway. So a judge provider that accepts the
connection and never answers — a hung local server, a slow backoff after a
rate limit — hangs the whole benchmark on one test, with no progress
indicator to tell "slow" from "dead" and no way out but killing the
process. The AI SDK's default retry policy sits on top of that,
re-issuing a request nobody is timing.

Give `callJudge` an `abortSignal` and forward it into `generateText`. One
signal covers the retries too: the SDK cancels the in-flight fetch,
rethrows an abort without retrying it, and interrupts the backoff delay
between attempts.

Judging gets its own budget of `timeout` rather than sharing the
inference one. It is a second model call, and the existing loop already
spends one budget per call — with `--samples n` a test may take up to n
timeouts. Extending the inference timer instead would leave the judge
whatever inference did not use, so a slow-but-fine generation could
starve the judge and flip a passing test to failed.

A judge that never answered is not a verdict, so the sample fails and the
run moves on, exactly as an inference timeout already does a few lines
up. `judgeScore` is left unset and the reason recorded in
`judgeReasoning` — "Judge timed out after Nms" — so the report does not
read as the model having scored 0.

Closes Nano-Collective#132
Both tests run against a local HTTP server, so they need no network and
no live provider.

The first stands up the endpoint the issue describes and cannot easily be
reproduced by hand: one that accepts the connection and never replies.
Strip the `abortSignal` back out of `generateText` and this test does not
fail, it hangs until AVA's own timeout — which is the bug, stated as a
test. The elapsed-time assertion is the point of it: "it threw" is not
enough, because the SDK retries a failed call by default and an unaborted
attempt would still be waiting rather than having given up.

The second is the other half — a provider that answers inside its budget
still gets scored normally, so the new optional parameter stays out of the
way of the path everyone actually uses. It covers `callJudge` and
`createJudgeProvider` end to end for the first time.

Sockets held open by an unanswered request are destroyed in the teardown;
without that the server never finishes closing and the worker never exits.
"Timeout per test" was the promise the flag did not keep for LLM-judge
tests, and it is imprecise now that it does: the budget is spent per model
call, so a judged test can spend it twice — once generating, once being
scored — and an n-sample run spends it n times over.
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.

[Bug] --timeout does not bound the LLM judge call, so a stalled judge provider hangs the benchmark indefinitely

1 participant