Skip to content

fix(experimentalist): retain the baseline when no candidate beats it - #1195

Merged
schuellc-nvidia merged 3 commits into
mainfrom
fix-winner-selection-baseline-retention/cschueller
Aug 10, 2026
Merged

fix(experimentalist): retain the baseline when no candidate beats it#1195
schuellc-nvidia merged 3 commits into
mainfrom
fix-winner-selection-baseline-retention/cschueller

Conversation

@schuellc-nvidia

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

Copy link
Copy Markdown
Contributor

Summary

Finalization picked the run's winner with select_diverse_survivors(..., k=1). That agent is prompted to always include a candidate created in the current round, so at k=1 the rule consumes the only slot and the round-0 baseline can never be returned. Before this change, every run shipped a diff — including runs where no candidate measured better than doing nothing. After it, finalization is deterministic: the Pareto front over the configured objectives, with the oldest candidate winning ties, so a run that finds no improvement keeps the baseline.

Winner selection and survivor selection are different questions. Picking a set to carry into the next round wants diversity and fresh blood; picking a winner wants the best measured result and no judgement call. Mid-loop survivor selection is untouched and still uses the diversity selector.

Changes

  • Add select_winner_node() in experimentalist/components/loop.py: pareto_front over pareto_objectives, then min(front, key=round). Used by _finalize in place of the select_diverse_survivors(..., 1, ...) call.
  • Preserve the existing objective-metric contract: has_metric_dimensions eligibility filtering and pareto_objectives ranking are unchanged.
  • Leave the mid-loop select_diverse_survivors call site unchanged.
  • Add plugins/nemo-experimentalist/tests/experimentalist/test_winner_selection.py (10 tests). This path had no coverage, which is how the regression shipped.

Preferring the lowest round only decides ties: a candidate that genuinely improves dominates its ancestor and removes it from the front before the tie-break is consulted, so it can never block a real gain. The previous behaviour was correct only by accident — pareto_front preserves input order and agent-0 is inserted first, so ties happened to fall to the baseline. The rule is now stated explicitly rather than relying on dict insertion order.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with documentation updates
  • Documentation only
  • Contributor tooling or automation
  • CI, build, or test infrastructure

Quality Gates

  • Tests added or updated for changed behavior
  • Existing tests cover changed behavior — justification:
  • Tests not applicable — justification:
  • Documentation updated for user-visible behavior
  • Documentation not applicable — justification: internal selection behaviour with no documented user-facing surface; the rationale is captured in the function docstring and the tests.

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/test_winner_selection.py -q
  → 10 passed

uv run --frozen pytest plugins/nemo-experimentalist/tests/ -q
  → 628 passed

uv run ruff check <loop.py> <test_winner_selection.py>          → All checks passed!
uv run ruff format --check <loop.py> <test_winner_selection.py> → 2 files already formatted
uv run --frozen ty check <loop.py>                              → All checks passed!

uv run pre-commit run -a
  → all hooks passed EXCEPT "Run uv lock with platform uv", which failed with:
       "uv.lock must be checked or updated with uv 0.9.14 ... Current uv: uv 0.9.30"

The one failing hook is environmental and pre-existing, not caused by this change. It was reproduced on pristine main with these changes stashed, and fails identically. This PR modifies no pyproject.toml and no uv.lock; the separate "Check for uv.lock drift" hook passed. It needs uv 0.9.14 locally, which this machine does not have. The Verification checkbox above is left unchecked rather than claiming a gate that did not pass.

End-to-end behaviour was also confirmed against a fixture whose expected outcome is known in advance: a single-round generalization scenario where all three agents tie at 1/3 on validation now finalizes with winner=agent-0 (the baseline). Under the previous code the baseline was structurally ineligible, so a candidate that improved nothing would have been published as the winner.

Provenance

The finalization behaviour corrected here was introduced in #1107, which also added selector.py. Before that change, finalization ranked with pareto_front and did not call an agent.

Summary by CodeRabbit

  • Bug Fixes
    • Improved final candidate selection using objective comparisons across multiple metrics.
    • Ensured genuine improvements win over regressions while respecting maximized, minimized, and protected metrics.
    • Restored the original baseline for reliable regression comparisons, even if it ended early.
    • Prevented incomplete or incomparable candidates from displacing the baseline.
    • Added deterministic tie-breaking for consistent results.
    • Finalization now handles empty, single-candidate, and baseline-free results consistently.

Finalization picked the winner with `select_diverse_survivors(..., k=1)`. That
agent is prompted to always include a candidate created in the current round, so
at k=1 the rule consumes the only slot and the round-0 baseline can never be
returned. Every run therefore shipped a diff, including runs where no candidate
measured better than doing nothing.

Winner selection is not the same question as survivor selection. Picking a set to
carry forward wants diversity and fresh blood; picking a winner wants the best
measured result, and no judgement call. Finalization now uses `select_winner_node`:
the Pareto front by the configured objectives, with the lowest round winning ties.
A candidate that genuinely improves dominates its ancestor and removes it from the
front, so the age preference only ever decides ties and never blocks a real gain.

Mid-loop survivor selection is unchanged and still uses the diversity selector.
Objective-metric eligibility (`has_metric_dimensions`) and `pareto_objectives`
ranking are both preserved.

The previous behaviour was correct only by accident: `pareto_front` preserves
input order and agent-0 is inserted first, so ties fell to the baseline. The rule
is now stated explicitly rather than relying on dict ordering.

This path had no test coverage, which is how the regression shipped. Adds
`test_winner_selection.py` pinning the tie case, order-independence, real
improvements, regressions, multi-objective trades, and minimized objectives.

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

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 966ab6a4-ccef-4049-befd-e22a2b8760a1

📥 Commits

Reviewing files that changed from the base of the PR and between be2f2c3 and e959ca4.

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

📝 Walkthrough

Walkthrough

The experimentalist loop now builds a baseline-aware finalization pool, filters candidates that regress against the baseline, ranks the remaining Pareto front, and selects a deterministic winner. Tests cover objectives, protected metrics, killed baselines, missing metrics, and fallback behavior.

Changes

Winner selection

Layer / File(s) Summary
Selection logic
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/loop.py
Added baseline-aware finalization and winner selection. The code filters missing objective dimensions and regressions, ranks the Pareto front, and breaks ties by round and creation order.
Finalization integration
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/loop.py
Finalization re-admits an eligible round-0 baseline and passes it explicitly to select_winner_node.
Selection validation
plugins/nemo-experimentalist/tests/experimentalist/test_winner_selection.py
Tests cover dominance, objective direction, protected metrics, missing metrics, deterministic ties, killed candidates, baseline fallback, and runs without a baseline.

Sequence Diagram(s)

sequenceDiagram
  participant Finalization
  participant finalization_pool
  participant select_winner_node
  participant pareto_front
  Finalization->>finalization_pool: collect eligible candidates and baseline
  finalization_pool-->>Finalization: candidate pool and baseline reference
  Finalization->>select_winner_node: pool, objectives, and baseline
  select_winner_node->>select_winner_node: remove regressing candidates
  select_winner_node->>pareto_front: rank eligible candidates
  pareto_front-->>select_winner_node: non-dominated candidates
  select_winner_node-->>Finalization: deterministic winner or None
Loading

Suggested reviewers: a2bondar, aahunt-nv, ajaythorve

🚥 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 and concisely describes retaining the baseline during experimentalist finalization when no candidate improves it.
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 fix-winner-selection-baseline-retention/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/loop.py`:
- Around line 149-150: Add a deterministic creation-order secondary key to the
winner selection in pareto_front/min within loop.py, while retaining round as
the primary key. In
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/loop.py
lines 149-150, update the min key using the existing creation-order symbol. In
plugins/nemo-experimentalist/tests/experimentalist/test_winner_selection.py
lines 78-89, add a reversed-input test covering equal-round, non-dominated
candidates and asserting the same creation-order winner.
🪄 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: e62da7b5-debd-4ff0-a762-c19e1f2f6ab4

📥 Commits

Reviewing files that changed from the base of the PR and between 0c4dc81 and 1f1e877.

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

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 31778/40384 78.7% 63.4%
Integration Tests 18459/38314 48.2% 20.9%

Review follow-up. Three gaps in the first pass.

`regression_metrics` was dropped. The old finalization call passed it to the
selector; ranking on the objectives alone let a candidate buy an objective gain
with a regression the config says must not happen. Candidates that worsen a
regression target against the baseline are now filtered out before ranking. The
baseline is exempt from its own comparison, so a round where every candidate
regresses keeps the baseline rather than shipping the least-bad regression.

A target absent from either side is skipped rather than treated as a regression:
the evaluator not reporting a dimension is not evidence of harm.

Tie-breaking relied on input order. `min` returns the first minimum and
`pareto_front` preserves input order, so two same-round candidates that nothing
dominates could swap winners if the caller reordered its input -- the same
implicit-ordering dependence this change set out to remove, one level down.
Ties now fall to creation order, recovered from the run-scoped label's numeric
suffix so `agent-9` precedes `agent-10`.

The docstring described how the previous behaviour broke. That belongs in
history, not in an interface contract, so it now states what the function
guarantees and the reasoning stays in the commit that made the change.

Adds nine tests: regression in both directions, an objective gain that cannot pay
for one, multiple objectives alongside a protected metric, every candidate
regressing, a metric missing from one side, reversed input at equal round, and
numeric-versus-lexicographic creation order.

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.

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/loop.py`:
- Line 1847: Update _finalize so the round-0 agent-0 baseline remains eligible
for final winner selection even when survivor selection marks it non-surviving;
alternatively compare all candidates against agent-0 independently of
is_survivor. Ensure select_winner_node receives the real baseline context,
preventing regressions against agent-0 from winning, and add an _finalize
integration test covering this case.
🪄 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: dd101a15-2269-49e6-b1e9-ceae7aadf388

📥 Commits

Reviewing files that changed from the base of the PR and between 1f1e877 and be2f2c3.

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

…d-loop

Review follow-up. Baseline retention still had a second way to fail.

The candidate pool passed to survivor selection starts as the baseline and stays
in it every round -- `candidates = list(evolution_tree.survivors(0))`, then
`candidates = survivors + new_candidates`. Everything the selector does not keep
gets a `killed_round`, and that selector is the one told to always keep a
candidate created this round. So the baseline can be killed mid-loop.

`_finalize` filtered on `is_survivor`, so a killed baseline never reached winner
selection. Two consequences, both silent: a run whose baseline was killed could
only choose among diffs, and the regression comparison rehomed onto the oldest
surviving candidate, measuring "does not regress" against a candidate that had
itself already regressed.

Adds `finalization_pool`, which re-admits the round-0 node regardless of
`is_survivor` and returns it separately as the regression reference. Survivor
selection and finalization answer different questions -- "what is worth carrying
forward" versus "is any of this better than shipping nothing" -- and only the
second requires the baseline to remain on the table.

`select_winner_node` now takes that baseline explicitly rather than inferring it
from the oldest eligible node, which is only the same thing when the baseline
survived. A baseline lacking the configured objective dimensions cannot be ranked,
so it is not used as a reference and selection falls back rather than failing.

Adds five tests over an EvolutionTree with a killed baseline, including that the
regression reference stays the real baseline rather than the oldest survivor.

Signed-off-by: Christian Schüller <cschueller@nvidia.com>
@schuellc-nvidia
schuellc-nvidia added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 1c2e9d6 Aug 10, 2026
56 checks passed
@schuellc-nvidia
schuellc-nvidia deleted the fix-winner-selection-baseline-retention/cschueller branch August 10, 2026 12:33
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