Skip to main content

pounce_nl/
nl_external.rs

1//! AMPL imported (external) function support via the `funcadd_ASL` ABI.
2//!
3//! This module implements enough of AMPL's `funcadd.h` ABI to:
4//!
5//! 1. `dlopen` a user-supplied shared library;
6//! 2. resolve the `funcadd_ASL` symbol and call it;
7//! 3. receive registration callbacks of the form `Addfunc(name, rfunc, type,
8//!    nargs, funcinfo, ae)` and record them;
9//! 4. later call back into the registered `rfunc` with an `arglist` to obtain
10//!    function values, gradients, and Hessians.
11//!
12//! The `AmplExports` and `Arglist` struct layouts are taken from
13//! AMPL-MP/ASL `funcadd.h`; cross-checked against the ctypes mapping in
14//! `pyomo.core.base.external`. Fields we don't populate are left null —
15//! Pyomo does the same and it is sufficient for IDAES's Helmholtz library
16//! (see issue #15).
17//!
18//! All unsafe FFI is contained in this module. Public surface is safe.
19
20use std::collections::HashMap;
21use std::ffi::{CStr, CString, c_char, c_int, c_long, c_void};
22use std::path::Path;
23use std::ptr;
24use std::sync::{Arc, Mutex, OnceLock};
25
26#[cfg(any(unix, windows))]
27use libloading::{Library, Symbol};
28
29use crate::nl_reader::{Expr, FuncallArg, ImportedFunc};
30
31/// Resolved AMPL imported function: shared library + registered name.
32/// `NlProblem` carries one of these per `ImportedFunc` id when external
33/// functions are wired up at problem-build time. The same `Arc<ExternalLibrary>`
34/// may be shared across many funcall ids (one library typically registers
35/// several functions).
36#[derive(Default, Clone)]
37pub struct ExternalResolver {
38    /// `Funcall { id }` -> (library, registered function name).
39    pub funcs_by_id: HashMap<usize, (Arc<ExternalLibrary>, String)>,
40}
41
42impl ExternalResolver {
43    pub fn is_empty(&self) -> bool {
44        self.funcs_by_id.is_empty()
45    }
46
47    /// Build a resolver for every `ImportedFunc` declared in the `.nl` file
48    /// that is *actually referenced* somewhere in the problem's expressions.
49    ///
50    /// Library paths are resolved through the `AMPLFUNC` environment variable
51    /// (a `\n`-separated list of shared-library paths, matching AMPL/IPOPT
52    /// conventions). Each path is loaded once and queried for every name we
53    /// need. Returns an error if a referenced name cannot be found in any
54    /// listed library, or if `AMPLFUNC` is missing.
55    pub fn build_for_problem(
56        imported_funcs: &[ImportedFunc],
57        referenced_ids: &std::collections::BTreeSet<usize>,
58    ) -> Result<Self, String> {
59        if referenced_ids.is_empty() {
60            return Ok(Self::default());
61        }
62        let amplfunc = std::env::var("AMPLFUNC").map_err(|_| {
63            "problem uses external functions but AMPLFUNC is not set; \
64             set AMPLFUNC to a newline-separated list of AMPL shared-library paths"
65                .to_string()
66        })?;
67        let mut libs: Vec<Arc<ExternalLibrary>> = Vec::new();
68        for path_str in amplfunc
69            .split('\n')
70            .map(|s| s.trim())
71            .filter(|s| !s.is_empty())
72        {
73            let path = std::path::Path::new(path_str);
74            let lib = ExternalLibrary::load(path).map_err(|e| format!("AMPLFUNC: {e}"))?;
75            libs.push(Arc::new(lib));
76        }
77
78        let mut funcs_by_id: HashMap<usize, (Arc<ExternalLibrary>, String)> = HashMap::new();
79        for id in referenced_ids {
80            let decl = imported_funcs
81                .iter()
82                .find(|f| f.id == *id)
83                .ok_or_else(|| format!("funcall id {id} has no F<{id}> declaration"))?;
84            let found = libs
85                .iter()
86                .find(|lib| lib.get(&decl.name).is_some())
87                .ok_or_else(|| {
88                    format!(
89                        "external function '{}' (id {}) not found in any library on AMPLFUNC",
90                        decl.name, decl.id
91                    )
92                })?;
93            funcs_by_id.insert(*id, (found.clone(), decl.name.clone()));
94        }
95        Ok(Self { funcs_by_id })
96    }
97}
98
99/// Walk an `Expr` and collect every funcall id it references (including
100/// through CSEs). Used to build an `ExternalResolver` covering exactly the
101/// functions a problem actually uses.
102pub fn collect_funcall_ids(e: &Expr, out: &mut std::collections::BTreeSet<usize>) {
103    // Shared `Cse` bodies are visited once, memoized on `Arc` pointer
104    // identity: without it this is Θ(2^depth) on a subexpression-sharing
105    // DAG, and `NlTnlp::try_new` runs it over every objective and
106    // constraint. Safe because `out` is a set — a repeat walk of the same
107    // body inserts exactly the ids the first one did. `HashSet::new` does
108    // not allocate until the first insert, so a CSE-free expression pays
109    // nothing.
110    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
111    collect_funcall_ids_memo(e, out, &mut seen);
112}
113
114fn collect_funcall_ids_memo(
115    e: &Expr,
116    out: &mut std::collections::BTreeSet<usize>,
117    seen: &mut std::collections::HashSet<*const Expr>,
118) {
119    match e {
120        Expr::Const(_) | Expr::Var(_) => {}
121        Expr::Binary(_, a, b) => {
122            collect_funcall_ids_memo(a, out, seen);
123            collect_funcall_ids_memo(b, out, seen);
124        }
125        Expr::Unary(_, a) => collect_funcall_ids_memo(a, out, seen),
126        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
127            for a in args {
128                collect_funcall_ids_memo(a, out, seen);
129            }
130        }
131        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
132            collect_funcall_ids_memo(a, out, seen);
133            collect_funcall_ids_memo(b, out, seen);
134        }
135        Expr::Not(a) => collect_funcall_ids_memo(a, out, seen),
136        Expr::Cond { cond, then_, else_ } => {
137            collect_funcall_ids_memo(cond, out, seen);
138            collect_funcall_ids_memo(then_, out, seen);
139            collect_funcall_ids_memo(else_, out, seen);
140        }
141        Expr::Cse(body) => {
142            if seen.insert(std::sync::Arc::as_ptr(body)) {
143                collect_funcall_ids_memo(body, out, seen);
144            }
145        }
146        Expr::Funcall { id, args } => {
147            out.insert(*id);
148            for arg in args {
149                if let FuncallArg::Real(e) = arg {
150                    collect_funcall_ids_memo(e, out, seen);
151                }
152            }
153        }
154    }
155}
156
157/// Process-wide lock serialising every call that crosses the AMPL external
158/// ABI. Real AMPL libraries (e.g. IDAES general_helmholtz) keep mutable
159/// global state (cached parameters, tabulated lookups) and are not safe for
160/// concurrent entry. Python's `pyomo.core.base.external` relies on the GIL
161/// for the same guarantee.
162fn ampl_lock() -> &'static Mutex<()> {
163    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
164    LOCK.get_or_init(|| Mutex::new(()))
165}
166
167/// FUNCADD_TYPE bits (mirrors `funcadd.h`).
168pub const FUNCADD_REAL_VALUED: i32 = 0;
169/// Set if the function consumes string arguments. Value is still real.
170pub const FUNCADD_STRING_ARGS: i32 = 1;
171/// Set if the function is allowed to have a variable number of args.
172pub const FUNCADD_OUTPUT_ARGS: i32 = 2;
173pub const FUNCADD_RANDOM_VALUED: i32 = 4;
174
175/// The `arglist` struct from AMPL's `funcadd.h`. Layout must match exactly.
176#[repr(C)]
177pub struct Arglist {
178    pub n: c_int,               // number of args
179    pub nr: c_int,              // number of real input args
180    pub at: *mut c_int,         // argument types
181    pub ra: *mut f64,           // pure real args (IN/OUT/INOUT)
182    pub sa: *mut *const c_char, // symbolic IN args
183    pub derivs: *mut f64,       // partial derivatives (if non-null)
184    pub hes: *mut f64,          // second partials (if non-null)
185    pub dig: *mut c_char,       // skip-derivatives flags
186    pub funcinfo: *mut c_void,  // per-function cookie (set by Addfunc)
187    pub ae: *mut AmplExports,   // points back at our AmplExports
188    pub f: *mut c_void,         // AMPL-internal
189    pub tva: *mut c_void,       // AMPL-internal
190    pub errmsg: *mut c_char,    // error description set by the function
191    pub tmi: *mut c_void,       // Tempmem cookie
192    pub private: *mut c_char,
193    pub nin: c_int,
194    pub nout: c_int,
195    pub nsin: c_int,
196    pub nsout: c_int,
197}
198
199/// Pointer to a user-defined real-valued function, matching
200/// `typedef real (*rfunc)(arglist*)`.
201pub type Rfunc = unsafe extern "C" fn(*mut Arglist) -> f64;
202
203/// Pointer to the `Addfunc` callback provided by the caller.
204pub type AddfuncFn = unsafe extern "C" fn(
205    name: *const c_char,
206    f: Rfunc,
207    ty: c_int,
208    nargs: c_int,
209    funcinfo: *mut c_void,
210    ae: *mut AmplExports,
211);
212
213/// Pointer to the `RandSeedSetter` callback.
214pub type RandSeedSetter = unsafe extern "C" fn(*mut c_void, std::os::raw::c_ulong);
215
216/// Pointer to the `Addrandinit` callback.
217pub type AddrandinitFn =
218    unsafe extern "C" fn(ae: *mut AmplExports, setter: RandSeedSetter, v: *mut c_void);
219
220/// Pointer to the `AtReset` callback.
221pub type AtResetFn = unsafe extern "C" fn(ae: *mut AmplExports, f: *mut c_void, v: *mut c_void);
222
223/// The `AmplExports` struct from AMPL's `funcadd.h`. Layout must match
224/// exactly. Function pointers we don't implement are held as `*mut c_void`
225/// (null) — AMPL's ABI does not require a caller to populate them unless the
226/// loaded library actually invokes them.
227#[repr(C)]
228pub struct AmplExports {
229    pub std_err: *mut c_void,
230    pub addfunc: Option<AddfuncFn>,
231    pub asl_date: c_long,
232    pub fprintf: *mut c_void,
233    pub printf: *mut c_void,
234    pub sprintf: *mut c_void,
235    pub vfprintf: *mut c_void,
236    pub vsprintf: *mut c_void,
237    pub strtod: *mut c_void,
238    pub crypto: *mut c_void,
239    pub asl: *mut c_char,
240    pub at_exit: *mut c_void,
241    pub at_reset: Option<AtResetFn>,
242    pub tempmem: *mut c_void,
243    pub add_table_handler: *mut c_void,
244    pub private_ae: *mut c_char,
245    pub qsortv: *mut c_void,
246
247    pub std_in: *mut c_void,
248    pub std_out: *mut c_void,
249    pub clearerr: *mut c_void,
250    pub fclose: *mut c_void,
251    pub fdopen: *mut c_void,
252    pub feof: *mut c_void,
253    pub ferror: *mut c_void,
254    pub fflush: *mut c_void,
255    pub fgetc: *mut c_void,
256    pub fgets: *mut c_void,
257    pub fileno: *mut c_void,
258    pub fopen: *mut c_void,
259    pub fputc: *mut c_void,
260    pub fputs: *mut c_void,
261    pub fread: *mut c_void,
262    pub freopen: *mut c_void,
263    pub fscanf: *mut c_void,
264    pub fseek: *mut c_void,
265    pub ftell: *mut c_void,
266    pub fwrite: *mut c_void,
267    pub pclose: *mut c_void,
268    pub perror: *mut c_void,
269    pub popen: *mut c_void,
270    pub puts: *mut c_void,
271    pub rewind: *mut c_void,
272    pub scanf: *mut c_void,
273    pub setbuf: *mut c_void,
274    pub setvbuf: *mut c_void,
275    pub sscanf: *mut c_void,
276    pub tempnam: *mut c_void,
277    pub tmpfile: *mut c_void,
278    pub tmpnam: *mut c_void,
279    pub ungetc: *mut c_void,
280    pub ai: *mut c_void,
281    pub getenv: *mut c_void,
282    pub breakfunc: *mut c_void,
283    pub breakarg: *mut c_char,
284
285    // Items available with ASLdate >= 20020501.
286    pub snprintf: *mut c_void,
287    pub vsnprintf: *mut c_void,
288
289    pub addrand: *mut c_void,
290    pub addrandinit: Option<AddrandinitFn>,
291}
292
293// SAFETY: AmplExports itself contains only raw pointers and integers. The
294// library never reads/writes it from another thread concurrently with us
295// (AMPL's model is single-threaded per problem), and we never share it
296// across threads. The Send/Sync bounds only matter because we box the
297// registry inside Arcs.
298unsafe impl Send for AmplExports {}
299unsafe impl Sync for AmplExports {}
300
301/// A function registered by a library via `Addfunc`. Mirrors the ASL
302/// `FUNCADD_TYPE` bits in `funcadd.h`.
303#[derive(Debug, Clone)]
304pub struct RegisteredFunc {
305    pub name: String,
306    pub rfunc: Rfunc,
307    /// OR of FUNCADD_TYPE bits.
308    pub ty: i32,
309    /// Declared arg count. >=0 means exactly that many, <=-1 means "at least
310    /// -(nargs+1) args".
311    pub nargs: i32,
312    /// Cookie set by the library; must be passed through to arglist.funcinfo.
313    pub funcinfo: *mut c_void,
314}
315
316// SAFETY: funcinfo is an opaque cookie owned by the library. We never
317// dereference it; we only pass it back to the library's functions, which
318// expect it. No thread-safety contract is violated by sending the struct.
319unsafe impl Send for RegisteredFunc {}
320unsafe impl Sync for RegisteredFunc {}
321
322impl std::fmt::Debug for ExternalLibrary {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        f.debug_struct("ExternalLibrary")
325            .field("funcs", &self.funcs.keys().collect::<Vec<_>>())
326            .finish()
327    }
328}
329
330/// A loaded external-function library plus its registered functions.
331pub struct ExternalLibrary {
332    /// Keep the library alive — it owns the code pages the function pointers
333    /// reference. Arc so `LoadedExternals` can share it. Absent on targets
334    /// with no dynamic loader, where [`ExternalLibrary::load`] always fails
335    /// and no instance is ever built.
336    #[cfg(any(unix, windows))]
337    _lib: Arc<Library>,
338    /// The AmplExports we handed to `funcadd_ASL`. Must be kept alive (pinned
339    /// in a Box) because some libraries may capture its address for later
340    /// use (e.g. for `AtReset` bookkeeping).
341    _ae: Box<AmplExports>,
342    /// Registrations collected during `funcadd_ASL`.
343    funcs: HashMap<String, RegisteredFunc>,
344}
345
346impl ExternalLibrary {
347    /// Stub for targets with no dynamic loader (`wasm32-*`). A `.nl` model
348    /// that actually references an imported function cannot be evaluated
349    /// there, so report that instead of pretending the library loaded.
350    /// Models with no external functions never reach this path —
351    /// `NlTnlp::try_new` skips resolution when nothing is referenced.
352    #[cfg(not(any(unix, windows)))]
353    pub fn load(path: &Path) -> Result<Self, String> {
354        Err(format!(
355            "cannot load '{}': AMPL imported (external) functions need a \
356             dynamic library loader, which this target does not have",
357            path.display()
358        ))
359    }
360
361    /// Open a shared library at `path` and invoke its `funcadd_ASL` entry
362    /// point, collecting all functions it registers.
363    #[cfg(any(unix, windows))]
364    pub fn load(path: &Path) -> Result<Self, String> {
365        // Serialise all ABI crossings: library init code and registration
366        // may touch global state that isn't safe under concurrent entry.
367        let _guard = ampl_lock().lock().unwrap_or_else(|e| e.into_inner());
368        // SAFETY: libloading::Library::new is unsafe because it can run
369        // arbitrary initialisers from the shared object. We trust the user's
370        // AMPLFUNC path the same way AMPL/IPOPT do.
371        let lib = unsafe { Library::new(path) }
372            .map_err(|e| format!("failed to open '{}': {}", path.display(), e))?;
373
374        // Resolve `funcadd_ASL`. AMPL's macro `#define funcadd funcadd_ASL`
375        // means every conforming library exports this symbol.
376        type FuncaddFn = unsafe extern "C" fn(*mut AmplExports);
377        let funcadd: Symbol<FuncaddFn> = unsafe { lib.get(b"funcadd_ASL\0") }
378            .map_err(|e| format!("no funcadd_ASL in '{}': {}", path.display(), e))?;
379
380        // Build an AmplExports. Most fields null — the library doesn't call
381        // them (same assumption Pyomo makes). Only the three hooks we can
382        // realistically service are set.
383        let mut ae = Box::new(AmplExports {
384            std_err: ptr::null_mut(),
385            addfunc: Some(trampoline_addfunc),
386            // ASLdate >= 20020501 unlocks the SnprintF/VsnprintF slots.
387            // Pyomo uses 20160307; mirror that.
388            asl_date: 20160307,
389            fprintf: ptr::null_mut(),
390            printf: ptr::null_mut(),
391            sprintf: ptr::null_mut(),
392            vfprintf: ptr::null_mut(),
393            vsprintf: ptr::null_mut(),
394            strtod: ptr::null_mut(),
395            crypto: ptr::null_mut(),
396            asl: ptr::null_mut(),
397            at_exit: ptr::null_mut(),
398            at_reset: Some(trampoline_atreset),
399            tempmem: ptr::null_mut(),
400            add_table_handler: ptr::null_mut(),
401            private_ae: ptr::null_mut(),
402            qsortv: ptr::null_mut(),
403            std_in: ptr::null_mut(),
404            std_out: ptr::null_mut(),
405            clearerr: ptr::null_mut(),
406            fclose: ptr::null_mut(),
407            fdopen: ptr::null_mut(),
408            feof: ptr::null_mut(),
409            ferror: ptr::null_mut(),
410            fflush: ptr::null_mut(),
411            fgetc: ptr::null_mut(),
412            fgets: ptr::null_mut(),
413            fileno: ptr::null_mut(),
414            fopen: ptr::null_mut(),
415            fputc: ptr::null_mut(),
416            fputs: ptr::null_mut(),
417            fread: ptr::null_mut(),
418            freopen: ptr::null_mut(),
419            fscanf: ptr::null_mut(),
420            fseek: ptr::null_mut(),
421            ftell: ptr::null_mut(),
422            fwrite: ptr::null_mut(),
423            pclose: ptr::null_mut(),
424            perror: ptr::null_mut(),
425            popen: ptr::null_mut(),
426            puts: ptr::null_mut(),
427            rewind: ptr::null_mut(),
428            scanf: ptr::null_mut(),
429            setbuf: ptr::null_mut(),
430            setvbuf: ptr::null_mut(),
431            sscanf: ptr::null_mut(),
432            tempnam: ptr::null_mut(),
433            tmpfile: ptr::null_mut(),
434            tmpnam: ptr::null_mut(),
435            ungetc: ptr::null_mut(),
436            ai: ptr::null_mut(),
437            getenv: ptr::null_mut(),
438            breakfunc: ptr::null_mut(),
439            breakarg: ptr::null_mut(),
440            snprintf: ptr::null_mut(),
441            vsnprintf: ptr::null_mut(),
442            addrand: ptr::null_mut(),
443            addrandinit: Some(trampoline_addrandinit),
444        });
445
446        // Drive registrations into a thread-local sink so the C trampoline
447        // has somewhere to deposit them without capturing Rust state.
448        REGISTRY_SINK.with(|sink| {
449            let mut guard = sink.borrow_mut();
450            assert!(
451                guard.is_none(),
452                "nested ExternalLibrary::load is not supported"
453            );
454            *guard = Some(HashMap::new());
455        });
456
457        // SAFETY: funcadd is a valid C function from the loaded library; we
458        // pass it a correctly-shaped AmplExports.
459        unsafe { funcadd(ae.as_mut()) };
460
461        let funcs = REGISTRY_SINK
462            .with(|sink| sink.borrow_mut().take())
463            .unwrap_or_default();
464
465        Ok(ExternalLibrary {
466            _lib: Arc::new(lib),
467            _ae: ae,
468            funcs,
469        })
470    }
471
472    /// Names of all functions registered by this library.
473    pub fn function_names(&self) -> impl Iterator<Item = &str> {
474        self.funcs.keys().map(|s| s.as_str())
475    }
476
477    /// Look up a registered function by name.
478    pub fn get(&self, name: &str) -> Option<&RegisteredFunc> {
479        self.funcs.get(name)
480    }
481
482    /// Evaluate a registered function with the given positional arguments.
483    ///
484    /// Arguments are encoded per the AMPL `arglist` ABI: real args are stored
485    /// in `ra[]`, string args in `sa[]`, and `at[i]` maps argument position
486    /// `i` to either a real-slot index (`at[i] >= 0`) or a string-slot index
487    /// (`at[i] < 0`, decoded as `-(at[i]+1)`).
488    ///
489    /// If `want_derivs` is set, a length-`nr` derivative buffer is allocated
490    /// and returned on success. If `want_hes` is set, a length-`nr*(nr+1)/2`
491    /// Hessian buffer is also allocated and returned. The library is told to
492    /// fill both by the non-null `arglist.derivs` / `arglist.hes` pointers.
493    pub fn eval(
494        &self,
495        name: &str,
496        args: &[ExternalArg<'_>],
497        want_derivs: bool,
498        want_hes: bool,
499    ) -> Result<EvalResult, String> {
500        let rf = self
501            .funcs
502            .get(name)
503            .ok_or_else(|| format!("no such external function '{name}'"))?;
504
505        // Validate arity against the registered signature.
506        let n = args.len() as i32;
507        if rf.nargs >= 0 {
508            if rf.nargs != n {
509                return Err(format!(
510                    "external '{name}' expects {} args, got {}",
511                    rf.nargs, n
512                ));
513            }
514        } else {
515            // Negative: minimum -(nargs+1) args.
516            let min_args = -(rf.nargs + 1);
517            if n < min_args {
518                return Err(format!(
519                    "external '{name}' expects at least {min_args} args, got {n}"
520                ));
521            }
522        }
523
524        // Bucket args: build at[], ra[], sa[] in lockstep with their indices.
525        let mut at_vec: Vec<c_int> = Vec::with_capacity(args.len());
526        let mut ra_vec: Vec<f64> = Vec::new();
527        let mut sa_owned: Vec<CString> = Vec::new();
528        for a in args {
529            match a {
530                ExternalArg::Real(x) => {
531                    at_vec.push(ra_vec.len() as c_int);
532                    ra_vec.push(*x);
533                }
534                ExternalArg::Str(s) => {
535                    let cs = CString::new(*s)
536                        .map_err(|_| format!("external '{name}' string arg contains NUL"))?;
537                    at_vec.push(-(sa_owned.len() as c_int + 1));
538                    sa_owned.push(cs);
539                }
540            }
541        }
542        let nr = ra_vec.len() as c_int;
543        let sa_ptrs: Vec<*const c_char> = sa_owned.iter().map(|s| s.as_ptr()).collect();
544
545        // If the library declared FUNCADD_STRING_ARGS we let it see sa; if it
546        // did not, the library shouldn't be called with strings. Surface that.
547        let has_strings = !sa_owned.is_empty();
548        if has_strings && (rf.ty & FUNCADD_STRING_ARGS) == 0 {
549            return Err(format!(
550                "external '{name}' is not declared FUNCADD_STRING_ARGS but was \
551                 called with string arguments"
552            ));
553        }
554
555        // Optional output buffers.
556        let mut derivs_buf: Vec<f64> = if want_derivs {
557            vec![0.0; nr as usize]
558        } else {
559            Vec::new()
560        };
561        let hes_len = if want_hes {
562            (nr as usize) * ((nr as usize) + 1) / 2
563        } else {
564            0
565        };
566        let mut hes_buf: Vec<f64> = if want_hes {
567            vec![0.0; hes_len]
568        } else {
569            Vec::new()
570        };
571
572        // Space for a library-set error message. The ABI lets a library
573        // signal an error two ways (see `decode_external_errmsg`): by writing
574        // into this buffer, OR — the canonical conforming path — by
575        // *reassigning* `arglist.errmsg` to its own string. We seed the field
576        // with this buffer's address and remember it so the reassignment is
577        // detectable afterwards.
578        let mut errmsg_buf: Vec<c_char> = vec![0; 1024];
579        let errmsg_orig_ptr = errmsg_buf.as_ptr();
580
581        // Build the arglist. Pointers into Rust-owned buffers are valid for
582        // the duration of the call since we hold those Vecs in this stack
583        // frame and the callee runs synchronously.
584        let mut al = Arglist {
585            n,
586            nr,
587            at: if at_vec.is_empty() {
588                ptr::null_mut()
589            } else {
590                at_vec.as_mut_ptr()
591            },
592            ra: if ra_vec.is_empty() {
593                ptr::null_mut()
594            } else {
595                ra_vec.as_mut_ptr()
596            },
597            sa: if sa_ptrs.is_empty() {
598                ptr::null_mut()
599            } else {
600                sa_ptrs.as_ptr() as *mut *const c_char
601            },
602            derivs: if want_derivs {
603                derivs_buf.as_mut_ptr()
604            } else {
605                ptr::null_mut()
606            },
607            hes: if want_hes {
608                hes_buf.as_mut_ptr()
609            } else {
610                ptr::null_mut()
611            },
612            dig: ptr::null_mut(),
613            funcinfo: rf.funcinfo,
614            // Some libraries read arglist.ae (e.g. to call fprintf); point at
615            // the same AmplExports we handed to funcadd_ASL.
616            ae: self._ae_ptr(),
617            f: ptr::null_mut(),
618            tva: ptr::null_mut(),
619            errmsg: errmsg_buf.as_mut_ptr(),
620            tmi: ptr::null_mut(),
621            private: ptr::null_mut(),
622            nin: 0,
623            nout: 0,
624            nsin: 0,
625            nsout: 0,
626        };
627
628        // SAFETY: rfunc is a valid extern "C" function pointer provided by
629        // the loaded library; arglist layout matches funcadd.h exactly.
630        // The AMPL lock serialises concurrent entry into the library.
631        let _guard = ampl_lock().lock().unwrap_or_else(|e| e.into_inner());
632        let value = unsafe { (rf.rfunc)(&mut al as *mut Arglist) };
633        drop(_guard);
634
635        // Surface a library-reported error from *either* ABI channel: the
636        // reassigned `arglist.errmsg` pointer (the conforming path) or our
637        // pre-pointed buffer. Checking only the buffer would miss every
638        // library that does `al->Errmsg = "...";`, silently consuming garbage.
639        // SAFETY: `al.errmsg` is either our zeroed NUL-terminated buffer or a
640        // C string the library assigned; both are valid to read as a CStr.
641        if let Some(msg) =
642            unsafe { decode_external_errmsg(al.errmsg, errmsg_orig_ptr, errmsg_buf[0]) }
643        {
644            return Err(format!("external '{name}' reported: {msg}"));
645        }
646
647        Ok(EvalResult {
648            value,
649            derivs: if want_derivs { Some(derivs_buf) } else { None },
650            hessian: if want_hes { Some(hes_buf) } else { None },
651        })
652    }
653
654    // Raw mutable pointer to the owned AmplExports. Used when building an
655    // arglist so the library can call back through the same table it was
656    // registered with. The Box is pinned for the lifetime of self.
657    fn _ae_ptr(&self) -> *mut AmplExports {
658        // Cast away the const; we never mutate the AmplExports ourselves.
659        (&*self._ae as *const AmplExports) as *mut AmplExports
660    }
661}
662
663/// One positional argument to an external function.
664#[derive(Debug, Clone, Copy)]
665pub enum ExternalArg<'a> {
666    Real(f64),
667    Str(&'a str),
668}
669
670/// Return value from [`ExternalLibrary::eval`].
671#[derive(Debug, Clone)]
672pub struct EvalResult {
673    /// Function value.
674    pub value: f64,
675    /// `df/dx_i` for each real argument, in `ra[]` order, if `want_derivs`.
676    pub derivs: Option<Vec<f64>>,
677    /// Packed upper-triangular Hessian in AMPL's convention,
678    /// `hes[i + j*(j+1)/2]` for `0 <= i <= j < nr`, if `want_hes`.
679    pub hessian: Option<Vec<f64>>,
680}
681
682/// Decode an external function's error signal after its `rfunc` returns.
683///
684/// The AMPL `funcadd` ABI lets a library report an error two ways:
685///
686/// 1. **Reassign** `arglist.errmsg` to its own (usually static) C string —
687///    `al->Errmsg = "T out of range";`. This is the conforming path used by
688///    real libraries (e.g. IDAES Helmholtz on out-of-domain evals). The
689///    caller's pre-pointed buffer is left untouched.
690/// 2. Write a string into the buffer the caller pointed `errmsg` at before the
691///    call.
692///
693/// We seed `arglist.errmsg` with our buffer's address (`orig_buf_ptr`). After
694/// the call: if the field no longer equals that address (and is non-null) the
695/// library reassigned it → read from the new pointer; otherwise fall back to
696/// the buffer when its first byte is non-zero. Returns `None` when neither
697/// channel carries a message. Checking only the buffer (the prior behavior)
698/// silently dropped every channel-1 error and let the IPM consume NaN/garbage
699/// f/∇f/∇²f.
700///
701/// # Safety
702/// `errmsg_field` (when reassigned) and `orig_buf_ptr` must each point at a
703/// readable NUL-terminated C string for the duration of the read.
704unsafe fn decode_external_errmsg(
705    errmsg_field: *const c_char,
706    orig_buf_ptr: *const c_char,
707    buf_first: c_char,
708) -> Option<String> {
709    if !errmsg_field.is_null() && errmsg_field != orig_buf_ptr {
710        // Channel 1: the library reassigned the pointer to its own string.
711        // SAFETY: caller guarantees `errmsg_field` is a NUL-terminated string.
712        return Some(
713            unsafe { CStr::from_ptr(errmsg_field) }
714                .to_string_lossy()
715                .into_owned(),
716        );
717    }
718    if buf_first != 0 {
719        // Channel 2: the library wrote into the caller-provided buffer.
720        // SAFETY: caller guarantees `orig_buf_ptr` is a NUL-terminated string.
721        return Some(
722            unsafe { CStr::from_ptr(orig_buf_ptr) }
723                .to_string_lossy()
724                .into_owned(),
725        );
726    }
727    None
728}
729
730// ---------------------------------------------------------------------------
731// Registration trampoline.
732//
733// `funcadd_ASL` can call Addfunc multiple times (once per registered name).
734// Rust closures can't be converted to `extern "C"` function pointers, so we
735// route each call through a free function that deposits into a thread-local
736// sink populated by `ExternalLibrary::load`.
737// ---------------------------------------------------------------------------
738
739thread_local! {
740    static REGISTRY_SINK: std::cell::RefCell<Option<HashMap<String, RegisteredFunc>>> =
741        std::cell::RefCell::new(None);
742}
743
744/// C-callable trampoline that receives Addfunc calls from the shared library.
745#[cfg(any(unix, windows))]
746unsafe extern "C" fn trampoline_addfunc(
747    name: *const c_char,
748    f: Rfunc,
749    ty: c_int,
750    nargs: c_int,
751    funcinfo: *mut c_void,
752    _ae: *mut AmplExports,
753) {
754    if name.is_null() {
755        return;
756    }
757    // SAFETY: AMPL guarantees name is a NUL-terminated C string.
758    let cname = unsafe { CStr::from_ptr(name) };
759    let name_str = match cname.to_str() {
760        Ok(s) => s.to_owned(),
761        Err(_) => return, // non-UTF8 name — skip; real libs use ASCII.
762    };
763    REGISTRY_SINK.with(|sink| {
764        if let Some(map) = sink.borrow_mut().as_mut() {
765            map.insert(
766                name_str.clone(),
767                RegisteredFunc {
768                    name: name_str,
769                    rfunc: f,
770                    ty: ty as i32,
771                    nargs: nargs as i32,
772                    funcinfo,
773                },
774            );
775        }
776    });
777}
778
779/// Stub — some libraries ask us to register an AtReset callback. Pyomo logs a
780/// warning and does nothing. We do the same.
781#[cfg(any(unix, windows))]
782unsafe extern "C" fn trampoline_atreset(_ae: *mut AmplExports, _f: *mut c_void, _v: *mut c_void) {
783    tracing::debug!("external library registered an AtReset callback; ignoring");
784}
785
786/// Stub — invoked by libraries that use random-valued externals. We just
787/// seed with 1 (matches Pyomo's default; no randomness in KKT paths).
788#[cfg(any(unix, windows))]
789unsafe extern "C" fn trampoline_addrandinit(
790    _ae: *mut AmplExports,
791    setter: RandSeedSetter,
792    v: *mut c_void,
793) {
794    unsafe { setter(v, 1) };
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    fn idaes_dylib() -> Option<std::path::PathBuf> {
802        let home = std::env::var_os("HOME")?;
803        let p = std::path::PathBuf::from(home).join(".idaes/bin/general_helmholtz_external.dylib");
804        if p.exists() { Some(p) } else { None }
805    }
806
807    fn idaes_params_dir() -> Option<String> {
808        let home = std::env::var_os("HOME")?;
809        let p = std::path::PathBuf::from(home).join(
810            "Dropbox/uv/.venv/lib/python3.12/site-packages/idaes/\
811             models/properties/general_helmholtz/components/parameters/",
812        );
813        if p.exists() {
814            p.to_str().map(|s| s.to_owned())
815        } else {
816            None
817        }
818    }
819
820    /// Opening the IDAES Helmholtz dylib (when present locally) should
821    /// surface the three functions used by the issue #15 fixture.
822    #[test]
823    fn load_idaes_helmholtz_dylib_registers_known_functions() {
824        let Some(path) = idaes_dylib() else {
825            eprintln!("skipping: IDAES dylib not present");
826            return;
827        };
828
829        let lib = ExternalLibrary::load(&path).expect("load should succeed");
830        let names: Vec<String> = lib.function_names().map(|s| s.to_owned()).collect();
831
832        for required in &["vf_hp", "h_liq_hp", "h_vap_hp"] {
833            assert!(
834                names.iter().any(|n| n == required),
835                "expected {required} in registered names: {names:?}"
836            );
837        }
838    }
839
840    /// Evaluate vf_hp at the NL fixture's initial guess. We don't assert the
841    /// exact numeric value (that's an IDAES invariant, not a ripopt one), but
842    /// the return value must be finite and the call must not set errmsg.
843    #[test]
844    fn eval_vf_hp_at_fixture_initial_point() {
845        let Some(path) = idaes_dylib() else {
846            eprintln!("skipping: IDAES dylib not present");
847            return;
848        };
849        let Some(params_dir) = idaes_params_dir() else {
850            eprintln!("skipping: IDAES parameters directory not present");
851            return;
852        };
853
854        let lib = ExternalLibrary::load(&path).expect("load");
855        // Fixture initial guess: h = 1878.71 kJ/kg-scaled, p = 101.325 kPa
856        // (the scaled values actually passed through the v3/v4 slots are
857        // 1878.71 * 0.0555... and 101325 * 0.001 respectively; using raw
858        // values here, the function should still return a finite number).
859        let args = [
860            ExternalArg::Str("h2o"),
861            ExternalArg::Real(1878.71 * 0.055508472036052976),
862            ExternalArg::Real(101325.0 * 0.001),
863            ExternalArg::Str(&params_dir),
864        ];
865        let res = lib.eval("vf_hp", &args, false, false).expect("eval");
866        assert!(
867            res.value.is_finite(),
868            "vf_hp returned non-finite value {}",
869            res.value
870        );
871    }
872
873    /// Same call path, but asking for first derivatives. derivs must be a
874    /// length-2 buffer (nr=2) of finite values.
875    #[test]
876    fn eval_vf_hp_with_derivatives() {
877        let Some(path) = idaes_dylib() else {
878            eprintln!("skipping: IDAES dylib not present");
879            return;
880        };
881        let Some(params_dir) = idaes_params_dir() else {
882            eprintln!("skipping: IDAES parameters directory not present");
883            return;
884        };
885
886        let lib = ExternalLibrary::load(&path).expect("load");
887        let args = [
888            ExternalArg::Str("h2o"),
889            ExternalArg::Real(1878.71 * 0.055508472036052976),
890            ExternalArg::Real(101325.0 * 0.001),
891            ExternalArg::Str(&params_dir),
892        ];
893        let res = lib.eval("vf_hp", &args, true, false).expect("eval");
894        let derivs = res.derivs.expect("derivs requested");
895        assert_eq!(derivs.len(), 2, "nr=2 reals -> 2 derivatives");
896        for (i, d) in derivs.iter().enumerate() {
897            assert!(d.is_finite(), "derivs[{i}] = {d} not finite");
898        }
899    }
900
901    /// Also request the packed Hessian. For nr=2 reals, that's 3 entries
902    /// (H00, H01, H11) in AMPL's packed upper-triangular layout.
903    #[test]
904    fn eval_vf_hp_with_hessian() {
905        let Some(path) = idaes_dylib() else {
906            eprintln!("skipping: IDAES dylib not present");
907            return;
908        };
909        let Some(params_dir) = idaes_params_dir() else {
910            eprintln!("skipping: IDAES parameters directory not present");
911            return;
912        };
913
914        let lib = ExternalLibrary::load(&path).expect("load");
915        let args = [
916            ExternalArg::Str("h2o"),
917            ExternalArg::Real(1878.71 * 0.055508472036052976),
918            ExternalArg::Real(101325.0 * 0.001),
919            ExternalArg::Str(&params_dir),
920        ];
921        let res = lib.eval("vf_hp", &args, true, true).expect("eval");
922        let hes = res.hessian.expect("hessian requested");
923        assert_eq!(hes.len(), 3, "nr=2 -> packed Hessian of length 3");
924        for (i, h) in hes.iter().enumerate() {
925            assert!(h.is_finite(), "hes[{i}] = {h} not finite");
926        }
927    }
928
929    // --- H5: errmsg detection across both funcadd ABI channels ---
930
931    /// A conforming `rfunc` that signals an error the canonical AMPL way: by
932    /// **reassigning** `al->Errmsg` to its own static C string (leaving any
933    /// caller-provided buffer untouched), and returning NaN like an
934    /// out-of-domain evaluation.
935    unsafe extern "C" fn rfunc_reassigns_errmsg(al: *mut Arglist) -> f64 {
936        static MSG: &[u8] = b"T out of range\0";
937        // SAFETY: `al` is a valid, exclusively-borrowed Arglist for the call.
938        unsafe {
939            (*al).errmsg = MSG.as_ptr() as *mut c_char;
940        }
941        f64::NAN
942    }
943
944    /// Build an `Arglist` with every pointer null except `errmsg`. Sufficient
945    /// for a `rfunc` that only manipulates the error channel.
946    fn null_arglist(errmsg: *mut c_char) -> Arglist {
947        Arglist {
948            n: 1,
949            nr: 1,
950            at: ptr::null_mut(),
951            ra: ptr::null_mut(),
952            sa: ptr::null_mut(),
953            derivs: ptr::null_mut(),
954            hes: ptr::null_mut(),
955            dig: ptr::null_mut(),
956            funcinfo: ptr::null_mut(),
957            ae: ptr::null_mut(),
958            f: ptr::null_mut(),
959            tva: ptr::null_mut(),
960            errmsg,
961            tmi: ptr::null_mut(),
962            private: ptr::null_mut(),
963            nin: 0,
964            nout: 0,
965            nsin: 0,
966            nsout: 0,
967        }
968    }
969
970    /// End-to-end over the real `Arglist` + a real `extern "C"` call: a library
971    /// that reports an error by reassigning `al->Errmsg` (channel 1) must be
972    /// detected. Pre-fix, `eval` only inspected the caller buffer — which a
973    /// reassigning library never touches — so the error was invisible and the
974    /// IPM consumed the NaN return as a valid value.
975    #[test]
976    fn reassigned_errmsg_pointer_is_detected_end_to_end() {
977        let mut errmsg_buf: Vec<c_char> = vec![0; 1024];
978        let orig_ptr = errmsg_buf.as_ptr();
979        let mut al = null_arglist(errmsg_buf.as_mut_ptr());
980
981        // SAFETY: the rfunc matches the ABI and only writes `al.errmsg`.
982        let v = unsafe { rfunc_reassigns_errmsg(&mut al) };
983        assert!(v.is_nan(), "the failing eval returned NaN");
984
985        // A reassigning library leaves the caller buffer zeroed, so the old
986        // `errmsg_buf[0] != 0` check (the bug) saw nothing.
987        assert_eq!(
988            errmsg_buf[0], 0,
989            "a reassigning library must not touch the caller buffer"
990        );
991
992        // The fixed decode reads the reassigned pointer and surfaces the error.
993        let decoded = unsafe { decode_external_errmsg(al.errmsg, orig_ptr, errmsg_buf[0]) };
994        assert_eq!(
995            decoded.as_deref(),
996            Some("T out of range"),
997            "the reassigned errmsg pointer must be surfaced as an error"
998        );
999    }
1000
1001    /// The buffer channel (a library that writes into the caller buffer) and
1002    /// the no-error cases still behave correctly.
1003    #[test]
1004    fn decode_external_errmsg_buffer_and_none_channels() {
1005        // Channel 2: library wrote a string into the caller buffer.
1006        let mut buf: Vec<c_char> = vec![0; 16];
1007        for (i, b) in b"bad input".iter().enumerate() {
1008            buf[i] = *b as c_char;
1009        }
1010        let orig = buf.as_ptr();
1011        let decoded = unsafe { decode_external_errmsg(orig, orig, buf[0]) };
1012        assert_eq!(decoded.as_deref(), Some("bad input"));
1013
1014        // No error: field still points at the (zeroed) buffer.
1015        let zero: Vec<c_char> = vec![0; 16];
1016        let z = zero.as_ptr();
1017        assert_eq!(unsafe { decode_external_errmsg(z, z, zero[0]) }, None);
1018
1019        // No error via an explicitly NULL field (some libraries zero it).
1020        assert_eq!(unsafe { decode_external_errmsg(ptr::null(), z, 0) }, None);
1021    }
1022}