Skip to content

Commit ba8f3fe

Browse files
committed
fix(evaluator): give the structured-output probe room for reasoning models
The probe capped output at 128 tokens. A reasoning model spends that budget thinking and returns truncated or empty content, which is indistinguishable from an endpoint that ignored the encoding, so detection concluded UNSUPPORTED and fell back to the unenforced prompt instruction. Live against integrate.api.nvidia.com, nvidia/nemotron-3-nano-30b-a3b and nvidia/llama-3.3-nemotron-super-49b-v1.5 both returned empty or truncated content at 128 tokens and a valid probe response at 2048. Raise the budget and warn when a probe is truncated, so an inconclusive detection is visible rather than silently downgrading enforcement. Also hoist the trials/target validation out of the evaluation session. Signed-off-by: Sandy Chapman <schapman@nvidia.com>
1 parent 6021f5e commit ba8f3fe

6 files changed

Lines changed: 92 additions & 12 deletions

File tree

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/evaluator.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,17 @@ async def run(
166166
runtime_config = resolved_config.model_copy(update={"run_id": run_id})
167167
started_at = datetime.now(UTC)
168168

169+
if (trials is None) == (target is None):
170+
raise ValueError("provide exactly one of trials or target")
171+
169172
async with begin_evaluation_session():
170173
# Branch on which seam was supplied so the type checker can narrow ``target`` to a
171174
# concrete ``AgentEvalTarget`` without a cast.
172175
if trials is not None:
173-
if target is not None:
174-
raise ValueError("provide exactly one of trials or target")
175176
trial_list = list(trials)
176-
elif target is not None:
177-
trial_list = await self._generate_trials(tasks=task_list, target=target, config=runtime_config)
178177
else:
179-
raise ValueError("provide exactly one of trials or target")
178+
assert target is not None
179+
trial_list = await self._generate_trials(tasks=task_list, target=target, config=runtime_config)
180180
scores = await self._score_trials(
181181
tasks=task_list,
182182
trials=trial_list,

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/structured_output.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
_logger = logging.getLogger(__name__)
1818

19+
#: Probes must survive a reasoning model's thinking budget; too small and truncated output looks
20+
#: identical to an endpoint that ignored the encoding.
21+
_PROBE_MAX_TOKENS = 4096
22+
1923
#: The marker name must stay unguessable: the probe never shows this schema to the model, so a
2024
#: mode only passes when the server actually injected the grammar.
2125
_DEFAULT_PROBE_SCHEMA: dict = {
@@ -165,6 +169,14 @@ def _looks_like_unsupported_guided_json_error(message: str) -> bool:
165169
return False
166170

167171

172+
def _probe_was_truncated(response: dict) -> bool:
173+
"""Return whether the probe ran out of output budget before producing content."""
174+
choices = response.get("choices")
175+
if not isinstance(choices, list) or not choices:
176+
return False
177+
return choices[0].get("finish_reason") == "length"
178+
179+
168180
def _extract_chat_content(response: dict) -> str | None:
169181
choices = response.get("choices")
170182
if not isinstance(choices, list) or not choices:
@@ -249,7 +261,9 @@ async def _probe_structured_output_mode(
249261
base_request = {
250262
"messages": [{"role": "user", "content": probe_message}],
251263
"temperature": 0,
252-
"max_tokens": 128,
264+
# Generous because reasoning models spend most of a small budget thinking and return
265+
# truncated or empty content, which is indistinguishable from an unhonoured encoding.
266+
"max_tokens": _PROBE_MAX_TOKENS,
253267
}
254268
# Deep-copied so `probe_schema` stays unreachable from the request: Pydantic copies only a
255269
# dict's top level, and `probe_schema` is what the response is validated against.
@@ -270,6 +284,14 @@ async def _probe_structured_output_mode(
270284
content = _extract_chat_content(response)
271285
if content and _is_probe_valid_json(content, probe_schema):
272286
return mode
287+
if _probe_was_truncated(response):
288+
_logger.warning(
289+
"Structured output probe for %s hit the %d-token budget, so support for %s "
290+
"could not be determined; enforcement may be dropped for this run.",
291+
model.name,
292+
_PROBE_MAX_TOKENS,
293+
mode.value,
294+
)
273295
except Exception as e:
274296
if _looks_like_unsupported_guided_json_error(str(e)):
275297
continue

packages/nemo_evaluator_sdk/tests/agent_eval/test_evaluator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,8 @@ def test_metric_row_error_is_none_for_a_trial_that_did_not_fail() -> None:
921921
row = _metric_row(AgentEvalTask(id="task-1", intent="Fix it.", inputs={}), _candidate_trial())
922922

923923
assert row["trial"]["error"] is None
924+
925+
924926
class _PreflightCountingMetric(_ConstantMetric):
925927
"""Metric that records how many times its preflight ran."""
926928

packages/nemo_evaluator_sdk/tests/test_structured_output.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,37 @@ async def inference_fn(model, request, max_retries, **kwargs):
519519

520520
assert set(modes) == {StructuredOutputMode.OPENAI_RESPONSE_FORMAT}
521521
assert sorted(calls) == ["a", "b"], f"each concurrent run must probe in its own cache, got {calls}"
522+
523+
524+
@pytest.mark.asyncio
525+
async def test_truncated_probe_is_reported_rather_than_silently_unsupported(caplog):
526+
"""A reasoning model can spend the whole probe budget thinking and return empty content.
527+
528+
Truncation is indistinguishable from an endpoint ignoring the encoding, so it must at least be
529+
surfaced instead of quietly resolving to the unenforced prompt fallback.
530+
"""
531+
532+
async def truncating_inference_fn(model, request, max_retries, **kwargs):
533+
return {"choices": [{"finish_reason": "length", "message": {"content": ""}}]}
534+
535+
with caplog.at_level(logging.WARNING):
536+
mode = await detect_structured_output_mode(
537+
model=_test_model(), inference_fn=truncating_inference_fn, api_key=None
538+
)
539+
540+
assert mode == StructuredOutputMode.UNSUPPORTED
541+
assert [r for r in caplog.records if "could not be determined" in r.message]
542+
543+
544+
@pytest.mark.asyncio
545+
async def test_probe_budget_accommodates_a_reasoning_model():
546+
"""The probe must not use a budget a reasoning model burns before emitting content."""
547+
seen: list[int] = []
548+
549+
async def inference_fn(model, request, max_retries, **kwargs):
550+
seen.append(request["max_tokens"])
551+
return {"choices": [{"message": {"content": '{"__nmp_probe_score": 1}'}}]}
552+
553+
await detect_structured_output_mode(model=_test_model(), inference_fn=inference_fn, api_key=None)
554+
555+
assert seen[0] >= 2048, f"probe budget {seen[0]} is too small for a reasoning model"

sdk/python/nemo-platform/src/nemo_platform/beta/evaluator/agent_eval/evaluator.py

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/python/nemo-platform/src/nemo_platform/beta/evaluator/structured_output.py

Lines changed: 23 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)