Skip to main content

pounce_common/
options_list.rs

1//! User-set options list.
2//!
3//! Mirrors `Common/IpOptionsList.{hpp,cpp}`. Stores name → value
4//! string mappings; lookup is case-insensitive and prefix-aware. The
5//! prefix mechanism is what gives the restoration sub-algorithm its
6//! own option scope: looking up `tol` with prefix `"resto."` first
7//! tries `resto.tol`, then falls back to `tol`.
8//!
9//! Internal value representation is always a `String`, exactly as in
10//! upstream — typed accessors parse on each call, matching Ipopt
11//! behavior.
12
13use crate::exception::{ExceptionKind, SolverException};
14use crate::reg_options::{DefaultValue, OptionType, RegisteredOptions};
15use crate::throw;
16use crate::types::{Index, Number};
17use std::collections::BTreeMap;
18use std::io::Read;
19use std::rc::Rc;
20
21#[derive(Debug, Clone)]
22struct OptionValue {
23    value: String,
24    counter: std::cell::Cell<Index>,
25    allow_clobber: bool,
26    dont_print: bool,
27}
28
29impl OptionValue {
30    fn new(value: String, allow_clobber: bool, dont_print: bool) -> Self {
31        Self {
32            value,
33            counter: std::cell::Cell::new(0),
34            allow_clobber,
35            dont_print,
36        }
37    }
38    fn get_value(&self) -> &str {
39        self.counter.set(self.counter.get() + 1);
40        &self.value
41    }
42}
43
44/// Mirrors `Ipopt::OptionsList`.
45#[derive(Debug, Default, Clone)]
46pub struct OptionsList {
47    options: BTreeMap<String, OptionValue>,
48    reg_options: Option<Rc<RegisteredOptions>>,
49}
50
51impl OptionsList {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn with_registered(reg: Rc<RegisteredOptions>) -> Self {
57        Self {
58            options: BTreeMap::new(),
59            reg_options: Some(reg),
60        }
61    }
62
63    pub fn set_registered_options(&mut self, reg: Rc<RegisteredOptions>) {
64        self.reg_options = Some(reg);
65    }
66
67    pub fn registered_options(&self) -> Option<Rc<RegisteredOptions>> {
68        self.reg_options.clone()
69    }
70
71    pub fn clear(&mut self) {
72        self.options.clear();
73    }
74
75    /// Every option name present in the list, lowercased, in sorted
76    /// order.
77    ///
78    /// "Present" means someone called a `set_*_value` for it — an unset
79    /// option is absent here even though it *reads back* as its
80    /// registered default. That distinction is the whole point of the
81    /// accessor: it answers "what did this run actually mention?",
82    /// which is not a question `get_*_value` can answer.
83    pub fn names(&self) -> impl Iterator<Item = &str> {
84        self.options.keys().map(String::as_str)
85    }
86
87    fn key(name: &str) -> String {
88        name.to_ascii_lowercase()
89    }
90
91    /// Mirrors `OptionsList::find_tag` — try `prefix+tag` first, then
92    /// bare `tag`. Returns the stored string and bumps its read counter.
93    fn find_tag(&self, tag: &str, prefix: &str) -> Option<&OptionValue> {
94        if !prefix.is_empty() {
95            let key = Self::key(&format!("{prefix}{tag}"));
96            if let Some(v) = self.options.get(&key) {
97                return Some(v);
98            }
99        }
100        self.options.get(&Self::key(tag))
101    }
102
103    fn will_allow_clobber(&self, tag: &str) -> bool {
104        match self.options.get(&Self::key(tag)) {
105            Some(v) => v.allow_clobber,
106            None => true,
107        }
108    }
109
110    /// Mirrors `SetStringValue`.
111    pub fn set_string_value(
112        &mut self,
113        tag: &str,
114        value: &str,
115        allow_clobber: bool,
116        dont_print: bool,
117    ) -> Result<bool, SolverException> {
118        if let Some(reg) = &self.reg_options {
119            let opt = reg.get_option(tag).ok_or_else(|| {
120                SolverException::new(
121                    ExceptionKind::OPTION_INVALID,
122                    format!("Unknown option \"{tag}\"."),
123                    file!(),
124                    line!() as Index,
125                )
126            })?;
127            if opt.option_type != OptionType::OT_String {
128                throw!(
129                    ExceptionKind::OPTION_INVALID,
130                    format!("Option \"{tag}\" is not a string option.")
131                );
132            }
133            if !opt.is_valid_string(value) {
134                throw!(
135                    ExceptionKind::OPTION_INVALID,
136                    format!("Invalid value \"{value}\" for string option \"{tag}\".")
137                );
138            }
139        }
140        if !self.will_allow_clobber(tag) {
141            return Ok(false);
142        }
143        let stored = value.to_ascii_lowercase();
144        self.options.insert(
145            Self::key(tag),
146            OptionValue::new(stored, allow_clobber, dont_print),
147        );
148        Ok(true)
149    }
150
151    /// Mirrors `SetNumericValue`.
152    pub fn set_numeric_value(
153        &mut self,
154        tag: &str,
155        value: Number,
156        allow_clobber: bool,
157        dont_print: bool,
158    ) -> Result<bool, SolverException> {
159        if let Some(reg) = &self.reg_options {
160            let opt = reg.get_option(tag).ok_or_else(|| {
161                SolverException::new(
162                    ExceptionKind::OPTION_INVALID,
163                    format!("Unknown option \"{tag}\"."),
164                    file!(),
165                    line!() as Index,
166                )
167            })?;
168            if opt.option_type != OptionType::OT_Number {
169                throw!(
170                    ExceptionKind::OPTION_INVALID,
171                    format!("Option \"{tag}\" is not a numeric option.")
172                );
173            }
174            if !opt.is_valid_number(value) {
175                throw!(
176                    ExceptionKind::OPTION_INVALID,
177                    format!("Numeric value {value} for option \"{tag}\" out of range.")
178                );
179            }
180        }
181        if !self.will_allow_clobber(tag) {
182            return Ok(false);
183        }
184        // Print with full precision so round-trip preserves the value.
185        let s = format!("{value:.18e}");
186        self.options.insert(
187            Self::key(tag),
188            OptionValue::new(s, allow_clobber, dont_print),
189        );
190        Ok(true)
191    }
192
193    /// Mirrors `SetIntegerValue`.
194    pub fn set_integer_value(
195        &mut self,
196        tag: &str,
197        value: Index,
198        allow_clobber: bool,
199        dont_print: bool,
200    ) -> Result<bool, SolverException> {
201        if let Some(reg) = &self.reg_options {
202            let opt = reg.get_option(tag).ok_or_else(|| {
203                SolverException::new(
204                    ExceptionKind::OPTION_INVALID,
205                    format!("Unknown option \"{tag}\"."),
206                    file!(),
207                    line!() as Index,
208                )
209            })?;
210            if opt.option_type != OptionType::OT_Integer {
211                throw!(
212                    ExceptionKind::OPTION_INVALID,
213                    format!("Option \"{tag}\" is not an integer option.")
214                );
215            }
216            if !opt.is_valid_integer(value) {
217                throw!(
218                    ExceptionKind::OPTION_INVALID,
219                    format!("Integer value {value} for option \"{tag}\" out of range.")
220                );
221            }
222        }
223        if !self.will_allow_clobber(tag) {
224            return Ok(false);
225        }
226        self.options.insert(
227            Self::key(tag),
228            OptionValue::new(value.to_string(), allow_clobber, dont_print),
229        );
230        Ok(true)
231    }
232
233    /// Mirrors `SetBoolValue`.
234    pub fn set_bool_value(
235        &mut self,
236        tag: &str,
237        value: bool,
238        allow_clobber: bool,
239        dont_print: bool,
240    ) -> Result<bool, SolverException> {
241        self.set_string_value(
242            tag,
243            if value { "yes" } else { "no" },
244            allow_clobber,
245            dont_print,
246        )
247    }
248
249    /// Mirrors `UnsetValue`. Returns true if the value was removed.
250    pub fn unset_value(&mut self, tag: &str) -> bool {
251        let key = Self::key(tag);
252        if let Some(v) = self.options.get(&key) {
253            if !v.allow_clobber {
254                return false;
255            }
256            self.options.remove(&key);
257            true
258        } else {
259            false
260        }
261    }
262
263    /// Mirrors `GetStringValue`. Returns true if found in the list.
264    /// Falls back to the registered default when not found.
265    pub fn get_string_value(
266        &self,
267        tag: &str,
268        prefix: &str,
269    ) -> Result<(String, bool), SolverException> {
270        if let Some(v) = self.find_tag(tag, prefix) {
271            return Ok((v.get_value().to_string(), true));
272        }
273        if let Some(reg) = &self.reg_options {
274            if let Some(opt) = reg.get_option(tag) {
275                if let DefaultValue::String(d) = &opt.default {
276                    return Ok((d.clone(), false));
277                }
278                throw!(
279                    ExceptionKind::OPTION_INVALID,
280                    format!("Option \"{tag}\" is not a string option.")
281                );
282            }
283        }
284        Ok((String::new(), false))
285    }
286
287    /// Mirrors `GetNumericValue`.
288    pub fn get_numeric_value(
289        &self,
290        tag: &str,
291        prefix: &str,
292    ) -> Result<(Number, bool), SolverException> {
293        if let Some(v) = self.find_tag(tag, prefix) {
294            let s = v.get_value().to_string();
295            let parsed = parse_ipopt_number(&s).ok_or_else(|| {
296                SolverException::new(
297                    ExceptionKind::OPTION_INVALID,
298                    format!("Option \"{tag}\": cannot parse value \"{s}\" as Number."),
299                    file!(),
300                    line!() as Index,
301                )
302            })?;
303            return Ok((parsed, true));
304        }
305        if let Some(reg) = &self.reg_options {
306            if let Some(opt) = reg.get_option(tag) {
307                if let DefaultValue::Number(d) = &opt.default {
308                    return Ok((*d, false));
309                }
310                throw!(
311                    ExceptionKind::OPTION_INVALID,
312                    format!("Option \"{tag}\" is not a numeric option.")
313                );
314            }
315        }
316        Ok((0.0, false))
317    }
318
319    /// Mirrors `GetIntegerValue`.
320    pub fn get_integer_value(
321        &self,
322        tag: &str,
323        prefix: &str,
324    ) -> Result<(Index, bool), SolverException> {
325        if let Some(v) = self.find_tag(tag, prefix) {
326            let s = v.get_value().to_string();
327            let parsed: Index = s.trim().parse().map_err(|_| {
328                SolverException::new(
329                    ExceptionKind::OPTION_INVALID,
330                    format!("Option \"{tag}\": cannot parse value \"{s}\" as Integer."),
331                    file!(),
332                    line!() as Index,
333                )
334            })?;
335            return Ok((parsed, true));
336        }
337        if let Some(reg) = &self.reg_options {
338            if let Some(opt) = reg.get_option(tag) {
339                if let DefaultValue::Integer(d) = &opt.default {
340                    return Ok((*d, false));
341                }
342                throw!(
343                    ExceptionKind::OPTION_INVALID,
344                    format!("Option \"{tag}\" is not an integer option.")
345                );
346            }
347        }
348        Ok((0, false))
349    }
350
351    /// Mirrors `GetBoolValue`. Accepts `"yes"`/`"no"`.
352    pub fn get_bool_value(&self, tag: &str, prefix: &str) -> Result<(bool, bool), SolverException> {
353        let (s, found) = self.get_string_value(tag, prefix)?;
354        let v = match s.to_ascii_lowercase().as_str() {
355            "yes" => true,
356            "no" => false,
357            other => throw!(
358                ExceptionKind::OPTION_INVALID,
359                format!("Option \"{tag}\" has non-boolean value \"{other}\".")
360            ),
361        };
362        Ok((v, found))
363    }
364
365    /// Mirrors `GetEnumValue`. Returns the index of the value in the
366    /// registered string list.
367    pub fn get_enum_value(
368        &self,
369        tag: &str,
370        prefix: &str,
371    ) -> Result<(Index, bool), SolverException> {
372        let (s, found) = self.get_string_value(tag, prefix)?;
373        let reg = self.reg_options.as_ref().ok_or_else(|| {
374            SolverException::new(
375                ExceptionKind::OPTION_INVALID,
376                "GetEnumValue requires a RegisteredOptions registry.".to_string(),
377                file!(),
378                line!() as Index,
379            )
380        })?;
381        let opt = reg.get_option(tag).ok_or_else(|| {
382            SolverException::new(
383                ExceptionKind::OPTION_INVALID,
384                format!("Unknown option \"{tag}\"."),
385                file!(),
386                line!() as Index,
387            )
388        })?;
389        let idx = opt.map_string_to_enum(&s).ok_or_else(|| {
390            SolverException::new(
391                ExceptionKind::ERROR_CONVERTING_STRING_TO_ENUM,
392                format!("Cannot map \"{s}\" to enum for option \"{tag}\"."),
393                file!(),
394                line!() as Index,
395            )
396        })?;
397        Ok((idx, found))
398    }
399
400    /// Mirrors `ReadFromStream`. Parses an `ipopt.opt`-style file:
401    /// whitespace-separated `tag value` pairs, `#` line comments,
402    /// double-quoted tokens permitted.
403    pub fn read_from_stream<R: Read>(
404        &mut self,
405        mut r: R,
406        allow_clobber: bool,
407    ) -> Result<(), SolverException> {
408        let mut s = String::new();
409        r.read_to_string(&mut s).map_err(|e| {
410            SolverException::new(
411                ExceptionKind::OPTION_INVALID,
412                format!("I/O error reading options: {e}"),
413                file!(),
414                line!() as Index,
415            )
416        })?;
417        self.read_from_str(&s, allow_clobber)
418    }
419
420    pub fn read_from_str(&mut self, s: &str, allow_clobber: bool) -> Result<(), SolverException> {
421        let mut tokens = Tokenizer::new(s);
422        loop {
423            let Some(tag) = tokens.next_token()? else {
424                return Ok(());
425            };
426            let Some(value) = tokens.next_token()? else {
427                throw!(
428                    ExceptionKind::OPTION_INVALID,
429                    format!("Error reading value for tag {tag} from option file.")
430                );
431            };
432            self.set_from_text(&tag, &value, allow_clobber)?;
433        }
434    }
435
436    fn set_from_text(
437        &mut self,
438        tag: &str,
439        value: &str,
440        allow_clobber: bool,
441    ) -> Result<(), SolverException> {
442        if let Some(reg) = self.reg_options.clone() {
443            let opt = reg.get_option(tag).ok_or_else(|| SolverException::new(
444                ExceptionKind::OPTION_INVALID,
445                format!("Read Option: \"{tag}\". It is not a valid option. Check the list of available options."),
446                file!(), line!() as Index,
447            ))?;
448            match opt.option_type {
449                OptionType::OT_String => {
450                    let ok = self.set_string_value(tag, value, allow_clobber, false)?;
451                    if !ok {
452                        throw!(
453                            ExceptionKind::OPTION_INVALID,
454                            "Error setting string value read from option file.".to_string()
455                        );
456                    }
457                }
458                OptionType::OT_Number => {
459                    let v = parse_ipopt_number(value).ok_or_else(|| SolverException::new(
460                        ExceptionKind::OPTION_INVALID,
461                        format!("Option \"{tag}\": Double value expected, but non-numeric option value \"{value}\" found.\n"),
462                        file!(), line!() as Index,
463                    ))?;
464                    let ok = self.set_numeric_value(tag, v, allow_clobber, false)?;
465                    if !ok {
466                        throw!(
467                            ExceptionKind::OPTION_INVALID,
468                            "Error setting numeric value read from file.".to_string()
469                        );
470                    }
471                }
472                OptionType::OT_Integer => {
473                    let v: Index = value.parse().map_err(|_| SolverException::new(
474                        ExceptionKind::OPTION_INVALID,
475                        format!("Option \"{tag}\": Integer value expected, but non-integer option value \"{value}\" found.\n"),
476                        file!(), line!() as Index,
477                    ))?;
478                    let ok = self.set_integer_value(tag, v, allow_clobber, false)?;
479                    if !ok {
480                        throw!(
481                            ExceptionKind::OPTION_INVALID,
482                            "Error setting integer value read from option file.".to_string()
483                        );
484                    }
485                }
486                OptionType::OT_Unknown => {
487                    throw!(
488                        ExceptionKind::OPTION_INVALID,
489                        format!("Option \"{tag}\" has unknown type.")
490                    );
491                }
492            }
493        } else {
494            self.set_string_value(tag, value, allow_clobber, false)?;
495        }
496        Ok(())
497    }
498
499    /// Mirrors `PrintList`. One option per line: `name value # used N times`.
500    pub fn print_list(&self) -> String {
501        let mut out = String::new();
502        out.push_str("                                    Name   Value           # times used\n");
503        for (k, v) in &self.options {
504            out.push_str(&format!(
505                "{:>40} = {:<30} # {}\n",
506                k,
507                v.value,
508                v.counter.get()
509            ));
510        }
511        out
512    }
513
514    /// Mirrors `PrintUserOptions`.
515    pub fn print_user_options(&self) -> String {
516        let mut out = String::new();
517        for (k, v) in &self.options {
518            if v.dont_print {
519                continue;
520            }
521            let used = if v.counter.get() > 0 {
522                "used"
523            } else {
524                "notused"
525            };
526            out.push_str(&format!("{} {} ({})\n", k, v.value, used));
527        }
528        out
529    }
530}
531
532/// Parse a number, allowing Fortran-style `d`/`D` exponents (matching
533/// `IpOptionsList::ReadFromStream`).
534fn parse_ipopt_number(s: &str) -> Option<Number> {
535    let mut buf = String::with_capacity(s.len());
536    for c in s.chars() {
537        if c == 'd' || c == 'D' {
538            buf.push('e');
539        } else {
540            buf.push(c);
541        }
542    }
543    buf.trim().parse().ok()
544}
545
546/// Tokeniser matching `OptionsList::readnexttoken` semantics:
547/// whitespace splits tokens; `#` introduces a line comment; double
548/// quotes group whitespace into a single token.
549struct Tokenizer<'a> {
550    chars: std::str::Chars<'a>,
551    peeked: Option<char>,
552}
553
554impl<'a> Tokenizer<'a> {
555    fn new(s: &'a str) -> Self {
556        Self {
557            chars: s.chars(),
558            peeked: None,
559        }
560    }
561
562    fn next_char(&mut self) -> Option<char> {
563        self.peeked.take().or_else(|| self.chars.next())
564    }
565
566    fn next_token(&mut self) -> Result<Option<String>, SolverException> {
567        let mut c = match self.next_char() {
568            Some(c) => c,
569            None => return Ok(None),
570        };
571        loop {
572            if c.is_whitespace() { /* skip */
573            } else if c == '#' {
574                // skip until newline
575                loop {
576                    match self.next_char() {
577                        Some('\n') | None => break,
578                        _ => {}
579                    }
580                }
581            } else {
582                break;
583            }
584            c = match self.next_char() {
585                Some(c) => c,
586                None => return Ok(None),
587            };
588        }
589        let inside_quotes = c == '"';
590        let mut tok = String::new();
591        if inside_quotes {
592            c = match self.next_char() {
593                Some(c) => c,
594                None => throw!(
595                    ExceptionKind::OPTION_INVALID,
596                    "Unterminated quoted string in option file.".to_string()
597                ),
598            };
599        }
600        loop {
601            if !inside_quotes && c.is_whitespace() {
602                return Ok(Some(tok));
603            }
604            if inside_quotes && c == '"' {
605                return Ok(Some(tok));
606            }
607            tok.push(c);
608            c = match self.next_char() {
609                Some(c) => c,
610                None => {
611                    if inside_quotes {
612                        throw!(
613                            ExceptionKind::OPTION_INVALID,
614                            "Unterminated quoted string in option file.".to_string()
615                        );
616                    }
617                    return Ok(Some(tok));
618                }
619            };
620        }
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    fn registry_with_basic() -> Rc<RegisteredOptions> {
629        let r = RegisteredOptions::new();
630        r.set_registering_category("Test");
631        r.add_lower_bounded_number_option("tol", "Convergence tolerance", 0.0, true, 1e-8, "")
632            .unwrap();
633        r.add_string_option(
634            "linear_solver",
635            "Linear solver",
636            "mumps",
637            &[("mumps", ""), ("feral", "")],
638            "",
639        )
640        .unwrap();
641        r.add_lower_bounded_integer_option("max_iter", "Maximum iterations", 0, 3000, "")
642            .unwrap();
643        r.add_bool_option("print_user_options", "", false, "")
644            .unwrap();
645        r
646    }
647
648    #[test]
649    fn prefix_lookup_overrides() {
650        let reg = registry_with_basic();
651        let mut o = OptionsList::with_registered(reg);
652        o.set_numeric_value("tol", 1e-6, true, false).unwrap();
653        o.set_numeric_value("resto.tol", 1e-3, true, false).unwrap();
654        let (v_main, _) = o.get_numeric_value("tol", "").unwrap();
655        let (v_resto, _) = o.get_numeric_value("tol", "resto.").unwrap();
656        let (v_other, _) = o.get_numeric_value("tol", "noprefix.").unwrap();
657        assert!((v_main - 1e-6).abs() < 1e-20);
658        assert!((v_resto - 1e-3).abs() < 1e-20);
659        assert!((v_other - 1e-6).abs() < 1e-20);
660    }
661
662    #[test]
663    fn defaults_returned_when_unset() {
664        let reg = registry_with_basic();
665        let o = OptionsList::with_registered(reg);
666        let (v, found) = o.get_numeric_value("tol", "").unwrap();
667        assert!((v - 1e-8).abs() < 1e-20);
668        assert!(!found);
669    }
670
671    #[test]
672    fn read_options_file_text() {
673        let reg = registry_with_basic();
674        let mut o = OptionsList::with_registered(reg);
675        let opt_file = "
676# A comment line
677tol  1.0e-7
678max_iter 500
679linear_solver mumps
680print_user_options yes
681";
682        o.read_from_str(opt_file, false).unwrap();
683        assert_eq!(o.get_numeric_value("tol", "").unwrap().0, 1e-7);
684        assert_eq!(o.get_integer_value("max_iter", "").unwrap().0, 500);
685        assert_eq!(o.get_string_value("linear_solver", "").unwrap().0, "mumps");
686        assert!(o.get_bool_value("print_user_options", "").unwrap().0);
687    }
688
689    #[test]
690    fn fortran_d_exponent_accepted() {
691        let reg = registry_with_basic();
692        let mut o = OptionsList::with_registered(reg);
693        o.read_from_str("tol 1.0d-9\n", false).unwrap();
694        assert!((o.get_numeric_value("tol", "").unwrap().0 - 1e-9).abs() < 1e-30);
695    }
696
697    #[test]
698    fn unknown_option_in_file_is_error() {
699        let reg = registry_with_basic();
700        let mut o = OptionsList::with_registered(reg);
701        let err = o.read_from_str("nonsense_option 1.0\n", false).unwrap_err();
702        assert_eq!(err.kind, ExceptionKind::OPTION_INVALID);
703    }
704
705    #[test]
706    fn invalid_string_value_rejected() {
707        let reg = registry_with_basic();
708        let mut o = OptionsList::with_registered(reg);
709        let err = o
710            .set_string_value("linear_solver", "ma27", true, false)
711            .unwrap_err();
712        assert_eq!(err.kind, ExceptionKind::OPTION_INVALID);
713    }
714
715    #[test]
716    fn out_of_range_number_rejected() {
717        let reg = registry_with_basic();
718        let mut o = OptionsList::with_registered(reg);
719        let err = o.set_numeric_value("tol", 0.0, true, false).unwrap_err();
720        assert_eq!(err.kind, ExceptionKind::OPTION_INVALID);
721    }
722
723    #[test]
724    fn enum_value_index() {
725        let reg = registry_with_basic();
726        let mut o = OptionsList::with_registered(reg);
727        o.set_string_value("linear_solver", "feral", true, false)
728            .unwrap();
729        assert_eq!(o.get_enum_value("linear_solver", "").unwrap().0, 1);
730    }
731
732    #[test]
733    fn get_value_increments_use_counter() {
734        let reg = registry_with_basic();
735        let mut o = OptionsList::with_registered(reg);
736        o.set_numeric_value("tol", 1e-6, true, false).unwrap();
737        let _ = o.get_numeric_value("tol", "").unwrap();
738        let _ = o.get_numeric_value("tol", "").unwrap();
739        let listing = o.print_list();
740        assert!(listing.contains("# 2"));
741    }
742}