Skip to content

Add NotOn, NotNextTo loss strategy and solver#732

Draft
xyao-nv wants to merge 1 commit into
mainfrom
xyao/dev/not_relation
Draft

Add NotOn, NotNextTo loss strategy and solver#732
xyao-nv wants to merge 1 commit into
mainfrom
xyao/dev/not_relation

Conversation

@xyao-nv
Copy link
Copy Markdown
Collaborator

@xyao-nv xyao-nv commented May 28, 2026

Summary

Add NotOn, NotNextTo loss strategy and solver

Detailed description

  • Why: when a task goal is On(parent) or NextTo(parent, side, distance), the reset state must NOT already satisfy it
  • What: Added NotOn and NotNextTo strategy, which are geometric inverse of its On, NextTo loss. (see below)
not_relation_losses

Copy link
Copy Markdown
Contributor

@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 Summary

This PR introduces NotOn and NotNextTo relation constraints—the geometric inverses of the existing On and NextTo relations. The implementation is well-designed and follows the established patterns in the codebase.

Verdict: APPROVE


Architecture & Design

The implementation correctly identifies the core insight: negated spatial relations can be satisfied by escaping along any single axis rather than satisfying all constraints simultaneously. The use of torch.minimum() to take the minimum across escape dimensions is mathematically sound and produces correct gradients for the optimizer.

Strengths

  1. Clean separation of concerns: Relation classes in relations.py are pure data containers; all loss computation logic lives in the strategy classes.

  2. Consistent API: The new strategies follow the exact same interface pattern as OnLossStrategy and NextToLossStrategy, accepting the same parameters and returning tensors in the same shape.

  3. Correct gradient behavior: The test test_not_on_gradient_pushes_outward() verifies that gradients push toward the nearer edge—this is the expected behavior for efficient optimization.

  4. Configurable margin: The margin_m parameter in both strategies allows fine-tuning the "escape distance" required before loss reaches zero. This is important for avoiding jitter at the boundary.


Potential Issues & Suggestions

1. Asymmetric margin_m defaults

NotOnLossStrategy defaults to margin_m=0.0, while NotNextToLossStrategy defaults to margin_m=0.05 and requires it to be positive (assert margin_m > 0.0).

This asymmetry may be intentional (different geometric semantics), but it could surprise users who expect consistent behavior. Consider adding a brief note in the docstring explaining why the default differs.

2. Missing Z-axis consideration in NotOn

The docstring states: "Z is ignored — with no XY overlap there is nothing to stack on."

This is correct for the stated use case, but there may be edge cases where an object is directly below the parent (same XY footprint, different Z). If the goal is to prevent any spatial overlap, Z should also be checked. If the goal is purely to prevent "resting on top," the current behavior is correct—but this assumption should be documented more explicitly for future maintainers.

3. Test coverage for batch inputs

The tests use single-position inputs (child_pos.dim() == 1). The strategies handle batched inputs (child_pos.dim() == 2), but this code path is not tested. Consider adding a parametrized test that verifies correct behavior with batched positions:

def test_not_on_batched_input():
    """Verify batched positions produce correct output shape."""
    child_pos = torch.tensor([[2.0, 0.4, 0.5], [0.4, 0.4, 0.5]])  # outside, inside
    loss = NotOnLossStrategy(slope=100.0).compute_loss(
        NotOn(_table()), child_pos, _box().bounding_box, _table().bounding_box
    )
    assert loss.shape == (2,)
    assert torch.isclose(loss[0], torch.tensor(0.0), atol=1e-6)  # outside
    assert loss[1] > 0.0  # inside

4. Solver integration test starts mug in wrong location

In test_solver_drives_mug_off_forbidden_table():

  • The mug starts at (1.9, 0.4, 0.13) which is already outside the right table's footprint ([1.5, 2.5] × [0, 1]).
  • The test verifies the final position, but the starting position already satisfies NotOn(right).

This test proves the solver converges, but doesn't verify the NotOn loss actively pushes the mug off. Consider starting the mug inside the forbidden table's footprint (e.g., (1.7, 0.4, 0.13)) to validate the repulsive behavior.


Minor Suggestions

  • Docstring typo (non-blocking): In NotNextToLossStrategy.__init__, the docstring says "margin_m: Meters of clearance required along whichever escape axis the optimizer takes." The word "clearance" could be misleading since this is the penalty zone width, not a physical clearance. Consider rephrasing to "safety margin around the forbidden zone."

Test Coverage Assessment

Test Area Coverage
Zero loss when constraint satisfied ✅ Both
Positive loss when violated ✅ Both
Gradient direction ✅ NotOn
Margin behavior ✅ NotOn
All Side variants ✅ NotNextTo
Solver integration
Batched input ❌ Not tested
Relation weight scaling ❌ Not tested

The test suite is comprehensive for the core functionality. The two gaps noted above are minor and can be addressed in a follow-up.


Final Notes

This is a clean, well-documented implementation that solves a real problem (preventing reset states from satisfying goal conditions). The mathematical approach is sound, the code follows established patterns, and the test coverage is good. Approving with the minor suggestions above for consideration.

inside_y = torch.maximum(zero, torch.minimum(child_pos[:, 1] - valid_y_min, valid_y_max - child_pos[:, 1]))

# min(): a single-axis escape is enough to satisfy Not(On).
loss = self.slope * torch.minimum(inside_x, inside_y)
Copy link
Copy Markdown
Collaborator

@cvolkcvolk cvolkcvolk May 28, 2026

Choose a reason for hiding this comment

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

Suggestion to move the all loss computation into loss_primitives.py into respective functions next to the others.

gap_dist = torch.maximum(zero, margin - escape_dist)

# min(): a single escape past the margin is enough to satisfy Not(NextTo).
loss = self.slope * torch.minimum(torch.minimum(gap_side, gap_cross), gap_dist)
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.

Same here, suggestion to move the loss computation into loss_primitives.py to line this up stylistically with the existing strategies.

# Cross band: child placed at target position within parent's perpendicular extent.
parent_band_min = parent_world_bbox.min_point[:, cfg.band_axis]
parent_band_max = parent_world_bbox.max_point[:, cfg.band_axis]
valid_band_min = parent_band_min - child_bbox.min_point[:, cfg.band_axis]
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.

I think we could reuse some existing losses like the linear_band_loss() from loss_primitives.py in this function

cross = child_pos[:, cfg.band_axis]
zero = torch.zeros((), dtype=child_pos.dtype, device=child_pos.device)

# escape_side: how far on the WRONG side of parent's edge (0 if on correct side).
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.

This looks like the half-plane logic that single_boundary_linear_loss() implements

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants