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
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.
There was a problem hiding this comment.
Hi @planetf1, this behaviour predates this change: BaseMBRDSampling._sample() has always fanned out number_of_samples branches and returned only results[maxR][1]. I have documented this in the docs and docstrings of the payload. Please let me know if you want to change this behaviour to aggregate all the branches into the returned result.
|
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! |
Signed-off-by: Vishal V <VishalV@ibm.com>
Co-authored-by: Nigel Jones <nigel.l.jones+git@gmail.com> Signed-off-by: Vishal V <56761954+cptnm3@users.noreply.github.com>
planetf1
left a comment
There was a problem hiding this comment.
Two regressions from this refactor, plus a one-liner, below. Prior rounds' items checked against this head and are resolved.
| sample_index=i, | ||
| ) | ||
| ) | ||
| tasks.append(task) |
There was a problem hiding this comment.
Two lines below (sampling_results = await asyncio.gather(*tasks), not shown in this diff so no inline anchor for it): branches now share one sampling_id. On failure:
asyncio.gatherdoesn't cancel the other branches — they keep runningsample()'sfinallycloses the shared span immediately- surviving branches' events get silently dropped (
tracing.py:815), and they keep burning backend calls for nothing - no test covers this path
Fix — wrap the gather call:
try:
sampling_results = await asyncio.gather(*tasks)
except BaseException:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise(same pattern as base.py:336)
| model_options: dict | None = None, | ||
| tool_calls: bool = False, | ||
| show_progress: bool = True, | ||
| **kwargs, |
There was a problem hiding this comment.
- Pre-PR: SOFAI's
sample()took no**kwargs→show_progress=FalseraisedTypeErrorif unsupported - Post-PR:
sample()forwardsshow_progressthrough**kwargs, but SOFAI's_sample()has no such param — silently absorbed and ignored (recomputed from log level instead,sofai.py:667) - same silent-swallow applies to any typo'd kwarg, on any strategy
Suggest dropping **kwargs from sample() (it's @final, only caller passes no extras) so bad keywords raise; give SOFAI an explicit show_progress param if it should honour it.
|
Not part of the diff, but same gap
Neither blocks merge. |
Co-authored-by: Nigel Jones <nigel.l.jones+git@gmail.com> Signed-off-by: Vishal V <56761954+cptnm3@users.noreply.github.com>
- use show_progress passed to sofai instead of deriving it - add sample_index to majority_voting and budget_forcing _sample() to satisfy the override structure. Signed-off-by: Vishal V <VishalV@ibm.com>
|
Hi @planetf1, I have addressed your comments, please let me know if there are any open issues that need to be addressed. Thanks! |
ajbozarth
left a comment
There was a problem hiding this comment.
Some feedback from Claude:
Re-reviewed at a081fc0. My earlier nits (@final, _sample naming, base-class loop_budget/requirements, majority-vote sample_index, docs) and @planetf1's **kwargs and list(set()) ordering items are all addressed. Requesting changes on one item: the majority-voting cancellation path is now correct in code but still untested (inline). Three smaller nits inline too — none blocking on their own.
|
|
||
| sampling_results = await asyncio.gather(*tasks) | ||
| try: | ||
| sampling_results = await asyncio.gather(*tasks) |
There was a problem hiding this comment.
The cancel-and-drain guard is correct and matches the base.py producer-task pattern. The one item still open from @planetf1's review is that this path has no test. A branch raising should cancel the siblings and propagate, rather than leaving them running against the shared sampling_id (whose span the wrapper's finally has already closed). Since our review guidelines treat a missing test as blocking, requesting changes to add one: make a single branch's backend raise, then assert the exception propagates and the sibling tasks end up cancelled.
There was a problem hiding this comment.
Sorry I missed this. I have added test_branch_exception_cancels_siblings_and_propagates test for this path.
| tool_calls: bool = False, | ||
| sampling_id: str, | ||
| show_progress: bool = True, | ||
| sample_index: int | None = None, |
There was a problem hiding this comment.
nit: sample_index is accepted here but never passed to the _emit_sampling_iteration / _emit_sampling_repair calls below (and majority_voting._sample accepts it as the fan-out parent, where it's always None) — so it's inert in both today. Budget forcing isn't fanned-out-into, but if it ever were, its iteration/repair events would silently drop the branch index. Either drop the param or thread it through the _emit_* calls.
There was a problem hiding this comment.
Threaded sample_index in Buget forcing through _emit_* calls. But it can not be dropped in majority_voting._sample as it causes mypy to raise
error: Signature of "_sample" incompatible with supertype "mellea.stdlib.sampling.base.BaseSamplingStrategy" [override].
Do you want me to override it instead?
|
|
||
| repair_payload = SamplingRepairPayload( | ||
| sampling_id=sampling_id, | ||
| repair_type=getattr(self, "_get_repair_type", lambda: "unknown")(), |
There was a problem hiding this comment.
nit (pre-existing, optional): this is the same getattr(self, ...) shim you removed for loop_budget / requirements. Base rejection sampling defines no _get_repair_type, so its repair events carry repair_type="unknown". Declaring _get_repair_type(self) -> str: return "unknown" on SamplingStrategy would let this read self._get_repair_type() and finish that cleanup.
planetf1
left a comment
There was a problem hiding this comment.
CI's code-checks / quality is failing on 3.11/3.12/3.13 with a real error, not a flake:
test/stdlib/test_context_type_enforcement.py:429: error: Cannot override final attribute "sample" (previously declared in base class "SamplingStrategy") [misc]
test/stdlib/test_context_type_enforcement.py:429: error: Signature of "sample" incompatible with supertype "mellea.core.sampling.SamplingStrategy" [override]
_StubStrategy in that file (added on main after this branch forked, so it won't show in this PR's diff) subclasses SamplingStrategy and overrides sample() directly — now illegal now that sample() is @final. Needs renaming to override _sample/_sample_impl instead, matching the new template-method contract the rest of this PR migrates to.
Re-checked against a081fc0d: all three items from my last round are addressed —
majority_voting.pygather now cancels and drains sibling tasks on failure, matching thebase.pypattern.sample()dropped**kwargsfor an explicitshow_progressparam, so typo'd kwargs raise again._merge_requirementsnow preserves order (dict.fromkeys) instead oflist(set(...)).
The plugins.mdx doc gap from round 1 is fixed too (sample_index/sampling_id now listed).
I'm happy with the state of my own findings. Once @ajbozarth's outstanding item (missing test for the gather cancellation path) is addressed and the mypy CI failure above is fixed, this looks good to merge from my side.
Co-authored-by: Alex Bozarth <ajbozart@us.ibm.com> Signed-off-by: Vishal V <56761954+cptnm3@users.noreply.github.com>
…propagates and the sibling tasks end up cancelled. - Thread sample_index through emit* for budget_forcing - Remove getattr shim - Fixes for mypy errors Signed-off-by: Vishal V <VishalV@ibm.com>
Signed-off-by: Vishal V <VishalV@ibm.com>
Signed-off-by: Vishal V <VishalV@ibm.com>
|
Somehow the pre-commit did not catch the reasons for CI failure (again). I have made the requested changes. Thank you @planetf1 @ajbozarth for the through review and suggestions! |
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.