Skip to content

feat(nl): parse + evaluate AMPL external functions (#15)#20

Merged
jkitchin merged 3 commits intomainfrom
worktree-issue-15-external-functions
Apr 25, 2026
Merged

feat(nl): parse + evaluate AMPL external functions (#15)#20
jkitchin merged 3 commits intomainfrom
worktree-issue-15-external-functions

Conversation

@jkitchin
Copy link
Copy Markdown
Owner

@jkitchin jkitchin commented Apr 23, 2026

Summary

  • Phase 1: parse AMPL external-function segments (F, f<id>) and reject with a clear error naming the library and symbol.
  • Phase 2: actually evaluate the external functions end-to-end via the funcadd_ASL ABI, so problems that call imported functions (IDAES Helmholtz property packages, etc.) solve instead of erroring.

Phase 2 design

  • src/nl/external.rs — dlopens AMPLFUNC libraries via libloading, registers functions through funcadd_ASL / Addfunc, evaluates with value / gradient / packed-upper-triangular Hessian. A process-wide Mutex guards load+call because AMPL libs keep mutable global state. Arity and string-arg type bits are validated per call.
  • src/nl/autodiff.rs — new TapeOp::Funcall variant holding Arc<ExternalLibrary>. Every tape pass handles it: forward, reverse (chain rule through derivs[k]), forward_tangent, hessian_accumulate (forward-over-reverse with the packed Hessian), hessian_sparsity (self-product over real-arg vars), remap_op.
  • src/nl/problem_impl.rsresolve_externals walks the NL AST for funcall ids, loads each path on AMPLFUNC (colon- or newline-separated) as Arc<ExternalLibrary>, and maps referenced names to a library. Clean error if AMPLFUNC is unset or a required symbol is missing.
  • examples/probe_external.rs — diagnostic that lists functions registered by a library.

Result

AMPLFUNC=$HOME/.idaes/bin/general_helmholtz_external.dylib \
  cargo run --release --bin ripopt -- tests/fixtures/issue_15/idaes_helmholtz.nl
# → Optimal after 5 iterations. Objective: 0.000000000000000e0

Test coverage

Axis Covered via
Parse funcall in objective / constraint nl_parse_external_function_*
Load via funcadd_ASL load_idaes_helmholtz_dylib_registers_known_functions
Clean error when AMPLFUNC unset nl_parse_idaes_helmholtz_fixture
End-to-end IPM solve with externals nl_build_idaes_helmholtz_with_amplfunc + CLI smoke
type=1 (string args) + variadic nargs<0 eval_vf_hp_* against IDAES Helmholtz vf_hp
type=0 (no strings) + exact nargs>0 eval_cbrt_matches_closed_form against IDAES functions.dylib
Closed-form value / deriv / Hessian check eval_cbrt_matches_closed_form
Runtime arity mismatch rejection eval_cbrt_arity_mismatch_errors
String arg to type=0 function rejected eval_cbrt_rejects_string_arg

Known gaps — not exercised by any test

These paths are either not triggered by any AMPL library in our environment, or rely on optional AMPL ABI features we have not had a fixture for. The code may be wrong on these axes and we would not catch it:

  • FUNCADD_OUTPUT_ARGS (type bit 2) — functions that return extra values through output slots instead of the return value. Our eval() accepts the type bit but does not wire the output path back to the caller. Likely broken if encountered.
  • FUNCADD_RANDOM_VALUED (type bit 4) — RNG functions. Not handled at all; unusual in optimization NL files but possible.
  • Multiple libraries colon- or newline-separated in AMPLFUNC — the splitter is implemented but no test exercises a model that needs symbols from two libraries at once.
  • Same symbol registered by two libraries — our resolver is first-match-wins. Reasonable but untested.
  • Library-reported errors via errmsg — we read the buffer and surface it as an Err, but no test triggers a library to write into it.
  • Variadic calls with many reals (the IDAES fixture only hits nargs=-4, i.e. min 3). More extreme arities are not directly covered.

If you hit one of these in a real model, please open an issue with the library and .nl so we can add a fixture.

Test plan

  • cargo test — 259/259 pass
  • nl_parse_external_function_reports_clean_error — rejection path still works when AMPLFUNC is absent
  • nl_parse_idaes_helmholtz_fixture — clean error when AMPLFUNC is unset (serialized via env_lock())
  • nl_build_idaes_helmholtz_with_amplfunc — end-to-end build with AMPLFUNC set
  • ripopt CLI solves tests/fixtures/issue_15/idaes_helmholtz.nl to Optimal
  • Three new tests against functions.dylib cover type=0, exact arity, string-arg rejection

cc @CMarcher — phase 2 is ready. If you have a Helmholtz flowsheet .nl handy, you can solve it with:

AMPLFUNC=/path/to/library.dylib cargo run --release --bin ripopt -- yourmodel.nl

Multiple libraries work with colon- or newline-separated paths in AMPLFUNC.

Closes #15.

🤖 Generated with Claude Code

jkitchin and others added 2 commits April 23, 2026 19:07
Phase 1 of issue #15: ripopt's NL parser used to abort with
`Unknown expression token: 'f0 3'` on any model that calls an AMPL
imported function (e.g. IDAES property packages, CMarcher's Helmholtz
flowsheet). That made those files unusable even for inspection.

The parser now:
- reads `nfunc` from dim line 4 (field 1) into `NlHeader.n_funcs`;
- consumes top-level `F<k> <type> <nargs> <name>` segments into
  `NlFileData.imported_funcs`;
- recognises `f<id> <nargs>` expression tokens and builds an
  `ExprNode::Funcall { id, args }` node;
- walks past `f`-forms in `count_expr_lines_recursive` so surrounding
  expressions stay in sync.

`NlProblem::from_nl_data` now returns `Result<Self, String>`. Before any
tape is built it scans objective, constraint, and common expressions for
`Funcall` nodes and, if any is found, resolves the id back to its `F`
name and returns:

  problem uses external function '<name>'; external functions
  (AMPL imported functions) are not supported by ripopt

Phase 2 (libloading + `funcadd_ASL` ABI + derivatives) is still open.

Test fixtures:
- tests/fixtures/issue_15/generate_helmholtz_nl.py — CMarcher's IDAES
  Helmholtz flowsheet (credit: CMarcher + GPT 5.4), regenerable with
  `idaes get-extensions` installed.
- tests/fixtures/issue_15/idaes_helmholtz.nl — captured NL file with
  3 `F` segments (vf_hp, h_liq_hp, h_vap_hp) and 4 `f<id>` calls.

New integration tests (tests/nl_integration.rs):
- minimal hand-written `f` in objective → clean error naming `myfunc`;
- minimal hand-written `f` in a constraint → same;
- real IDAES Helmholtz fixture → parse succeeds, all three F-names
  surface, construction errors with one of them named.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of issue #15. Extends the NL loader beyond the clean-rejection
stub so problems that reference AMPL imported functions (e.g. IDAES
Helmholtz property calls) actually solve instead of erroring.

- src/nl/external.rs: dlopen AMPLFUNC libraries via libloading, register
  funcs via funcadd_ASL, evaluate with value/gradient/packed-Hessian
  buffers. Process-wide Mutex guards load+call (AMPL libs keep mutable
  global state). Arity and string-arg type bits validated per call.
- src/nl/autodiff.rs: new TapeOp::Funcall variant with Arc<ExternalLibrary>.
  Tape build resolves funcall ids through an ExternalResolver; forward,
  reverse, forward_tangent, hessian_accumulate, hessian_sparsity, and
  remap_op all handle Funcall. Second-order reverse uses packed upper-
  triangular Hessian from the library; sparsity emits self-product over
  real-arg vars.
- src/nl/problem_impl.rs: resolve_externals walks the NL AST for funcall
  ids, loads each AMPLFUNC path as Arc<ExternalLibrary>, and maps each
  referenced name to a library. Clear error if AMPLFUNC unset or name
  missing.
- tests/nl_integration.rs: nl_build_idaes_helmholtz_with_amplfunc end-to-
  end test. env_lock() serializes the two AMPLFUNC-touching tests so
  parallel execution does not leak env state.
- examples/probe_external.rs: diagnostic that lists registered funcs
  from an AMPL external library.

IDAES Helmholtz flowsheet fixture now solves to Optimal in 5 iterations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jkitchin jkitchin changed the title feat(nl): parse AMPL external functions, reject with clear error (#15) feat(nl): parse + evaluate AMPL external functions (#15) Apr 24, 2026
The Helmholtz fixture exercises type=1 (string-arg) variadic functions
only. Added three targeted tests against functions.dylib to cover the
orthogonal axis:

- eval_cbrt_matches_closed_form: verifies value, first, and second
  derivatives against cbrt's closed form at x=8 (2, 1/12, -1/144).
- eval_cbrt_arity_mismatch_errors: exact positive nargs=1 rejects both
  underfilled (0) and overfilled (2) calls.
- eval_cbrt_rejects_string_arg: strings passed to a type=0 function
  are rejected in our wrapper, not forwarded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jkitchin jkitchin merged commit eb719a5 into main Apr 25, 2026
1 check passed
@jkitchin jkitchin deleted the worktree-issue-15-external-functions branch April 25, 2026 16:12
jkitchin added a commit that referenced this pull request Apr 25, 2026
The v0.9 plan replaces the rmumps backend wholesale with feral, so
all rmumps-side recommendations in REFERENCE_GAP_ROADMAP.md are now
moot rather than deferred. Investing in rmumps internals
(CNTL(4) static-pivot threshold, ICNTL(24) null-pivot detection,
NBTINY/RINFOG telemetry, real MC64, METIS, 2x2 static pivots,
analysis-time front sizing, backward-error refinement stop) would
be wasted effort against code that is being removed.

Changes:
  - Top-of-file status banner clarifying the post-v0.8 state and
    listing the rmumps items that are moot (#3, #4, #7, #10, #11,
    #15, #16, #17, #20).
  - Each of those nine cross-cutting items is now wrapped in
    strikethrough with a "MOOT (superseded by feral, v0.9)" tag.
  - Closing summary rewritten: "the cross-cutting roadmap is
    effectively closed; what's next is the feral swap and a
    post-feral re-triage of the four known regressions
    (CRESC50, OET4, OET6, TFI1) plus grid case30_ieee."
  - Banner at the top of "Roadmap recommendations (rmumps-side)"
    explaining that the full list below is preserved for historical
    reference only.

Mechanics: ripopt's LinearSolver trait already abstracts the
backend, and MultifrontalLdl / IterativeMinres / HybridSolver are
all #[cfg(feature = "rmumps")]-gated. Feral slots in as another
LinearSolver impl with its own feature flag; the IPM driver does
not call rmumps directly. Telemetry/scaling/ordering responsibilities
that the moot items would have addressed are now feral's job.

No code changes; documentation only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jkitchin pushed a commit that referenced this pull request May 7, 2026
Add auxiliary preprocessing regression tests
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.

Ripopt does not seem to support external functions

1 participant