Skip to content

Simplify preset resolution with single-pass resolve_presets#5177

Merged
AntoineRichard merged 13 commits intoisaac-sim:developfrom
ooctipus:fix/hydra
Apr 13, 2026
Merged

Simplify preset resolution with single-pass resolve_presets#5177
AntoineRichard merged 13 commits intoisaac-sim:developfrom
ooctipus:fix/hydra

Conversation

@ooctipus
Copy link
Copy Markdown
Collaborator

@ooctipus ooctipus commented Apr 6, 2026

Description

  • Add resolve_presets(cfg, selected) — a single-pass function that walks the config tree and replaces each PresetCfg with the CLI-selected or default alternative. This fixes bugs where presets inside dict-valued alternatives and PresetCfg(default=None) were not being discovered or resolved, causing deeply nested configs to silently use wrong defaults.

  • Unknown or unresolvable preset names now raise ValueError with a grouped listing of available presets and the paths they affect.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read and understood the contribution guidelines
  • I have run the pre-commit checks with ./isaaclab.sh --format
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the changelog and the corresponding version in the extension's config/extension.toml file
  • I have added my name to the CONTRIBUTORS.md or my name already exists there

@github-actions github-actions Bot added bug Something isn't working documentation Improvements or additions to documentation asset New asset feature or request isaac-sim Related to Isaac Sim team isaac-mimic Related to Isaac Mimic team infrastructure labels Apr 6, 2026
@ooctipus ooctipus marked this pull request as draft April 6, 2026 00:40
@ooctipus ooctipus changed the base branch from main to develop April 6, 2026 00:41
@ooctipus ooctipus marked this pull request as ready for review April 6, 2026 00:41
@ooctipus ooctipus changed the title Simplify preset resolution with single-pass resolve_presets and fix deeply nested preset bugs Simplifies preset resolution with single-pass resolve_presets and fix deeply nested preset bugs Apr 6, 2026
@ooctipus ooctipus self-assigned this Apr 6, 2026
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #5177 — Simplify preset resolution with single-pass resolve_presets

Summary

This PR replaces the two-phase resolve_preset_defaultsapply_overrides flow with a single-pass resolve_presets function that walks the config tree once, resolving every PresetCfg in-place. It also fixes two real bugs:

  1. collect_presets didn't recurse into dict-valued alternatives — deeply nested configs were silently using wrong defaults.
  2. resolve_preset_defaults silently skipped PresetCfg(default=None) — leaving the PresetCfg wrapper in the tree instead of replacing it with None.

Both fixes are correct. The test coverage is comprehensive (new tests for dict-valued alternatives, deeply nested dicts, default=None, class-level overrides, and selected name propagation). The refactoring of test boilerplate into a shared _apply helper is clean. The __init__.pyi cleanup removing the duplicate import line is good.

Design Assessment

The approach is sound. Moving preset resolution earlier (in register_task with CLI selection) and making apply_overrides handle only path-based selections simplifies the mental model. The defensive resolution of alternatives inside the collected presets dict is a good touch.

One architectural note: resolve_cfg_presets in configclass.py (used by the gym.make() path) has the same dict-traversal bug being fixed here in collect_presets. That's out of scope for this PR but worth a follow-up.

Issues Found

See inline comments below.

val = _pick_alternative(preset_obj, selected)
if val is preset_obj:
return
while isinstance(val, PresetCfg):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Potential infinite loop when a replacement PresetCfg has no default and no matching selected name.

If _pick_alternative(val, selected) returns val itself (no matching field, no default), this while loop never terminates because isinstance(val, PresetCfg) remains True.

Reproduction scenario:

@configclass
class InnerNoDefault(PresetCfg):
    option_a: int = 1  # no "default" field

@configclass  
class OuterCfg(PresetCfg):
    default: InnerNoDefault = InnerNoDefault()

resolve_presets(OuterCfg()) → picks defaultInnerNoDefault() → while loop calls _pick_alternative(InnerNoDefault(), frozenset()) → returns InnerNoDefault() itself → infinite loop.

The root-level path (line 209) correctly handles this with if replacement is cfg: return cfg, but the _resolve callback's while loop lacks the same guard.

Suggested change
while isinstance(val, PresetCfg):
def _resolve(parent, key, preset_obj, _path):
val = _pick_alternative(preset_obj, selected)
if val is preset_obj:
return
while isinstance(val, PresetCfg):
next_val = _pick_alternative(val, selected)
if next_val is val:
break
val = next_val

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ooctipus the bot looks correct? Maybe this would never happen though

# Preset resolution
# ============================================================================


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Non-deterministic selection order when multiple preset names match.

selected is typed as set[str] (unordered). The for name in selected loop picks the "first" match, but sets have no guaranteed iteration order in Python. If a user passes presets=newton,physx and a PresetCfg has both newton and physx fields, the result depends on Python's internal hash ordering.

This isn't a problem in practice today since PresetCfg alternatives are typically disjoint, but it's a latent bug that could surprise users. Consider using a list (or dict.fromkeys() to preserve insertion order while deduplicating) for selected:

Suggested change
def _pick_alternative(preset_obj: PresetCfg, selected: list[str] | set[str]):

And in register_task, build selected as a list:

selected: list[str] = []
for arg in sys.argv[1:]:
    if "=" in arg:
        key, val = arg.split("=", 1)
        if key.lstrip("-") == "presets":
            selected.extend(v.strip() for v in val.split(",") if v.strip() and v.strip() not in selected)

agent_cfg = resolve_presets(agent_cfg, selected)

# Also resolve presets inside collected alternatives so that apply_overrides
# never re-introduces unresolved PresetCfg objects when applying a selection.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Duplicated CLI preset parsing — register_task re-parses sys.argv for preset names.

parse_overrides (called later in _run_hydra) also extracts global presets from sys.argv. The register_task function re-implements this extraction to call resolve_presets early, before Hydra runs. This creates a maintenance hazard — if someone changes the presets= parsing logic in parse_overrides, they'd need to update register_task too.

Consider factoring the sys.argvselected: set[str] extraction into a small shared helper, or calling parse_overrides early (it only needs the presets dict and args, both available at this point).

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 6, 2026

Greptile Summary

This PR replaces the multi-phase resolve_preset_defaults with a new single-pass resolve_presets that correctly handles presets inside dict-valued alternatives, fixing a silent wrong-default bug in deeply nested configs. The implementation is well-tested for the primary resolution path (resolve_presets) and collect_presets discovery, but has two edge-case issues in hydra.py worth addressing before merging.

Confidence Score: 4/5

Safe to merge with minor issues — the primary fix and resolution path work correctly; remaining findings are edge-case bugs and style issues.

The two logic findings in hydra.py (infinite loop and dict-key omission) are both P2 edge cases that do not affect the primary resolution path exercised by CI tests. They could silently misbehave for unusual preset shapes or path-based CLI overrides on dict-nested presets, but the core fix is sound and well-tested.

source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py — the _resolve while loop guard and collect_presets._record dict traversal

Important Files Changed

Filename Overview
source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py Core preset resolution refactored to single-pass walk; introduces potential infinite loop in _resolve while loop and path-key omission in collect_presets._record for dict-valued alternatives
source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py Adds resolve_presets call in parse_env_cfg before attribute overrides to avoid discarding overrides on PresetCfg wrapper fields
source/isaaclab_tasks/isaaclab_tasks/utils/init.pyi Adds resolve_presets to public exports, consistent with its usage in parse_cfg.py
source/isaaclab_tasks/test/test_hydra.py Comprehensive tests for single-pass preset resolution covering nested presets, dict-valued alternatives, scalar presets, root-level presets, and error handling
source/isaaclab_tasks/test/test_shadow_hand_vision_presets.py New preset resolution and validation tests for shadow hand vision env; contains a leftover commented-out pdb breakpoint on line 421
source/isaaclab_tasks/docs/CHANGELOG.rst Version bumped to 1.5.19 with correct Added/Fixed entries; Changed entry lacks migration guidance per project guidelines
source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py New test verifying all terms appear in non-concatenated observation groups — unrelated to the preset system

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[register_task] --> B[collect_presets]
    B --> C{Node is PresetCfg?}
    C -->|Yes| D[_record: store alternatives at path]
    D --> E{Alternative is dataclass?}
    E -->|Yes| F[Recurse collect_presets\nwith preset_path prefix]
    E -->|No - alt is dict| G[Iterate dict values\nRecurse collect_presets\nwith preset_path — dict key skipped ⚠]
    C -->|No - is dataclass or dict| H[_walk_cfg recursive traversal]
    A --> I[resolve_presets with selected names]
    I --> J[_walk_cfg cfg]
    J --> K{Found PresetCfg node?}
    K -->|Yes| L[_pick_alternative]
    L --> M{Resolved val is PresetCfg?}
    M -->|Yes - while loop| N{next_val is val?}
    N -->|Yes - no default and no match| O[⚠ Infinite loop risk]
    N -->|No| L
    M -->|No| P[Replace node in parent]
    P --> Q[Continue walk into replacement]
    A --> R[apply_overrides]
    R --> S{Path in presets dict?}
    S -->|Dict-nested sub-preset| T[⚠ Key omitted — path mismatch, silently ignored]
    S -->|Standard paths| U[Apply chosen alternative]
Loading

Comments Outside Diff (2)

  1. source/isaaclab_tasks/test/test_shadow_hand_vision_presets.py, line 421 (link)

    P2 Leftover debug statement

    A commented-out pdb breakpoint was left in the test body and should be removed before merging.

  2. source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py, line 119-123 (link)

    P2 dir() traversal includes non-config attributes

    _walk_cfg uses dir(cfg) to enumerate attributes of a configclass, which includes inherited methods, class-level non-field attributes, and properties not declared in __dataclass_fields__. Using cfg.__dataclass_fields__ is more precise and consistent with how _preset_fields iterates preset_obj.__dataclass_fields__. This also avoids accidentally calling properties with side-effects via getattr.

Reviews (1): Last reviewed commit: "fix hydra" | Re-trigger Greptile

Comment thread source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py Outdated
Comment thread source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py
Comment thread source/isaaclab_tasks/docs/CHANGELOG.rst
@github-actions github-actions Bot added the isaac-lab Related to Isaac Lab team label Apr 6, 2026
@ooctipus ooctipus changed the title Simplifies preset resolution with single-pass resolve_presets and fix deeply nested preset bugs Simplify preset resolution with single-pass resolve_presets Apr 6, 2026
Copy link
Copy Markdown
Collaborator

@AntoineRichard AntoineRichard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would look into the bot's feedback, otherwise it looks like it's doing what it should. Though I feel like the hydra thing is starting to be heavy.


_HYDRA_AVAILABLE = True
except ImportError:
raise ImportError("Hydra not installed. Run: pip install hydra-core")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to support environments without HYDRA? At this point HYDRA is so important to lab that I don't see why we would bother supporting non-HYDRA environments.

val = _pick_alternative(preset_obj, selected)
if val is preset_obj:
return
while isinstance(val, PresetCfg):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ooctipus the bot looks correct? Maybe this would never happen though

Comment thread source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Isaac Lab Review Bot — Follow-up Review

Changes Since Last Review

The author addressed the main concern from our previous review:

Infinite loop / unresolvable PresetCfg_pick_alternative now raises ValueError when no default field exists and no selected name matches. Both the root-level and nested resolution paths use seen: set[int] to detect cyclic chains. Four new test cases cover these scenarios (test_resolve_presets_errors_on_no_default, test_resolve_presets_errors_on_chained_no_default, test_resolve_presets_errors_on_cyclic_preset, test_resolve_presets_errors_on_cyclic_preset_at_root).

Current State

The code is in good shape. The remaining items from the initial review are minor and non-blocking:

  1. Non-deterministic set iteration (line 180) — selected is set[str], so when multiple preset names match the same PresetCfg, the winner depends on Python hash ordering. Low risk since preset alternatives are typically disjoint, but worth documenting.

  2. Duplicated CLI parsing (line 367-375) — register_task re-parses sys.argv for preset names independently of parse_overrides. A shared helper would reduce maintenance risk but isn't a blocker.

CI Status

CI failures (isaaclab (core), isaaclab_tasks [1/3], [2/3]) are unrelated to this PR — they're sensor/camera rendering test flakes (test_first_frame_textured_rendering, test_tiled_camera). The hydra tests (test_hydra.py) run in isaaclab_tasks [3/3] which passed. Pre-commit also passed.

Verdict

No new blocking issues. The cycle detection and error handling improvements are solid. LGTM pending the CI re-runs on unrelated failures.

elif isinstance(alt, dict):
for v in alt.values():
if hasattr(v, "__dataclass_fields__"):
result.update(collect_presets(v, preset_path))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nice fix — This correctly discovers PresetCfg nodes inside dict-valued alternatives (e.g. default={"strategy_a": InnerTermCfg()} where InnerTermCfg contains further PresetCfg fields). Without this, deeply nested presets inside dict alternatives were silently missed.

Minor note: the dict key (k in for k, v in alt.items()) isn't included in the recursive preset_path, so the discovered paths won't include the dict key segment. This works because _walk_cfg handles full path construction during resolution, and collect_presets paths are only used for override matching (where the dict key is part of the parent's path). Just flagging for awareness.

while isinstance(val, PresetCfg):
if id(val) in seen:
raise ValueError(
f"Cyclic PresetCfg chain detected at '{_path}': {type(val).__name__} was already visited."
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Previous concern resolved — The cycle detection via seen: set[int] correctly handles the case where a chained PresetCfg loops back to a previously visited node. Combined with _pick_alternative raising ValueError for no-default presets, this eliminates both the infinite loop and the silent failure modes from the previous implementation.

@AntoineRichard AntoineRichard merged commit 720161c into isaac-sim:develop Apr 13, 2026
5 checks passed
xiangdonglai pushed a commit to xiangdonglai/IsaacLab that referenced this pull request Apr 15, 2026
…m#5177)

# Description

- Add `resolve_presets(cfg, selected)` — a single-pass function that
walks the config tree and replaces each `PresetCfg` with the
CLI-selected or default alternative. This fixes bugs where presets
inside dict-valued alternatives and `PresetCfg(default=None)` were not
being discovered or resolved, causing deeply nested configs to silently
use wrong defaults.

- Unknown or unresolvable preset names now raise `ValueError` with a
grouped listing of available presets and the paths they affect.

## Type of change

- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)

## Checklist

- [x] I have read and understood the [contribution
guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html)
- [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with
`./isaaclab.sh --format`
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] I have updated the changelog and the corresponding version in the
extension's `config/extension.toml` file
- [x] I have added my name to the `CONTRIBUTORS.md` or my name already
exists there

---------

Co-authored-by: Antoine RICHARD <antoiner@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

asset New asset feature or request bug Something isn't working documentation Improvements or additions to documentation infrastructure isaac-lab Related to Isaac Lab team isaac-mimic Related to Isaac Mimic team isaac-sim Related to Isaac Sim team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants