Skip to content

feat(experimentalist): stop a run once the objective is reached - #1197

Merged
gaiadilorenzo merged 3 commits into
mainfrom
feat-stop-when-objective-reached/cschueller
Aug 11, 2026
Merged

feat(experimentalist): stop a run once the objective is reached#1197
gaiadilorenzo merged 3 commits into
mainfrom
feat-stop-when-objective-reached/cschueller

Conversation

@schuellc-nvidia

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

Copy link
Copy Markdown
Contributor

Summary

The loop had two ways to stop: the round budget, and a judgement that progress had stalled. Neither notices success. A round that goes 0.333 -> 1.000 is the clearest possible case of not stagnating, so a solved run kept buying rounds that could only match what it already had — and a baseline that already met the goal paid for the whole budget before returning itself.

This adds an optional target to MetricTarget: the value at which that objective is satisfied, in the metric's own units. When every targeted objective is met, the run stops. Unset, nothing changes.

Before and after, on a run whose first round solves the problem:

Before After (target: 1.0)
Round 1 reaches 1.000 runs every remaining round stops, objective reached by agent-1 (reward=1.0)
Baseline already at 1.000 runs the full budget, returns the baseline stops before round 1
No target configured unchanged unchanged

Changes

  • MetricTarget.target: float | None, with is_satisfied_by() handling direction and treating a missing measurement as not satisfied.
  • Terminator.assess_objective_reached, consulted first in Terminator.run().
  • test_objective_reached.py — 13 tests.
  • test_metric_contract.py — updated for the new field, plus an assertion that insight-authored objectives carry no target.

Three properties, each a deliberate decision:

Not gated by disable_convergence_check. That flag turns off an inference about stagnation, which is worth disabling when the signal is unreliable. This is a measurement against a threshold the caller stated. Anyone who does not want the run to stop here does not set a target.

Checked at the top of every round, including the first. The baseline is scored before the loop begins, so a baseline already meeting the targets ends the run without a single round being paid for.

Only candidates that could win are considered — survivors and the round-0 baseline, mirroring finalization. A killed candidate meeting the target would otherwise end the run in favour of a winner that never reaches it.

Unset is the only safe default: metrics are not required to be normalized, so there is no value that means "as good as possible" for an arbitrary one. Insight-authored objectives get no target for the same reason, leaving Mode 1 unaffected — an LLM-invented metric has no known satisfied value.

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: the new field is self-documenting through its Field(description=...), which is where the other optimizer settings are described; there is no separate settings reference to update.

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_objective_reached.py -q
  → 13 passed

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

uv run ruff check <config.py> <terminator.py> <both tests>          → All checks passed!
uv run ruff format --check <same>                                   → already formatted
uv run --frozen ty check <config.py> <terminator.py>                → All checks passed!

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

That hook is environmental and pre-existing — it needs uv 0.9.14 locally and this machine has 0.9.30. It was reproduced on pristine main with these changes stashed while preparing #1195, and CI's own Check uv lock and Check latest uv compatibility both passed there. This PR modifies no pyproject.toml and no uv.lock. Left unchecked rather than claiming a gate that did not pass.

Behaviour was also confirmed by mutation rather than by green tests alone: with no target the run continues exactly as before; with target: 1.0 the same tree stops and reports objective reached by agent-1 (reward=1.0).

Note

Found while running a fixture whose expected score is known in advance. Three groups each reached 1.000 and then spent a further round that could not improve on it, and one reached it in round 1 of 2. Nothing was wrong with those runs — there was simply no way to say "this is finished".

Summary by CodeRabbit

  • New Features

    • Added configurable satisfaction thresholds for optimization metrics, supporting maximize and minimize objectives.
    • Runs can stop immediately when all targeted objectives are met, including by baseline candidates.
    • Missing, unscored, or ineligible candidate metrics do not count toward objective completion.
  • Bug Fixes

    • Untargeted objectives no longer prevent early termination.
    • Objective-based stopping now works independently of convergence settings.
    • Numeric objective targets are now preserved and evaluated correctly.

The loop had two ways to stop: the round budget, and a judgement that progress
had stalled. Neither notices success. A round that goes 0.333 -> 1.000 is the
clearest possible case of *not* stagnating, so a solved run kept buying rounds
that could only match what it already had -- and a baseline that already met the
goal paid for the whole budget before returning itself.

Adds an optional `target` to MetricTarget: the value at which that objective is
satisfied, in the metric's own units. When every targeted objective is met, the
run stops. Unset, nothing changes -- which is the only safe default, because
metrics are not required to be normalized and there is no value that means "as
good as possible" for an arbitrary one.

Three properties worth stating, because each was a decision:

Not gated by `disable_convergence_check`. That flag turns off an *inference* about
stagnation, which is worth disabling when the signal is unreliable. This is a
measurement against a threshold the caller stated. Anyone who does not want the
run to stop here simply does not set a target.

Checked at the top of every round including the first. The baseline is scored
before the loop begins, so a baseline that already meets the targets ends the run
without a single round being paid for.

Only candidates that could win are considered -- survivors and the round-0
baseline, mirroring finalization. A killed candidate meeting the target would
otherwise end the run in favour of a winner that never reaches it.

Insight-authored objectives carry no target, so Mode 1 is unaffected: an
LLM-invented metric has no known satisfied value. test_metric_contract now pins
that, alongside the `target` a demoted objective keeps when it becomes a guardrail.

Signed-off-by: Christian Schüller <cschueller@nvidia.com>
@schuellc-nvidia
schuellc-nvidia requested review from a team as code owners August 10, 2026 13:25
@github-actions github-actions Bot added the feat 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: 8b7ef7c8-c51b-4901-a0fd-8c78fe3a3b7e

📥 Commits

Reviewing files that changed from the base of the PR and between 1e7f901 and f7e66e1.

📒 Files selected for processing (4)
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/analyzer.py
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py
  • plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/trace_analyzer.py
  • plugins/nemo-experimentalist/tests/test_experimentalist_analyzer.py

📝 Walkthrough

Walkthrough

Changes

Objective-based termination

Layer / File(s) Summary
Metric target satisfaction
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/config.py, plugins/nemo-experimentalist/tests/test_metric_contract.py
MetricTarget supports optional thresholds and evaluates maximize/minimize targets. Tests verify unset targets remain None.
Objective termination flow
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/terminator.py, plugins/nemo-experimentalist/tests/experimentalist/test_objective_reached.py
Terminator stops when an eligible candidate satisfies every targeted objective. Tests cover candidate eligibility, baselines, missing metrics, directions, and convergence settings.
Heterogeneous metric values
plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/analyzer.py, plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/proposer.py, plugins/nemo-experimentalist/src/nemo_experimentalist_plugin/experimentalist/components/trace_analyzer.py, plugins/nemo-experimentalist/tests/test_experimentalist_analyzer.py
Metric APIs accept arbitrary metric value types. Tests verify numeric objective targets pass through unchanged.

Sequence Diagram(s)

sequenceDiagram
  participant Terminator
  participant EvolutionTree
  participant MetricTarget
  Terminator->>EvolutionTree: inspect scored survivors and baseline nodes
  EvolutionTree-->>Terminator: return eligible candidates and metrics
  Terminator->>MetricTarget: evaluate configured targets
  MetricTarget-->>Terminator: return satisfaction results
  Terminator-->>Terminator: stop before budget or convergence checks
Loading

Suggested reviewers: arpitsardhana

🚥 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 the main change: stopping Experimentalist runs when the objective is reached.
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 feat-stop-when-objective-reached/cschueller

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

Twenty lines restating the commit message. Cut to the four things a caller needs:
how it differs from convergence, why `disable_convergence_check` does not gate it,
that it runs before the first round, and which candidates are eligible. The
reasoning behind each stays in the commit that introduced them.

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

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 31998/40622 78.8% 63.5%
Integration Tests 18552/38548 48.1% 20.8%

Signed-off-by: Gaia Di Lorenzo <gdilorenzo@ethz.ch>
@gaiadilorenzo
gaiadilorenzo added this pull request to the merge queue Aug 11, 2026
@gaiadilorenzo
gaiadilorenzo removed this pull request from the merge queue due to a manual request Aug 11, 2026
@gaiadilorenzo
gaiadilorenzo added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 2e55b4b Aug 11, 2026
57 checks passed
@gaiadilorenzo
gaiadilorenzo deleted the feat-stop-when-objective-reached/cschueller branch August 11, 2026 15:26
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