Resolve nested callable references in manager term configs - #7513
Conversation
Greptile SummaryThis PR makes observation-modifier lifecycle selection depend on an explicit stateful configuration type rather than callable implementation introspection.
Confidence Score: 5/5The PR appears safe to merge, with the documented breaking lifecycle migration consistently implemented and covered by focused regression tests. Stateful modifiers are now selected through an explicit configuration subtype, stateless lazy callables expose their underlying signatures, and no reachable unacknowledged failure remains in the reviewed paths. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
C[Observation modifier configuration] --> T{Configuration type}
T -->|ModifierBaseCfg| S[Resolve and construct ModifierBase]
S --> V[Validate constructed instance]
V --> SC[Call instance with observation tensor]
T -->|ModifierCfg| F[Resolve function signature]
F --> P[Validate configured parameters]
P --> FC[Call function with observation and params]
Reviews (1): Last reviewed commit: "Use config type for stateful observation..." | Re-trigger Greptile |
There was a problem hiding this comment.
Isaac Lab Review Bot
The configuration-type lifecycle discriminator fixes lazy round-trip handling and is consistently used during preparation and dispatch. However, the change removes previously supported class-based ModifierCfg behavior without the required deprecation path, and the new stateful configuration inherits an incompatible stateless func contract.
- Design and architecture: Selecting stateful modifiers through
ModifierBaseCfgavoids unreliable implementation introspection after serialization. The transition must retain deprecated handling for class-basedModifierCfgentries rather than silently routing them through stateless validation. - API:
ModifierBaseCfgis exported and accompanied by migration notes, but that does not satisfy the repository requirement for prior deprecation of a public API. Its inheritedCallable[..., torch.Tensor]annotation and tensor-first documentation also conflict with the constructor contract used by the manager;funcshould be declared as aModifierBaseimplementation type with matching documentation. - Implementation: The new preparation and compute paths consistently distinguish configuration types, and
ResolvableString.__signature__supports stateless signature validation. ExistingModifierCfg(func=MyModifier, ...)configurations now reach stateless signature validation and fail with a misleading parameter error instead of receiving compatibility handling and a deprecation warning.
Significant concerns. Posted 2 actionable findings inline.
Automated review; human maintainers own approval decisions.
| f"Modifier '{mod_cfg}' of observation term '{term_name}' is not callable. Received: {mod_cfg.func}" | ||
| ) | ||
|
|
||
| if isinstance(mod_cfg, modifiers.ModifierBaseCfg): |
There was a problem hiding this comment.
🟡 Warning · Api — Class modifiers removed without deprecation path
ModifierCfg.func previously documented and supported ModifierBase subclasses, and the manager instantiated any class-valued func. Now only ModifierBaseCfg is constructed, so existing ModifierCfg(func=MyModifier, params=...) falls into the stateless branch and fails with an unrelated "expects mandatory parameters: ['data_dim', 'device']" error. Keep constructing ModifierBase subclasses from ModifierCfg with a deprecation warning pointing at ModifierBaseCfg, or at minimum raise an explicit migration error.
|
|
||
|
|
||
| @configclass | ||
| class ModifierBaseCfg(ModifierCfg): |
There was a problem hiding this comment.
🔵 Suggestion · Api — ModifierBaseCfg inherits stateless func contract
ModifierBaseCfg inherits func: Callable[..., torch.Tensor] and the inherited docstring stating the callable takes a tensor first, but the manager invokes it as func(cfg=..., data_dim=..., device=...) and requires a ModifierBase instance. The documented ModifierBaseCfg(func=MyModifier) usage therefore contradicts its own annotation. Override func with type[ModifierBase] and a matching docstring, as DigitalFilterCfg and IntegratorCfg already do.
cfa7225 to
2bae5a3
Compare
f16ffda to
09255a4
Compare
ce945aa to
4413df1
Compare
2f12704 to
38f8304
Compare
38f8304 to
d6e39f3
Compare
d6e39f3 to
397c56f
Compare
|
run-ci |
| """A stateful modifier remains usable after its function becomes a lazy string.""" | ||
| cfg = HistoryObservationsCfg() | ||
| cfg.policy.history_length = None | ||
| cfg.policy.dummy.modifiers = [StatefulBiasModifierCfg(value=2.0)] |
There was a problem hiding this comment.
AI Review:
This test avoids the configuration that triggered #6067. The reported usage is ModifierCfg(func=MyModifier, params={...}), where params are read by the modifier constructor.
After this PR resolves and instantiates the class, mod_cfg.params remains populated. compute_group() then invokes the instance as:
obs = modifier.func(obs, **modifier.params)
For the reported call(self, data) implementation, this raises TypeError on the first observation computation. The dedicated config subclass and empty params used here hide that failure.
Please make the regression reproduce #6067 with a non-empty ModifierCfg.params and ensure constructor parameters are not forwarded to the constructed modifier’s call.
| elif isinstance(value, (list, tuple)): | ||
| for i, item in enumerate(value): | ||
| self._resolve_param_value(f"{term_name}.{key}", i, item) | ||
| elif not isinstance(value, type) and hasattr(value, "__dataclass_fields__") and hasattr(value, "__dict__"): |
There was a problem hiding this comment.
Can we use explicit dataclass/config-class check?
AI comment: The current duck-typing is difficult to read, skips slotted dataclasses, and attempts setattr() on frozen dataclasses
My comment: This type of hasattr setattr behaviour loses typing and adds misdirection that becomes unreadable
There was a problem hiding this comment.
can be replaced with dataclasses.is_dataclass() and iterating dataclasses.fields(). That avoids traversing unrelated runtime attributes stored in dict, but the discarded sequence results are the more worthwhile comment.
| def _resolve_param_value(self, term_name: str, key: str | int, value: Any) -> Any: | ||
| """Recursively resolve a value in a manager term configuration.""" | ||
| if key == "func" and isinstance(value, str): | ||
| return string_to_callable(value) |
There was a problem hiding this comment.
Could we avoid calling string_to_callable() directly from ManagerBase? Since ResolvableString already represents a lazy callable reference, it would be cleaner for it to expose a resolve() method and own the conversion logic.
That would let the manager resolve only explicit callable fields on known config types such as ManagerTermBaseCfg and ModifierCfg, instead of interpreting every string under a dictionary key named "func" as an import path. The latter can incorrectly affect arbitrary dictionaries in params, for example {"func": "linear"}.
Raw strings could still be supported on explicit func fields for backward compatibility. This keeps the recursive traversal generic while moving callable resolution to the abstraction that owns it.
| value[sub_key] = self._resolve_param_value(f"{term_name}.{key}", sub_key, sub_value) | ||
| elif isinstance(value, (list, tuple)): | ||
| for i, item in enumerate(value): | ||
| self._resolve_param_value(f"{term_name}.{key}", i, item) |
There was a problem hiding this comment.
The recursive calls for lists and tuples discard the returned value, so this only works when children happen to be mutated in place. Any resolver that replaces a value—such as resolving a ResolvableString into a callable—will not update the sequence.
Could we assign resolved list elements back and rebuild tuples? This would make _resolve_param_value() a consistent recursive transformation instead of relying on mutation as a side effect:
elif isinstance(value, list):
for i, item in enumerate(value):
value[i] = self._resolve_param_value(f"{term_name}.{key}", i, item)
elif isinstance(value, tuple):
value = tuple(
self._resolve_param_value(f"{term_name}.{key}", i, item)
for i, item in enumerate(value)
)
| elif isinstance(value, (list, tuple)): | ||
| for i, item in enumerate(value): | ||
| self._resolve_param_value(f"{term_name}.{key}", i, item) | ||
| elif not isinstance(value, type) and hasattr(value, "__dataclass_fields__") and hasattr(value, "__dict__"): |
There was a problem hiding this comment.
can be replaced with dataclasses.is_dataclass() and iterating dataclasses.fields(). That avoids traversing unrelated runtime attributes stored in dict, but the discarded sequence results are the more worthwhile comment.
StafaH
left a comment
There was a problem hiding this comment.
Left some comments, but overall LGTM
|
run-ci |
|
run-ci |
|
Backported to |
## Description After a Hydra / `from_dict()` round-trip, callable references nested outside `ManagerTermBaseCfg.params` can remain lazy string values. Observation modifiers are stored in `ObservationTermCfg.modifiers`, so the existing parameter recursion never reaches `ModifierCfg.func` before `ObservationManager` performs class detection and signature validation. This change extends the existing `ManagerBase._resolve_param_value` recursion instead of adding another resolution path: - `_process_term_cfg_at_play` walks every manager-term field rather than only `params` - `_resolve_param_value` traverses nested configuration dataclasses in addition to its existing dict, list, tuple, and nested-term cases - string-valued `func` fields are resolved at that single point - the root term uses the same resolver before its own signature validation - production code does not inspect or depend on the `ResolvableString` type - `ObservationManager`, `ModifierCfg`, and existing `params` forwarding behavior remain unchanged The modifier documentation now reflects the existing contract: function modifiers use `params` for call-time keyword arguments, while stateful class modifiers keep constructor settings on dedicated `ModifierCfg` subclasses. No second recursive walker, `class_type`, wrapper configuration, modifier dispatch branch, or callable-proxy introspection behavior is introduced. ## Release backport - [x] <!-- backport-active-release --> This PR already targets the active release branch; do not backport it again. ## Tests - Added an end-to-end observation modifier round-trip regression using a dedicated stateful modifier config and empty call-time `params` - Added a manager-level regression proving runtime traversal reaches term fields outside `params` - 92 focused manager, modifier, config, dictionary, and string tests passed - Formatting, lint, security, and RST checks passed - Changelog validation passed against the current upstream `develop` Fixes #6067 (cherry picked from commit befb2d4)
Description
After a Hydra /
from_dict()round-trip, callable references nested outsideManagerTermBaseCfg.paramscan remain lazy string values. Observation modifiers are stored inObservationTermCfg.modifiers, so the existing parameter recursion never reachesModifierCfg.funcbeforeObservationManagerperforms class detection and signature validation.This change extends the existing
ManagerBase._resolve_param_valuerecursion instead of adding another resolution path:_process_term_cfg_at_playwalks every manager-term field rather than onlyparams_resolve_param_valuetraverses nested configuration dataclasses in addition to its existing dict, list, tuple, and nested-term casesfuncfields are resolved at that single pointResolvableStringtypeObservationManager,ModifierCfg, and existingparamsforwarding behavior remain unchangedThe modifier documentation now reflects the existing contract: function modifiers use
paramsfor call-time keyword arguments, while stateful class modifiers keep constructor settings on dedicatedModifierCfgsubclasses.No second recursive walker,
class_type, wrapper configuration, modifier dispatch branch, or callable-proxy introspection behavior is introduced.Release backport
Tests
paramsparamsdevelopFixes #6067