Deterministic Object Placement#679
Conversation
There was a problem hiding this comment.
🤖 Isaac Lab Review Bot
Summary
This PR introduces deterministic object placement by adding a seed_offset parameter to ObjectPlacer.place() and managing seed streams within PooledObjectPlacer. The implementation ensures reproducibility across pool refills and sampling operations, backed by 10 comprehensive tests.
Findings
🔵 Suggestion: In pooled_object_placer.py line 50, the change from raise ValueError(...) to assert pool_size >= 1 is a deliberate design choice. However, note that asserts can be disabled with python -O, which would skip this validation in optimized builds. If this is intentional (fail-fast during development only), the current approach is fine. If this invariant should always be enforced, consider keeping ValueError.
🔵 Suggestion: The seeded random.Random(placer_params.placement_seed) instance (line 54) is correctly used for sample_with_replacement. When placement_seed=None, random.Random(None) seeds from system entropy, which gives the desired non-deterministic behavior. This is correct.
🔵 Suggestion: The test file imports pytest at line 8 but only uses it in the final test (test_pooled_placer_rejects_pool_size_below_one). This is fine, but ensure the test suite dependencies include pytest.
✅ The seed_offset bookkeeping correctly advances only when placement_seed is not None, avoiding phantom seed-stream tracking for unseeded runs.
✅ Tests thoroughly cover: identical seeds produce identical layouts, different seeds diverge, refill advances the seed stream, unseeded mode works without crashes, no dict aliasing, count > pool_size works, and the pool_size validation.
Verdict
Ship it 🚀
Clean implementation with solid test coverage. The suggestions above are minor style considerations; the core logic is correct and well-documented.
Greptile SummaryThis PR makes
Confidence Score: 3/5Safe to merge after restoring the ValueError guard and aligning the test to catch ValueError instead of AssertionError. The core seeding logic is correct and well-tested. The only substantive change that could affect production behavior is the replacement of
Important Files Changed
Reviews (1): Last reviewed commit: "increase test readability" | Re-trigger Greptile |
| ) -> None: | ||
| if pool_size < 1: | ||
| raise ValueError(f"pool_size must be >= 1, got {pool_size}") | ||
| assert pool_size >= 1, f"pool_size must be >= 1, got {pool_size}" |
There was a problem hiding this comment.
Using
assert for constructor argument validation is problematic because Python disables all assert statements when run with the -O (optimize) flag. In that mode, a pool_size=0 call will silently pass this check, then max_placement_attempts * 0 = 0 candidates are generated, and the pool initialization will fail later with a generic RuntimeError("Placement pool failed to produce any valid layouts") — losing the precise error message. The original ValueError is the idiomatic Python choice for invalid argument values in a public __init__.
| assert pool_size >= 1, f"pool_size must be >= 1, got {pool_size}" | |
| if pool_size < 1: | |
| raise ValueError(f"pool_size must be >= 1, got {pool_size}") |
| with pytest.raises(AssertionError, match="pool_size must be >= 1"): | ||
| PooledObjectPlacer(objects=list(_create_test_objects()), placer_params=placer_params, pool_size=0) |
There was a problem hiding this comment.
This test is coupled to the implementation mechanism (
AssertionError) rather than the semantic contract (ValueError). If the validation is restored to a ValueError (which is idiomatic for invalid constructor arguments), this test would incorrectly pass the assert but then fail with the wrong exception type. It should catch ValueError to verify the public API contract.
| with pytest.raises(AssertionError, match="pool_size must be >= 1"): | |
| PooledObjectPlacer(objects=list(_create_test_objects()), placer_params=placer_params, pool_size=0) | |
| with pytest.raises(ValueError, match="pool_size must be >= 1"): | |
| PooledObjectPlacer(objects=list(_create_test_objects()), placer_params=placer_params, pool_size=0) |
| ) | ||
|
|
||
| pool1 = PooledObjectPlacer(objects=list(_create_test_objects()), placer_params=placer_params, pool_size=4) | ||
| pool2 = PooledObjectPlacer(objects=list(_create_test_objects()), placer_params=placer_params, pool_size=4) | ||
|
|
||
| samples1 = pool1.sample_with_replacement(20) | ||
| samples2 = pool2.sample_with_replacement(20) | ||
|
|
||
| any_different = False | ||
| for s1, s2 in zip(samples1, samples2): | ||
| if _positions_by_name(s1) != _positions_by_name(s2): | ||
| any_different = True | ||
| break | ||
| assert any_different, "Two unseeded pools should not produce identical samples" | ||
|
|
||
|
|
||
| def test_pooled_placer_stored_layouts_have_distinct_positions_dicts(): | ||
| """Each stored layout must own a distinct positions dict (no aliasing across pool entries).""" | ||
| solver_params = RelationSolverParams(max_iters=50) | ||
| placer_params = ObjectPlacerParams(placement_seed=42, solver_params=solver_params, apply_positions_to_objects=False) | ||
|
|
There was a problem hiding this comment.
Non-deterministic test asserting non-determinism
test_pooled_placer_unseeded_does_not_crash_and_diverges asserts that two independently unseeded pools produce at least one differing sample across 20 draws. While this is overwhelmingly likely in practice, it is not guaranteed: if the gradient solver converges to the same global minimum from two different random inits, all positions could coincide and the assertion fires incorrectly. Consider replacing the divergence assertion with a no-crash smoke test.
Summary
Deterministic Object Placement
Detailed description