Skip to content

fix(experimentalist): stop discarding usable work in the optimization loop - #1163

Merged
schuellc-nvidia merged 4 commits into
mainfrom
experimentalist-fixes/cschueller
Aug 7, 2026
Merged

fix(experimentalist): stop discarding usable work in the optimization loop#1163
schuellc-nvidia merged 4 commits into
mainfrom
experimentalist-fixes/cschueller

Conversation

@schuellc-nvidia

@schuellc-nvidia schuellc-nvidia commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Two places where the optimization loop threw away work it had already paid for. A
proposal round died if any single improvement was imperfect, and trajectory
scoring killed the entire run when it met a trace it could not cite. Both now
degrade to less rather than to nothing.

Found while building a deterministic smoke fixture (#1089), but neither is
specific to it.

Changes

Proposer — salvage imperfect proposals. Three conditions discarded a whole
round rather than the offending improvement. The strictest required every
improvement in a round to carry a distinct optimization_type; nothing justifies
that, and it also meant a type used once could not be used again later in the
run. Two real runs died on it after round 1, having already produced usable
candidates. Surplus improvements, disallowed types, and duplicate optimization
text now drop the individual improvement and log why. Only an empty result is
fatal. Adds the first tests for this validation, which had none.

GroupLeafScorer — survive a trace with no turns. Every score must cite span
IDs, and the only sanctioned lookups (get_span_id, get_turn_data) are indexed
by turn. An agent that makes no LLM calls produces a trace with zero turns, so
both return None however often they are called. The scorer could not satisfy
its own contract, retried to the CodeAct ceiling, and raised GenerationError
and because scoring runs inside asyncio.gather with no handler above it, that
ended the run, discarding candidates that had already evaluated successfully.

The evidence was never missing: a turn-less trace still carries a call graph of
which methods ran, in what order, and their status. The contract now names the
observable condition (Turns: 0), says to leave span_ids empty, and points at
the call graph instead. Guessed or abbreviated ids are still refused, and traces
that do have turns are unaffected.

Type of Change

  • Code change (feature, bug fix, or refactor)

Quality Gates

  • Tests added or updated for changed behavior
  • Documentation not applicable — justification: internal component contracts; no user-visible surface changes.

Verification

  • Pull request title follows the repository's Conventional Commit format
  • Every commit includes an appropriate Signed-off-by: trailer
  • uv run pre-commit run -a passes, or any blocked checks are identified below
  • Targeted tests pass, or tests are marked not applicable above
  • No secrets, API keys, or credentials are included

Targeted validation:

  • uv run --frozen pytest plugins/nemo-experimentalist/tests/experimentalist/320 passed
  • uv run ruff check / ruff format --check on the changed files — clean
  • uv run pre-commit run -a — every hook passes except Check for uv.lock drift, which also fails on a pristine origin/main checkout with no changes applied. It is uv 0.9.30 on this machine re-locking to a narrower platform set (dropping armv7l, ppc64le, s390x, riscv64 wheels); committing that would break other architectures, so uv.lock is deliberately left untouched by this PR.

Behavioural verification. The proposer fix was confirmed by a multi-round run
that previously died at round 2 and now completes. The scorer fix was confirmed
by a run against a deterministic agent: the scorer completed and separated two
candidates that were tied on reward, citing method names and statuses from the
call graph.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Proposal handling now removes unsupported or duplicate-text suggestions, retains repeated optimization types, and limits results to the configured candidate count.
    • Clear errors are reported when no usable proposals remain.
    • Turn-less trace scoring now supports scores without span IDs and provides guidance for grounding explanations in available trace evidence.
  • Tests

    • Added coverage for proposal filtering, logging, truncation, and empty-result handling.
    • Added validation for turn-less trace scoring and span ID behavior.

… the run

A proposal round is expensive and everything before it is already paid for, but
three separate conditions discarded the whole round rather than the offending
improvement.

The strictest was a requirement that every improvement in a round carry a
distinct optimization_type. Nothing justifies it: a round that wants to edit two
different methods is a perfectly good round, and forbidding it also meant a type
used once could not be used again later in the run. Two real runs died on it
after round 1, having already produced usable candidates.

Surplus improvements, a type outside the allowed set, and duplicate optimization
text now drop the individual improvement and log why. Only an empty result is
fatal, because there is then nothing to build. Callers still pass the available
types as a prompt hint, but no longer as a constraint that outlives the round.

Adds the first tests for this validation, which had none.

Signed-off-by: Christian Schüller <cschueller@nvidia.com>
…turns

GroupLeafScorer requires every score to cite span IDs, and the only sanctioned
lookups -- get_span_id and get_turn_data -- are indexed by turn. An agent that
makes no LLM calls produces a trace with zero turns, so both return None however
often they are called. The scorer could not satisfy its own contract, retried to
the CodeAct iteration ceiling, and raised GenerationError; because scoring runs
inside asyncio.gather with no handler above it, that ended the whole run --
discarding candidates that had already been evaluated successfully.

Nothing was wrong with the evidence. A turn-less trace still carries a full call
graph: which methods ran, in what order, and their status, which is enough to
tell a broken candidate from a fixed one. The scorer could see it; it just had no
permitted way to cite it, since the ids visible in the overview text are
abbreviated and explicitly disallowed.

The contract now names the observable condition (`Turns: 0`), says to leave
span_ids empty, and points at the call graph as the grounding to use instead.
Guessed or abbreviated ids are still refused, and traces that do have turns are
unaffected.

Verified on a run against a deterministic agent: the scorer completed and
separated two candidates that were tied on reward, citing method names and
statuses. Not specific to one fixture -- any deterministic, rule-based, or purely
tool-driven agent produces turn-less traces and hit the same dead end.

Signed-off-by: Christian Schüller <cschueller@nvidia.com>
@schuellc-nvidia
schuellc-nvidia requested review from a team as code owners August 7, 2026 12:22
@github-actions github-actions Bot added the fix label Aug 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py`:
- Around line 199-205: Update the proposal filtering flow in the proposer method
so every returned improvement is validated for allowed content and duplicates
before applying the max_candidates limit. Truncate only the resulting usable
list, preserving the existing warning as appropriate, and ensure the method
raises only when no valid improvements remain.

In
`@plugins/nemo-experimentalist/tests/experimentalist/test_trace_scorer_contract.py`:
- Around line 43-46: Strengthen test_contract_offers_an_alternative_grounding by
asserting that the contract includes requirements for method names, execution
order, and statuses in addition to the call graph. Ensure the assertions verify
each required zero-turn evidence constraint so removing any one of them causes
the test to fail.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 035f20a5-869c-457c-a1ba-4d1bcfb3ebff

📥 Commits

Reviewing files that changed from the base of the PR and between 648152c and ba8b4a3.

📒 Files selected for processing (4)
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/trace_scorer.py
  • plugins/nemo-experimentalist/tests/experimentalist/test_proposer_validation.py
  • plugins/nemo-experimentalist/tests/experimentalist/test_trace_scorer_contract.py

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 31437/40061 78.5% 63.0%
Integration Tests 18308/38013 48.2% 20.8%

Comment thread plugins/nemo-experimentalist/tests/experimentalist/test_proposer_validation.py Outdated
_filter_improvements truncated to max_candidates before checking whether
the improvements it kept were usable. When the improvements past the cut
were the only valid ones, the round raised "none of them usable" while
holding work it could have built on -- the failure this PR set out to
remove. The cut now falls on the kept list.

Also trims the history out of three docstrings, leaving what the reader
needs to use the code, and pins the whole of the turn-less evidence
contract (methods, order, status) rather than just "call graph" -- a
prompt that dropped the other three still passed.

Review feedback from @coderabbitai and @gaiadilorenzo on #1163.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian Schüller <cschueller@nvidia.com>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Proposal filtering now drops unusable candidates, allows repeated optimization types, and updates prompt guidance. Trace scoring now defines handling for traces without LLM turns, including empty evidence span IDs.

Changes

Proposal filtering

Layer / File(s) Summary
Filter and validate improvements
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py, plugins/nemo-experimentalist/tests/experimentalist/test_proposer_validation.py, plugins/nemo-experimentalist/tests/experimentalist/test_proposer_contract.py
Proposer filters invalid types and duplicate descriptions, retains repeated types, truncates candidates, logs drops, updates prompt guidance, and raises when no candidates remain.

Trace scoring contract

Layer / File(s) Summary
Document zero-turn scoring
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/trace_scorer.py, plugins/nemo-experimentalist/tests/experimentalist/test_trace_scorer_contract.py
The scorer uses call-graph evidence for zero-turn traces and permits empty span_ids while rejecting fabricated or abbreviated IDs.

Suggested reviewers: callingmedic911, aleckhoury, briannewsom

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: preserving usable work in the experimentalist optimization loop.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch experimentalist-fixes/cschueller

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py`:
- Around line 163-170: Update the generation flow around _run_with_context so
available_types is treated as a prompt preference rather than a hard
restriction, allowing previously used optimization types when appropriate.
Preserve validation against all declared types and keep _filter_improvements
configured with all_types for that validation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: e2bceb00-69af-4479-bda6-5c5181cf9895

📥 Commits

Reviewing files that changed from the base of the PR and between 648152c and b4959e3.

📒 Files selected for processing (4)
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/trace_scorer.py
  • plugins/nemo-experimentalist/tests/experimentalist/test_proposer_validation.py
  • plugins/nemo-experimentalist/tests/experimentalist/test_trace_scorer_contract.py

Relaxing _filter_improvements only stopped the run from dying on a
repeated optimization_type. The prompt still said each one MUST be in
`available_types` -- the untried set -- so while any type was untried the
model was told never to propose a reused one, and the allowance below it
was unreachable. The comment claiming novelty was "a preference" was
describing the validator, not the instruction the model actually follows.

The pick is now legal from either set, preferred from the untried one, in
the MANDATORY block and in the two other places that restricted it.
test_proposer_contract.py pins the prompt half so it cannot drift back
out of agreement with the validator half.

Review feedback from @coderabbitai on #1163.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian Schüller <cschueller@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py (2)

204-209: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Drop empty normalized optimization text.

Line 204 strips the text, but Lines 205-209 keep description == "". A whitespace-only optimization can consume a candidate slot or become the only returned candidate. Reject empty description before duplicate handling.

Proposed fix
             description = improvement.optimization.strip()
+            if not description:
+                logger.warning("dropping improvement with empty optimization text")
+                continue
             if description in seen_descriptions:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py`
around lines 204 - 209, In the improvement filtering flow, reject empty
normalized optimization text immediately after assigning `description =
improvement.optimization.strip()` and before checking `seen_descriptions`. Skip
whitespace-only descriptions without adding them to `seen_descriptions` or
`kept`, while preserving the existing duplicate handling for non-empty
descriptions.

219-225: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Constrain max_candidates to positive values. EvolutionaryOptimizerConfig.max_candidates accepts 0 and negative values. These values make _filter_improvements() return an invalid candidate list. Enforce max_candidates >= 1 in the configuration or before truncation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py`
around lines 219 - 225, Enforce that EvolutionaryOptimizerConfig.max_candidates
is at least 1, preferably during configuration validation or before the
truncation logic in _filter_improvements(). Reject or normalize zero and
negative values so candidate filtering always produces a valid non-empty limit.
🧹 Nitpick comments (1)
plugins/nemo-experimentalist/tests/experimentalist/test_proposer_contract.py (1)

27-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the prompt contract assertions.

These assertions do not fully pin the clauses named by their tests. Assert the complete allowed-type clause, the explicit available_types preference, and the positive wording for the card selection.

Proposed fix
-    assert "must be in `available_types` or" in contract, "the contract must not restrict the pick to untried types"
-    assert "`tried_types`" in contract, "reuse is only reachable if tried types are a legal pick"
+    assert (
+        "each improvement.optimization_type must be in `available_types` or `tried_types`"
+        in contract
+    )

...
-    assert "prefer" in contract, "novelty has to survive as a preference, not vanish with the restriction"
+    assert "prefer `available_types`" in contract

...
-    assert "pick one optimization_type from `available_types`" not in contract
+    assert (
+        "pick one optimization_type that the card covers, preferring `available_types`"
+        in contract
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-experimentalist/tests/experimentalist/test_proposer_contract.py`
around lines 27 - 44, Strengthen the assertions in
test_contract_permits_reusing_a_tried_type,
test_contract_keeps_novelty_as_a_preference, and
test_contract_does_not_restrict_the_card_pick_to_untried_types to match the
complete contract wording: validate the full allowed-type clause, require an
explicit preference for available_types, and assert positive card-selection
wording rather than only rejecting the old phrase.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py`:
- Around line 204-209: In the improvement filtering flow, reject empty
normalized optimization text immediately after assigning `description =
improvement.optimization.strip()` and before checking `seen_descriptions`. Skip
whitespace-only descriptions without adding them to `seen_descriptions` or
`kept`, while preserving the existing duplicate handling for non-empty
descriptions.
- Around line 219-225: Enforce that EvolutionaryOptimizerConfig.max_candidates
is at least 1, preferably during configuration validation or before the
truncation logic in _filter_improvements(). Reject or normalize zero and
negative values so candidate filtering always produces a valid non-empty limit.

---

Nitpick comments:
In
`@plugins/nemo-experimentalist/tests/experimentalist/test_proposer_contract.py`:
- Around line 27-44: Strengthen the assertions in
test_contract_permits_reusing_a_tried_type,
test_contract_keeps_novelty_as_a_preference, and
test_contract_does_not_restrict_the_card_pick_to_untried_types to match the
complete contract wording: validate the full allowed-type clause, require an
explicit preference for available_types, and assert positive card-selection
wording rather than only rejecting the old phrase.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 4067e8d7-2aa2-4ecb-8513-8ef7cd9f7573

📥 Commits

Reviewing files that changed from the base of the PR and between b4959e3 and aa5c0ab.

📒 Files selected for processing (2)
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py
  • plugins/nemo-experimentalist/tests/experimentalist/test_proposer_contract.py

@schuellc-nvidia
schuellc-nvidia added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit ffbb4f3 Aug 7, 2026
52 checks passed
@schuellc-nvidia
schuellc-nvidia deleted the experimentalist-fixes/cschueller branch August 7, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants