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
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 givestrust_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:
tr < tr_min. Sincetr_min_hit == 0, sets it to 1, resets Hessian, takes steepest-descent step.tr ≥ tr_min. Code setstr_min_hit = 0← bug erases counter.tr < tr_min. Sincetr_min_hit == 0, sets it to 1, resets Hessian again.tr_min_hit = 0again. Cycle repeats indefinitely.The convergence escape hatch requires
tr_min_hitto reach 2 (two consecutivetr < tr_minhits), but the intervening rejected step withtr ≥ tr_minalways 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, replaceself.tr_min_hit = 0with pass whentrust_radius >= trust_radius_min:This preserves the counter across rejected steps so the termination condition can be reached:
tr < tr_min→tr_min_hit = 1, reset Hessiantr ≥ tr_min→tr_min_hit = 0(erased)tr ≥ tr_min→tr_min_hit = 1(preserved)tr < tr_min→tr_min_hit = 1, reset again → ∞ looptr < tr_min→tr_min_hit = 2→ terminatesThe 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) callsexit()whentr_min_hitreaches 2 on an accepted step. This terminates the Python process without saving the final geometry or running cleanup. Replacingexit()with allowing the main loop to checkconv_bfgs(which is already set viatr_min_hit > 1at line 356) would give a graceful shutdown instead.Fix: