fix(sampling): sampling span missing for strategies that override sam… - #1584
fix(sampling): sampling span missing for strategies that override sam…#1584cptnm3 wants to merge 2 commits into
Conversation
…ple() Move the sampling lifecycle boundary from BaseSamplingStrategy.sample() into the SamplingStrategy base class. SamplingStrategy.sample() now owns: - requirement merging and deduplication - sampling_id creation - sampling_loop_start dispatch and effective loop budget resolution - validation of hook-modified loop budgets - exception handling and error-path lifecycle closure - sampling_loop_end dispatch Concrete strategies now implement _sample_impl() for their sampling algorithm. Shared helpers centralize sampling iteration and repair payload construction and hook dispatch. Migrate BaseSamplingStrategy, BudgetForcingSamplingStrategy, SOFAISamplingStrategy, and majority-voting to the new contract. This ensures every top-level sample() call emits exactly one enclosing sampling lifecycle, regardless of whether a strategy uses the base sampling loop, implements its own loop, or fans out into multiple inner samples. Intentional behavior changes: - Majority voting emits one enclosing lifecycle for the top-level majority-vote operation instead of one lifecycle per inner sample. - Budget Forcing and SOFAI emit sampling iteration and repair events for their strategy-specific attempts and repairs. - Budget Forcing and SOFAI do not perform repairs after the final allowed failed iteration, since the repaired action/context cannot be consumed, aligning them with BaseSamplingStrategy. - sampling_loop_end fires for failures during lifecycle setup, including requirement merging, start-hook execution, and invalid hook-modified loop budgets, allowing lifecycle consumers to close error paths. Add firing-site and regression coverage for: - strategies overriding only _sample_impl() - sampling_id propagation and correlation - start-hook exceptions and lifecycle setup failures - hook-modified effective loop budgets - invalid effective loop budgets and error-path closure - Budget Forcing iteration and repair events - SOFAI S1/S2 iteration and repair behavior - majority-voting iteration and repair events - a single enclosing lifecycle for majority voting Assisted-by: IBM Bob Signed-off-by: Vishal V <VishalV@ibm.com>
Sampling Strategy PR ChecklistUse this checklist when adding or modifying sampling strategies in Base Class
Return Value
Integration
|
|
Just got back from vacation, I'll deep dive review this by EOW. Thank you for the contribution |
ajbozarth
left a comment
There was a problem hiding this comment.
Thanks for taking this on. Solid PR — cleanly mirrors the generate_from_raw split, and firing sampling_loop_end on setup failures (with tests) is a nice touch beyond the issue scope.
One item that has no diff anchor: docs/docs/community/building-extensions.md still teaches extension authors to override sample(). After this change a strategy overriding only sample() is missing _sample_impl and can't be instantiated — please update that page to override _sample_impl instead (leave docs/versioned_docs/** alone, it's a frozen version snapshot).
Inline: answers to your two questions, plus suggestions on @final, method naming, and declaring loop_budget/requirements on the base class.
…e hook payloads BaseMBRDSampling._sample fans out number_of_samples concurrent calls to BaseSamplingStrategy._sample, each receiving the same sampling_id and starting their own _subsample_iteration loop from subsample_index=0. This caused all branches to emit the same iteration numbers under one sampling_id, making (sampling_id, iteration) non-unique for consumers of SAMPLING_ITERATION and SAMPLING_REPAIR hooks. Fix by introducing sample_index: int | None = None on both payload classes and threading it through the emit helpers and _subsample_iteration so each fan-out branch carries a distinct 0-based ordinal. Non-fan-out strategies leave sample_index=None; no existing call sites change. - Renamed _sample_impl to _sample - Marked sample method as @Final enforces that subclasses override _sample rather than sample() - Add sample_index field to SamplingIterationPayload and SamplingRepairPayload (mellea/plugins/hooks/sampling.py) - Add sample_index kwarg to _emit_sampling_iteration and _emit_sampling_repair, forwarded to the payload (mellea/core/sampling.py) - Add sample_index param to BaseSamplingStrategy._sample and _subsample_iteration; forward to both _emit_* calls (mellea/stdlib/sampling/base.py) - Pass sample_index=i in the BaseMBRDSampling fan-out loop (mellea/stdlib/sampling/majority_voting.py) - Emit mellea.sampling.sample_index span-event attribute in SamplingTracingPlugin.on_iteration and on_repair when not None (mellea/telemetry/tracing_plugins.py) Tests added: - test_majority_vote_iteration_sample_index_is_unique: e2e regression proving (sampling_id, sample_index, iteration) is unique across all branches with number_of_samples=3, loop_budget=2 - test_majority_vote_repair_sample_index_matches_branch: repair events carry the same sample_index as the failed iteration that triggered them - test_sample_index_defaults_to_none / test_sample_index_construction on both payload classes - test_sampling_iteration_includes/omits_sample_index_when_set/none - test_sampling_repair_includes/omits_sample_index_when_set/none Assisted-by: IBM Bob Signed-off-by: Vishal V <VishalV@ibm.com>
ajbozarth
left a comment
There was a problem hiding this comment.
one docs nit otherwise this looks good, but I'll defer to @jakelorocco for final approval as he knows this code better than me and might catch things I missed
| | Pathway | When to use | | ||
| | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | | ||
| | **Core repository** | General-purpose additions that benefit all users — open an issue first to discuss placement | | ||
| | **Your own repo** (`mellea-` prefix) | Application-specific or domain-specific libraries | | ||
| | **[mellea-contribs](https://github.com/generative-computing/mellea-contribs)** | Experimental or specialized components not yet ready for the standard library | |
There was a problem hiding this comment.
nit: I see no reason to add all this whitespace and dashes
planetf1
left a comment
There was a problem hiding this comment.
Nicely structured refactor — the @final sample() / _sample() split is a clean way to guarantee the lifecycle wrapper can't be bypassed, and the new tests for setup-failure lifecycle closure are a real improvement. Two things below are worth fixing before merge; a couple of smaller ones are optional.
One doc note that doesn't have a line in this diff to anchor to: docs/docs/concepts/plugins.mdx documents the sampling_iteration/sampling_repair payload fields but doesn't list the new sample_index field yet — worth a follow-up so plugin authors know it exists.
| sampling_id=sampling_id, | ||
| strategy_name=type(self).__name__, | ||
| success=s_result.success, | ||
| iterations_used=len(s_result.sample_generations), |
There was a problem hiding this comment.
With majority voting, iterations_used/all_results/all_validations here only reflect the winning branch's SamplingResult (BaseMBRDSampling._sample returns results[maxR][1]) — the other number_of_samples - 1 branches' generations aren't included anywhere. That's a real gap against what SamplingLoopEndPayload promises (all_results: List of ModelOutputThunk from every iteration), and it'll skew anything downstream that counts on this — e.g. the sampling-failures metric moves from "per branch" to "per top-level call" for fan-out strategies.
Worth either aggregating all branches into the result _sample returns, or updating the docstrings here (and in docs/docs/observability/tracing.md) to say "of the selected branch" so plugin authors aren't misled.
|
|
||
| try: | ||
| reqs = self._merge_requirements(requirements) | ||
| effective_loop_budget = self.loop_budget |
There was a problem hiding this comment.
Before this PR, the whole sampling loop — including the start/end hook dispatch — ran inside log_context(strategy=..., loop_budget=...). In this wrapper, log_context is only opened inside _sample, which runs after sampling_loop_start and before sampling_loop_end fires in the finally. So a plugin that logs from inside a sampling_loop_start/sampling_loop_end handler (the built-in debug plugin does this) loses the strategy/loop_budget fields on those specific log lines.
Not a functional break — just missing context for anyone debugging via logs on those two hook types. Suggest wrapping this method's try body in with log_context(strategy=type(self).__name__): — nesting is safe per log_context's own docs, and _sample's inner call still shadows loop_budget with the post-hook value.
| show_progress=False, | ||
| ) | ||
|
|
||
| assert len(observed) == 3 |
There was a problem hiding this comment.
This test's name and docstring promise it checks that the S2 escalation fires as the final iteration number, but the assertion only checks the count — a regression to the wrong iteration number (e.g. loop_count instead of loop_count + 1) would still pass this test.
| assert len(observed) == 3 | |
| assert len(observed) == 3 | |
| assert observed[-1].iteration == 3 |
| @@ -138,21 +147,246 @@ async def sample( | |||
| format: type[BaseModelSubclass] | None = None, | |||
| model_options: dict | None = None, | |||
| tool_calls: bool = False, | |||
There was a problem hiding this comment.
Minor: sample() now accepts **kwargs and forwards it straight through to every _sample(), so a misspelled keyword (e.g. show_progres instead of show_progress) silently gets swallowed instead of raising TypeError like the old explicit signatures did. Not urgent, but worth naming show_progress explicitly if you want to keep that safety net.
|
I did not get a chance to work on requested changes last week. I'll make the changes by end of this week. Sorry for the delay! |
Pull Request
Issue
Fixes #1487
Description
Move the sampling lifecycle boundary from BaseSamplingStrategy.sample() into the SamplingStrategy base class.
SamplingStrategy.sample() now owns:
Concrete strategies now implement _sample_impl() for their sampling algorithm. Shared helpers centralize sampling iteration and repair payload construction and hook dispatch.
Migrate BaseSamplingStrategy, BudgetForcingSamplingStrategy, SOFAISamplingStrategy, and majority-voting to the new contract.
This ensures every top-level sample() call emits exactly one enclosing sampling lifecycle, regardless of whether a strategy uses the base sampling loop, implements its own loop, or fans out into multiple inner samples.
Intentional behavior changes:
Majority voting emits one enclosing lifecycle for the top-level majority-vote operation instead of one lifecycle per inner sample.
Budget Forcing and SOFAI emit sampling iteration and repair events for their strategy-specific attempts and repairs.
Budget Forcing and SOFAI do not perform repairs after the final allowed failed iteration, since the repaired action/context cannot be consumed, aligning them with BaseSamplingStrategy.
sampling_loop_end fires for failures during lifecycle setup, including requirement merging, start-hook execution, and invalid hook-modified loop budgets, allowing lifecycle consumers to close error paths.
Add firing-site and regression coverage for:
Assisted-by: IBM Bob
Testing
Attribution
Adding a new component, requirement, sampling strategy, or tool?
If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.
NOTE: Please ensure you have an issue that has been acknowledged by a core contributor and routed you to open a pull request against this repository. Otherwise, please open an issue before continuing with this pull request.