Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changes to improve exception handling #287

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

stevenrhall
Copy link

@stevenrhall stevenrhall commented Jan 26, 2025

Addresses issue #286

The Problem

Currently, there are many situations in which cyipopt produces predictable but cryptic errors in response to user input errors. Some examples:

  • If some of the indices returned by jacobianstructure are negative, one would expect an error message such as

    ValueError: All jacobian row indices must be non-negative and less than m
    

    Instead, Ipopt halts with status code -100 (Unrecoverable Exception), without any indication that this issue is an error in the indices.

  • If the hessian callback raises an exception, one would expect the exception to be raised eventually for the user to see. That doesn't happen, and instead the hessian is not calculated, and typically the Ipopt iteration will never converge. This bug is really insidious, because the user won't know that there's an exception in their code.

  • If a user specifies hessian indices that are not lower triangular, Ipopt exits with status code -12, Invalid Option, because it concludes that no hessian is provided, and expects the hessian_approximation option to be "limited-memory". It would be much better of course to get an error message:

    ValueError: Indices are not lower triangular in hessianstructure
    

The Causes

There are multiple issues:

  1. The jacobianstructure and hessianstructure user callbacks are called at problem instantiation only to get the length of the index arrays, with no error checking. Error checking is deferred until the Ipopt solver is called, and the error checking is done in the callbacks. That's a particularly problematic design choice, because an exception should not be raised in the callbacks. When an error is detected, the callbacks return False, which in turn leads Ipopt to return a failure status code. A quick fix would be to raise an exception in the callbacks, which would be caught in the try-except block. That's awkward, but actually works on MacOS, but causes a segfault on Linux, because it leaves the index arrays uninitialized.

  2. There are bugs in the hessian_cb function. For one, the user_data pointer is cast as self = <object>user_data instead of self = <Problem>user_data. That changes how Cython name mangles variables, and the result is that the self.__exception = sys.exc_info() doesn't actually pass the exception back to the class, and so an exception in the user hessian callback never gets re-raised. Further, there's a missing return True that prevents the intermediate callback from stopping Ipopt execution.

Proposed Solution

My proposed solution moves all the index checking into the Problem.__init__ method, so that all errors are caught at problem instantiation. This effectively moves all the code from jacobian_struct_cb and hessian_struct_cb into the constructor, and also simplifies jacobian_cb and hessian_cb. All errors in the indices raise ValueError exceptions, with richer messages than the current log messages to provide better feedback to the user.

I've also fixed the logic error that silenced all exceptions in the user hessian callback.

In order to allow unit testing of these changes, I added a feature that should be useful to the user. I've added a Problem.info attribute, which holds the info dict return in a problem.solve(x0), so that if an exception is re-raised after the call to the Ipopt solver, it can be caught by the unit tests (or the user!), and both the return status and the state of the solver can be examined. Adding Problem.info does not break the current API, and could actually be viewed as a positive feature to help users debug their code. If not desired as a feature, it could be changed to Problem.__info.

I've also fixed a few typos here and there.

Readiness of the PR

This PR is not to master but to a feature branch, improve-exception-handling, so that if the PR is accepted, some polishing can be done before merging to main. I have some additional tests that aren't quite ready, and minor changes to the docs would be required as well. If you'd prefer a different approach, let me know.

The current PR does pass all the CI tests I believe. I've modifed the test_deriv_errors.py test to reflect my changes. An added bonus is that because all the indices are properly tested and an exception is raised if there's an error before the call the Ipopt solver, it's no longer necessary to distinguish pre and post Ipopt v3.14.13, and that is reflected in the tests.

The changes here produce generally better error messages for cyipopt. In
many cases, errors in the callbacks produced predictable errors in
Ipopt, but with error messages that would be somewhat cryptic. So for
examples, errors in the hessianstructure callback produced status code
-12 errors (Invalid Option), with the invalid option being
hessian_approximation = “limited-memory”, which from a user perspective
does not point to an error in the hessianstructure callback. With these
changes, errors in the structure callbacks or exceptions in the other
callbacks all produce meaningful exceptions and error messages.
@moorepants
Copy link
Collaborator

This will take a careful look to process. So it may take a little longer to review.

@stevenrhall
Copy link
Author

This will take a careful look to process. So it may take a little longer to review.

No problem. I'm really looking for feedback so I can make needed changes if you want to move forward.

On my local repo, I've also made changes that allow you to get the coverage of the tests, including the cython code. Doing so allowed me to catch a couple of issues I had missed. It's a very modest change if you would like that incorporated. (2 lines in setup.py, which have no effect unless the proper environment variables are set, and a few lines in .coveragerc and pytest.ini. It would also take a line or two in the workflow files to set the envirnment variable for testing.)

@moorepants
Copy link
Collaborator

The main thing I want to think about is if this is somehow backwards incompatible. If the earlier error handling is relied on downstream, then we made need some caution.

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