Skip to content

BFGS can loop infinitely when trust radius hits minimum on rejected steps #7

Description

@yannnbingz

The related PR can be found here: #8

Issue:

In relaxation.py, the BFGS optimizer can enter an infinite loop when consecutive steps are rejected on a shallow potential energy surface. The root cause is in _compute_new_position(): when a step is rejected (energy_new > energy_old) and the parabolic backtrack gives trust_radius >= trust_radius_min (line 448).
This erases the counter that tracks whether the BFGS history has already been reset, preventing the termination condition (tr_min_hit > 1) from ever being reached.
The problematic infinite loop follows this pattern:

Step SCF result What happens tr_min_hit
N E_new > E_old Rejected. Backtrack gives tr < tr_min. Since tr_min_hit == 0, sets it to 1, resets Hessian, takes steepest-descent step. 1
N+1 E_new > E_old Rejected. Backtrack gives tr ≥ tr_min. Code sets tr_min_hit = 0 ← bug erases counter. 0
N+2 E_new > E_old Rejected. Backtrack gives tr < tr_min. Since tr_min_hit == 0, sets it to 1, resets Hessian again. 1 instead of 2
→ N+ ... Same rejection, same backtrack → tr_min_hit = 0 again. Cycle repeats indefinitely. ...  

The convergence escape hatch requires tr_min_hit to reach 2 (two consecutive tr < tr_min hits), but the intervening rejected step with tr ≥ tr_min always resets the counter to 0.

This is triggered on systems with shallow or flat excited-state PES where many consecutive BFGS steps are rejected.

Proposed Fix:

In _compute_new_position(), inside the step-rejection branch, replace self.tr_min_hit = 0 with pass when trust_radius >= trust_radius_min:

# Before (line 447-448):
            else:
                self.tr_min_hit = 0

# After:
            else:
                pass

This preserves the counter across rejected steps so the termination condition can be reached:

Step Before fix After fix
N tr < tr_min → tr_min_hit = 1, reset Hessian Same
N+1 tr ≥ tr_min → tr_min_hit = 0 (erased) tr ≥ tr_min → tr_min_hit = 1 (preserved)
N+2 tr < tr_min → tr_min_hit = 1, reset again → ∞ loop tr < tr_min → tr_min_hit = 2 → terminates

The counter is still properly reset to 0 whenever a step is accepted (energy decreases), both in _compute_new_position() (line 476) and _compute_trust_radius() (line 651), so normal BFGS operation is unaffected.

Related Issue:

Separately, _compute_trust_radius() (line 640) calls exit() when tr_min_hit reaches 2 on an accepted step. This terminates the Python process without saving the final geometry or running cleanup. Replacing exit() with allowing the main loop to check conv_bfgs (which is already set via tr_min_hit > 1 at line 356) would give a graceful shutdown instead.

Fix:

# Before (lines 637–640):
            if self.tr_min_hit == 1:
                self._log("history already reset at previous step: stopping")
                self.tr_min_hit = 2
                exit()

# After:
            if self.tr_min_hit == 1:
                self._log("history already reset at previous step: stopping")
                self.tr_min_hit = 2

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions