Makes DataGenConfig.max_num_failures actually bound a Mimic generation run - #7433
Conversation
DataGenConfig.max_num_failures is documented as "Maximum number of failures allowed before stopping generation" and eighteen shipped env configs set it to 25, but nothing ever reads it. The field has been inert since Isaac Lab Mimic was introduced in isaac-sim#179 -- `git grep max_num_failures` returns one definition, eighteen assignments and no reads, and env_loop's only termination is check_val = num_success if generation_guarantee else num_attempts if check_val >= generation_num_trials: num_failures is counted and then never consulted. With generation_guarantee on (which all eighteen configs also set) a task whose success rate is low retries without any bound, and the one knob that claims to stop that does nothing. Measured on the stock Isaac-Stack-Cube-Franka-IK-Rel-Mimic-v0 with max_num_failures=25: generation ran to 30 successes / 50 failures / 80 attempts, twice the configured cap, without stopping. Our own data generation hit 337 attempts for 10 demos before we found out the cap was not real. Wiring the field up exactly as documented would abort the primary documented workflow: at the ~35% success rate of the shipped stack task, `--generation_num_trials 1000` needs on the order of 1800 failures, so a cap of 25 would end the run after roughly forty attempts. The eighteen assignments were inert when they were written and no current behaviour depends on them, so they are removed and the default becomes None (no limit). Runs behave exactly as before unless a limit is asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFz91VMgmrjS4XR6f9Q9Z2
One per touched package. Both note that the cap is opt-in, so existing runs are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFz91VMgmrjS4XR6f9Q9Z2
…vironment env_loop is driven with a fake env whose step() consumes one scripted attempt outcome per call and refills the action queue, so the loop's own termination logic runs unmodified with no simulator, data generator, or dataset behind it. A step-count fuse turns an unbounded loop into a failed assertion rather than a hang. The cases: a run that never succeeds stops at the cap; None keeps the success guarantee unbounded exactly as before; enough successes still end the run first; a cap reached short of the target ends it; and the attempt-based termination with the guarantee off is untouched. Run against the unpatched loop the first case fails with 'fuse' == 'exited', which is the defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFz91VMgmrjS4XR6f9Q9Z2
Greptile SummaryThe PR activates DataGenConfig.max_num_failures as an optional Mimic generation bound, changes its default to no limit, removes inert per-task assignments, and adds regression coverage.
Confidence Score: 4/5The attempt-based generation regression should be fixed before merging because a finite failure cap can terminate a run before its requested attempt count. The new condition is unconditional even though both the configuration contract and existing branch logic distinguish success-guaranteed runs from fixed-attempt runs. Files Needing Attention: source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py and source/isaaclab_mimic/test/test_generation_failure_cap.py Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Completed generation attempt] --> B{Guarantee enabled?}
B -->|Yes| C{Successes reached target?}
B -->|No| D{Attempts reached target?}
C -->|Yes| E[Exit]
D -->|Yes| E
C -->|No| F{Failure cap configured and reached?}
D -->|No| F
F -->|Yes| E
F -->|No| A
Reviews (1): Last reviewed commit: "Adds Kai Pei to CONTRIBUTORS.md" | Re-trigger Greptile |
| # with the success guarantee on, nothing else bounds the run: a task that rarely | ||
| # succeeds retries forever. max_num_failures is the opt-in bound on that. | ||
| max_num_failures = env.cfg.datagen_config.max_num_failures | ||
| if max_num_failures is not None and num_failures >= max_num_failures: |
There was a problem hiding this comment.
Failure cap overrides attempt mode
When generation_guarantee=False and max_num_failures is lower than generation_num_trials, this unconditional check exits after the capped number of failures instead of completing the requested number of attempts, violating attempt-based generation's documented behavior.
| if max_num_failures is not None and num_failures >= max_num_failures: | |
| if generation_guarantee and max_num_failures is not None and num_failures >= max_num_failures: |
Knowledge Base Used: Imitation learning and data generation
There was a problem hiding this comment.
Good catch, fixed in 3bcd74c — the check is now gated on generation_guarantee.
You are right that the docstring and the code disagreed, and gating is the better way to resolve it rather than loosening the wording: with the guarantee off, the existing check_val >= generation_num_trials already bounds the run, so the cap has nothing to add there and must not cut a fixed-attempt run short.
I also extended the field's docstring to say so explicitly, so the "does this apply to attempt mode" question does not have to be re-derived from the loop.
The attempt-mode test was parametrised over caps of None and 100 against 10 requested attempts, so it never exercised a cap that could actually fire — exactly the gap you identified. It now also runs with a cap of 3, which would have ended the run at 3 attempts before this change and completes all 10 after it.
There was a problem hiding this comment.
Isaac Lab Review Bot
The failure cap is now enforced in env_loop while preserving uncapped behavior by default, but two issues need correction: runtime behavior does not match the newly documented generation_guarantee scope, and the new regression test uses the wrong repository license header.
- Design and architecture: Keeping the failure bound alongside the existing termination checks is consistent with the current generation-loop design, and removing the previously inert per-environment limits preserves existing shipped-task behavior. The cap’s intended relationship to attempt-based generation must, however, be made consistent across implementation and documentation.
- API:
max_num_failuresis widened toint | Noneand defaults toNone. Its docstring says it is meaningful only withgeneration_guarantee, but the unconditional loop check can also terminate attempt-based runs early. Either gate the check on the guarantee or document and test its effect in both modes. - Implementation: The counter check correctly handles
Noneand runs when attempt counters change. However, it currently ignoresgeneration_guarantee, contrary to the documented contract. The new test file also uses an Apache-2.0 SPDX header rather than the repository’s current BSD-3-Clause source-file template.
Minor fixes needed. Posted 2 actionable findings inline.
Automated review; human maintainers own approval decisions.
| # with the success guarantee on, nothing else bounds the run: a task that rarely | ||
| # succeeds retries forever. max_num_failures is the opt-in bound on that. | ||
| max_num_failures = env.cfg.datagen_config.max_num_failures | ||
| if max_num_failures is not None and num_failures >= max_num_failures: |
There was a problem hiding this comment.
🟡 Warning · Api — Failure cap ignores documented generation_guarantee scope
This check runs regardless of generation_guarantee, but the docstring added in mimic_env_cfg.py states the field is "Only meaningful together with :attr:generation_guarantee". With the guarantee off and a cap below generation_num_trials, the run now ends before the requested attempts complete, which the documented contract says cannot happen. Either gate this condition on generation_guarantee or drop the "only meaningful" wording and document the attempt-based effect.
| # Copyright (c) 2024-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). | ||
| # All rights reserved. | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 |
There was a problem hiding this comment.
🟡 Warning · Implementation — New file uses non-repository SPDX header
This new source file declares SPDX-License-Identifier: Apache-2.0 with Copyright (c) 2024-2026, while adjacent Isaac Lab sources such as mimic_env_cfg.py use the repository template Copyright (c) 2022-2026 with BSD-3-Clause. New source files must use the repository's current header template; copy the header from a neighbouring isaaclab_mimic file so the contributed file's licensing is stated correctly.
There was a problem hiding this comment.
I think this one is a false positive — the file is in the right place for an Apache-2.0 header.
.pre-commit-config.yaml runs insert-license twice, splitting on path:
- id: insert-license
files: \.(pyi?|ya?ml)$
args: [--license-filepath, .github/LICENSE_HEADER.txt, --use-current-year]
exclude: "source/isaaclab_mimic/|scripts/imitation_learning/isaaclab_mimic/"
# Apache 2.0 license for mimic files
- id: insert-license
files: ^(source/isaaclab_mimic|scripts/imitation_learning/isaaclab_mimic)/.*\.py$
args: [--license-filepath, .github/LICENSE_HEADER_MIMIC.txt, --use-current-year].github/LICENSE_HEADER_MIMIC.txt is Copyright (c) 2024-<year> / SPDX-License-Identifier: Apache-2.0, and this file is source/isaaclab_mimic/test/test_generation_failure_cap.py, so that is the template it gets — matching every other file in source/isaaclab_mimic/test/.
mimic_env_cfg.py, which the finding compares against, lives in source/isaaclab/, which the first hook covers with the BSD-3-Clause template. That file is BSD in this same PR, and correctly so.
Happy to change it if maintainers prefer otherwise, but as it stands pre-commit would rewrite a BSD header here back to Apache-2.0.
…romised Review on isaac-sim#7433 caught a contradiction I introduced: the field's docstring says it is meaningful only together with generation_guarantee, but the loop check ran unconditionally. With the guarantee off and a cap below generation_num_trials, a fixed-attempt run would have ended before delivering the attempts it was asked for. The guarantee is also the only mode that needs the cap. Without it the existing check already stops on generation_num_trials attempts, so there is no unbounded run to bound and the cap has nothing to add. Gating the condition makes the code match the contract rather than loosening the contract to match the code. The attempt-mode test was parametrised over caps of None and 100 against 10 requested attempts, so it never exercised a cap that could fire; a cap of 3 is added, which is the case the reviewer identified as uncovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFz91VMgmrjS4XR6f9Q9Z2
|
Hi @2047767028-lang thanks a lot for the PR, @peterd-NV will take a look! |
peterd-NV
left a comment
There was a problem hiding this comment.
Thanks for catching and fixing this. The changes look good to me. I have a minor comment about the handling of parallel envs and how that affects the strictness of the threshold (not a merge blocker).
| # succeeds retries forever. max_num_failures is the opt-in bound on that. Without the | ||
| # guarantee the check above already stops on generation_num_trials attempts, so the | ||
| # cap has nothing to add and must not cut a fixed-attempt run short. | ||
| max_num_failures = env.cfg.datagen_config.max_num_failures |
There was a problem hiding this comment.
This limit is currently checked only after all generation attempts are completed by all envs during a parallel run. In theory this means that the actual number of trials can slightly exceed the max_num_failures as multiple envs cross that threshold.
The test case currently tests a single env so this would not occur. Handling of the parallel env case and an additional test can be added if the limit should be strict.
There was a problem hiding this comment.
You are right, and the overshoot is measurable. Driving env_loop with scripted attempt outcomes, the recorded failure count for max_num_failures=5:
num_envs |
attempts ending on the same step | failures | overshoot |
|---|---|---|---|
| 1 | 1 | 5 | 0 |
| 2 | 2 | 6 | 1 |
| 4 | 4 | 8 | 3 |
| 10 | 10 | 10 | 5 |
| 4 | staggered episode lengths | 5 | 0 |
So the overshoot is the number of attempts that end on the step which crosses the bound, minus one: zero for a single environment, at most num_envs - 1, and zero whenever episodes happen to end on separate steps.
I could not find a way to drive it to zero. Those attempts are already complete when the bound is read — their episodes ended on the same step, so the outcomes exist before anything can react to the crossing. Suppressing them would mean not counting episodes the recorder has already written.
The obvious fix is also worse than it looks. Having run_data_generator return once the bound is reached deadlocks the run: waypoint.py puts one action per environment and awaits env_action_queue.join(), while env_loop blocks until qsize() == env.num_envs, so a single generator leaving parks the loop forever. Measured with the generators as real coroutines, num_envs=4 with staggered episodes and num_envs=10 both hang, and in the cases that do not hang the failure count is unchanged (8 and 6) — the overshoot never came from newly started attempts.
So 9fe2ba7 documents the guarantee on the field rather than changing behaviour, and adds two tests pinning both sides of it: exact when attempts end on separate steps, bounded by num_envs - 1 when they end together.
Would you like the strict version anyway? Making the bound exact would need env_loop to wait on a count of live generators instead of env.num_envs, so a generator can leave without stalling the loop — and even then it would only stop attempts from starting, not the ones already in flight on the crossing step. Happy to write it if you want it; it touches the generation loop itself, which felt like more than this fix should carry.
Review on isaac-sim#7433 noted that with several environments the recorded failure count can pass max_num_failures. Measured on the shipped loop: the overshoot is exactly the number of attempts that end on the step which crosses the bound, minus one, so it is zero for a single environment and at most num_envs - 1. It cannot be driven to zero -- those attempts have already completed when the bound is read, and stopping a generator instead leaves env_loop waiting forever for an action from it, since the loop blocks until all num_envs actions are queued. So the field's docstring now states the guarantee for parallel runs, and two tests pin both sides of it: exact when attempts end on separate steps, bounded by num_envs - 1 when they end together. The test harness gains num_envs and attempts_per_step, defaulting to what it did before. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SsU8ziGULaEBbtmvJSQZW
|
run-ci |
Description
DataGenConfig.max_num_failuresis documented as "Maximum number of failures allowed before stopping generation" and eighteen shipped Mimic environment configs set it to25. Nothing reads it.No third entry: there is no read anywhere in the repository, and the field has been inert since Isaac Lab Mimic was introduced in #179.
env_loopcountsnum_failuresand then never consults it; its only termination isWith
generation_guarantee = True(which those same eighteen configs also set), a task whose success rate is low retries without any bound, and the one knob that claims to stop that does nothing. On our own data generation this cost 337 attempts for 10 demos before we discovered the cap was not real.Reproduction, on a stock task
FrankaCubeStackIKRelMimicEnvCfgsetsmax_num_failures = 25. Observed:max_num_failures = 25)Before the change the run passed the configured cap and kept going, ending at twice it. After, it stops on the attempt that reaches 25 failures and says so:
On the default
Wiring the field up exactly as written would abort the primary documented workflow. At the ~36% success rate measured above,
--generation_num_trials 1000needs on the order of 1800 failures, so a cap of 25 would end the run after roughly forty attempts. Since the eighteen assignments were inert when they were written and no current behaviour depends on them, they are removed here and the default becomesNone, meaning no limit. Runs behave exactly as they do today unless a limit is explicitly requested.If maintainers would rather the shipped configs keep an active cap, that is a one-line change per config and I am happy to make it — it just needs a value that suits large runs.
Tests
source/isaaclab_mimic/test/test_generation_failure_cap.pydrivesenv_loopwith a fake environment whosestep()consumes scripted attempt outcomes and refills the action queue, so the loop's own termination logic runs unmodified with no simulator or dataset behind it; a step-count fuse turns an unbounded loop into a failed assertion. Six cases: the cap stops a run that never succeeds;Nonekeeps the guarantee unbounded as before; enough successes still end the run first; a cap reached short of the target ends it; attempt-based termination with the guarantee off is untouched (×2).Against the unpatched loop the two cap-dependent cases fail (
'fuse' == 'exited', and(3, 4) == (2, 4)— the loop overran the cap and collected an extra success) and the four asserting unchanged behaviour pass.Type of change
Checklist
pre-commitchecks with./isaaclab.sh --formatsource/<pkg>/changelog.d/for every touched packageCONTRIBUTORS.mdor my name already exists there🤖 Generated with Claude Code
https://claude.ai/code/session_01JFz91VMgmrjS4XR6f9Q9Z2