Skip to main content

gam_terms/inference/
formula_dsl.rs

1use std::collections::BTreeMap;
2
3use pest::Parser;
4use pest::iterators::Pair;
5use pest_derive::Parser;
6
7use crate::smooth::BoundedCoefficientPriorSpec;
8use gam_problem::types::{
9    InverseLink, LikelihoodSpec, LinkComponent, LinkFunction, StandardLink, WigglePenaltyConfig,
10};
11
12#[derive(Parser)]
13#[grammar_inline = r#"
14WHITESPACE = _{ " " | "\t" | NEWLINE }
15
16top_function_call = { SOI ~ function_call ~ EOI }
17top_expr = { SOI ~ expr ~ EOI }
18formula = { SOI ~ expr ~ "~" ~ rhs ~ EOI }
19rhs = { term ~ ("+" ~ term)* }
20term = { expr }
21
22expr = { sum }
23sum = { product ~ (add_op ~ product)* }
24add_op = { "+" | "-" }
25product = { interact ~ (mul_op ~ interact)* }
26mul_op = { "*" | "/" }
27interact = { power ~ (interact_op ~ power)* }
28interact_op = { ":" }
29power = { unary ~ (pow_op ~ unary)* }
30pow_op = { "^" }
31unary = { unary_op* ~ primary }
32unary_op = _{ "+" | "-" }
33
34primary = { function_call | list_lit | tuple_lit | ident | number | string_lit | "(" ~ expr ~ ")" }
35list_lit = @{ "[" ~ (!"]" ~ ANY)* ~ "]" }
36tuple_lit = @{ "(" ~ (!("," | ")") ~ ANY)+ ~ "," ~ (!")" ~ ANY)* ~ ")" }
37function_call = { ident ~ "(" ~ arg_list? ~ ")" }
38arg_list = { arg ~ ("," ~ arg)* }
39arg = { named_arg | expr }
40named_arg = { ident ~ "=" ~ expr }
41
42ident = @{ ident_start ~ ident_continue* }
43ident_start = _{ ASCII_ALPHA | "_" }
44ident_continue = _{ ASCII_ALPHANUMERIC | "_" | "." }
45
46number = @{
47    "-"?
48    ~ (ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT*)? | "." ~ ASCII_DIGIT+)
49    ~ (("e" | "E") ~ ("+" | "-")? ~ ASCII_DIGIT+)?
50}
51
52string_lit = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" | "'" ~ (!"'" ~ ANY)* ~ "'" }
53"#]
54struct FormulaParser;
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct FormulaDslParse {
58    pub response_expr: String,
59    pub rhs_terms: Vec<String>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum CallArgSpec {
64    Positional(String),
65    Named { key: String, value: String },
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct FunctionCallSpec {
70    pub name: String,
71    pub args: Vec<CallArgSpec>,
72}
73
74/// Typed error surface for the formula DSL parser.
75///
76/// Every variant carries a free-form `reason: String` payload; `Display`
77/// emits exactly that payload, so converting a `FormulaDslError` into
78/// `String` (via the `From` impl below) is byte-equivalent to the pre-
79/// refactor `Err(format!(...))` / `Err("...".to_string())` strings that
80/// the same call sites produced. Public entry points keep their existing
81/// `Result<_, String>` signatures — CLI input handling stays unchanged —
82/// and typed errors flow across the boundary via `From<FormulaDslError>
83/// for String`.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum FormulaDslError {
86    /// Pest grammar failure, unbalanced delimiters, empty terms, or
87    /// missing required parse fragments — i.e. the formula text is
88    /// not a well-formed DSL string.
89    ParseError { reason: String },
90    /// A referenced symbol (link name, blended-link component, term
91    /// function name, top-level RHS identifier) is not part of the
92    /// supported vocabulary.
93    UnknownIdentifier { reason: String },
94    /// A named option's value is unparseable, out of range, or not a
95    /// finite number / valid integer.
96    InvalidArgument { reason: String },
97    /// A combination of terms or options is disallowed (duplicate
98    /// terms, multiple linkwiggle/link/survmodel, mutually exclusive
99    /// option groups in bounded(), wiggle-incompatible links, etc.).
100    IncompatibleTerm { reason: String },
101    /// A required configuration option is missing or empty (e.g.
102    /// `link()` without `type=`, `survmodel()` with no options,
103    /// `bounded()` without a required argument).
104    MalformedConfig { reason: String },
105}
106
107gam_linalg::impl_reason_error_boilerplate! {
108    FormulaDslError {
109        ParseError,
110        UnknownIdentifier,
111        InvalidArgument,
112        IncompatibleTerm,
113        MalformedConfig,
114    }
115}
116
117/// Inbound conversion from `String` is used by `?` cascades inside `parse_formula`
118/// and friends so that internal parser helpers still returning `Result<_, String>`
119/// can flow through without each call site needing an explicit `.map_err(...)`.
120/// We route into `ParseError` because by construction every internal helper that
121/// still produces a raw `String` is itself a parse/term-resolution stage.
122impl From<String> for FormulaDslError {
123    fn from(reason: String) -> Self {
124        FormulaDslError::ParseError { reason }
125    }
126}
127
128pub fn parse_formula_dsl(formula: &str) -> Result<FormulaDslParse, String> {
129    validate_balanced_delimiters(formula, "invalid formula syntax")?;
130    let mut parsed =
131        FormulaParser::parse(Rule::formula, formula).map_err(|e| FormulaDslError::ParseError {
132            reason: format!("invalid formula syntax: {e}"),
133        })?;
134    let formula_pair = parsed.next().ok_or_else(|| FormulaDslError::ParseError {
135        reason: "invalid formula syntax: empty parse".to_string(),
136    })?;
137
138    let mut response_expr: Option<String> = None;
139    let mut rhs_terms: Option<Vec<String>> = None;
140
141    for part in formula_pair.into_inner() {
142        match part.as_rule() {
143            Rule::expr if response_expr.is_none() => {
144                response_expr = Some(part.as_str().trim().to_string());
145            }
146            Rule::rhs => {
147                rhs_terms = Some(extract_rhs_terms(part)?);
148            }
149            _ => {}
150        }
151    }
152
153    let response_expr = response_expr.ok_or_else(|| FormulaDslError::ParseError {
154        reason: "invalid formula: missing response expression".to_string(),
155    })?;
156    let rhs_terms = rhs_terms.ok_or_else(|| FormulaDslError::ParseError {
157        reason: "invalid formula: missing RHS terms".to_string(),
158    })?;
159    if rhs_terms.is_empty() {
160        return Err(FormulaDslError::ParseError {
161            reason: "formula has no usable terms".to_string(),
162        }
163        .into());
164    }
165
166    Ok(FormulaDslParse {
167        response_expr,
168        rhs_terms,
169    })
170}
171
172fn delimiter_balance_error(prefix: &str) -> String {
173    format!("{prefix}: unbalanced parentheses or quotes")
174}
175
176// Pest reports malformed delimiters as a generic parse failure. We validate the
177// raw text first so callers get a stable, specific error class for unmatched
178// parentheses/quotes instead of whichever grammar branch happened to fail last.
179fn validate_balanced_delimiters(input: &str, prefix: &str) -> Result<(), String> {
180    let mut stack = Vec::<char>::new();
181    let mut in_single = false;
182    let mut in_double = false;
183
184    for ch in input.chars() {
185        match ch {
186            '\'' if !in_double => in_single = !in_single,
187            '"' if !in_single => in_double = !in_double,
188            '(' | '[' | '{' if !in_single && !in_double => stack.push(ch),
189            ')' | ']' | '}' if !in_single && !in_double => {
190                let expected = match ch {
191                    ')' => '(',
192                    ']' => '[',
193                    // The outer match arm guarantees ch is one of ')', ']', '}'.
194                    _ => '{',
195                };
196                if stack.pop() != Some(expected) {
197                    return Err(FormulaDslError::ParseError {
198                        reason: delimiter_balance_error(prefix),
199                    }
200                    .into());
201                }
202            }
203            _ => {}
204        }
205    }
206
207    if in_single || in_double || !stack.is_empty() {
208        return Err(FormulaDslError::ParseError {
209            reason: delimiter_balance_error(prefix),
210        }
211        .into());
212    }
213    Ok(())
214}
215
216fn extract_rhs_terms(rhs: Pair<'_, Rule>) -> Result<Vec<String>, String> {
217    let mut out = Vec::new();
218    let mut depth = 0_i32;
219    let mut in_single = false;
220    let mut in_double = false;
221    let mut start = 0_usize;
222    // Last non-whitespace character seen at depth 0 outside quotes. A top-level
223    // `+` only separates terms when it follows a completed operand; when it
224    // follows a binary operator (`:`, `*`, `/`, `^`) or another sign — or opens
225    // the RHS — it is a UNARY sign, not a separator. Splitting on it there would
226    // hand the grammar a truncated fragment like `x:` (from `x:+z`) and surface
227    // a confusing "invalid term syntax in `x:`" instead of the dedicated unary
228    // diagnostic the grammar raises when the whole `x:+z` term reaches it. This
229    // never suppresses a split in a valid formula: no valid Wilkinson-Rogers RHS
230    // places `+` immediately after an operator. `-` is already never treated as
231    // a separator here (it is rejected downstream as a binary term operator), so
232    // the unary `-` path already flows through intact — this restores the same
233    // intact flow for unary `+`.
234    let mut last_significant: Option<char> = None;
235    let text = rhs.as_str();
236    let bytes = text.as_bytes();
237    for (idx, &b) in bytes.iter().enumerate() {
238        let ch = b as char;
239        match ch {
240            '\'' if !in_double => in_single = !in_single,
241            '"' if !in_single => in_double = !in_double,
242            '(' | '[' | '{' if !in_single && !in_double => depth += 1,
243            ')' | ']' | '}' if !in_single && !in_double && depth > 0 => depth -= 1,
244            '+' if !in_single
245                && !in_double
246                && depth == 0
247                && !matches!(
248                    last_significant,
249                    None | Some(':' | '*' | '/' | '^' | '+' | '-')
250                ) =>
251            {
252                let term = text[start..idx].trim();
253                if term.is_empty() {
254                    return Err(FormulaDslError::ParseError {
255                        reason: "formula RHS contains an empty term".to_string(),
256                    }
257                    .into());
258                }
259                out.push(term.to_string());
260                start = idx + 1;
261            }
262            _ => {}
263        }
264        if !ch.is_ascii_whitespace() {
265            last_significant = Some(ch);
266        }
267    }
268    if in_single || in_double || depth != 0 {
269        return Err(FormulaDslError::ParseError {
270            reason: "formula RHS has unbalanced quotes or parentheses".to_string(),
271        }
272        .into());
273    }
274    let tail = text[start..].trim();
275    if tail.is_empty() {
276        return Err(FormulaDslError::ParseError {
277            reason: "formula RHS contains an empty term".to_string(),
278        }
279        .into());
280    }
281    out.push(tail.to_string());
282    Ok(out)
283}
284
285/// Wilkinson-Rogers operator-family expansion.
286///
287/// A raw RHS term (already split on top-level `+`) may use the documented
288/// formula operators `:`, `*`, `/`, `^`, and parenthesization. This function
289/// expands the term into a normalized list of `WrAtomList`s, where each
290/// `WrAtomList` is one resulting model term — either a single atom (linear
291/// main effect / function-call term) or multiple atoms (interaction).
292///
293/// Wilkinson-Rogers semantics implemented here:
294/// * `a` produces `{a}` — one main effect.
295/// * `a:b` produces `{a:b}` — one interaction.
296/// * `a*b` produces `{a, b, a:b}` — crossing (expanded).
297/// * `a/b` produces `{a, a:b}` — nesting.
298/// * `(a + b + ... )^n` produces every non-empty subset of `{a, b, ...}` of
299///   size at most `n`, each subset being one interaction.
300/// * `+` unions two term sets; `-` is rejected.
301///
302/// Atoms inside the AST may be bare identifiers or function calls. Function
303/// calls are opaque — they pass through as-is and cannot participate in an
304/// `:` interaction with other atoms (smooths/factors require dedicated
305/// constructors like `te()`).
306type WrAtomList = Vec<String>;
307
308fn expand_wr_term(raw: &str) -> Result<Vec<WrAtomList>, String> {
309    let mut parsed = FormulaParser::parse(Rule::top_expr, raw).map_err(|e| {
310        FormulaDslError::ParseError {
311            reason: format!("invalid term syntax in `{raw}`: {e}"),
312        }
313        .to_string()
314    })?;
315    let top = parsed.next().ok_or_else(|| {
316        FormulaDslError::ParseError {
317            reason: format!("invalid term syntax in `{raw}`: empty parse"),
318        }
319        .to_string()
320    })?;
321    let expr = top
322        .into_inner()
323        .find(|p| p.as_rule() == Rule::expr)
324        .ok_or_else(|| {
325            FormulaDslError::ParseError {
326                reason: format!("invalid term syntax in `{raw}`: missing expr"),
327            }
328            .to_string()
329        })?;
330    let interactions = expand_expr(expr, raw)?;
331    let normalized: Vec<WrAtomList> = interactions
332        .into_iter()
333        .map(normalize_interaction)
334        .collect();
335    // Deduplicate while preserving order: a*b yields {a, b, a:b}; a*b + a
336    // would otherwise list `a` twice.
337    let mut seen = std::collections::BTreeSet::<Vec<String>>::new();
338    let mut out = Vec::<WrAtomList>::new();
339    for term in normalized {
340        let key = term.clone();
341        if seen.insert(key) {
342            out.push(term);
343        }
344    }
345    Ok(out)
346}
347
348fn normalize_interaction(mut atoms: WrAtomList) -> WrAtomList {
349    atoms.sort();
350    atoms.dedup();
351    atoms
352}
353
354fn expand_expr(pair: Pair<'_, Rule>, raw: &str) -> Result<Vec<WrAtomList>, String> {
355    match pair.as_rule() {
356        Rule::expr => {
357            let inner = pair.into_inner().next().ok_or_else(|| {
358                FormulaDslError::ParseError {
359                    reason: format!("invalid term syntax in `{raw}`: empty expr"),
360                }
361                .to_string()
362            })?;
363            expand_expr(inner, raw)
364        }
365        Rule::sum => {
366            let mut iter = pair.into_inner();
367            let first = iter.next().ok_or_else(|| {
368                FormulaDslError::ParseError {
369                    reason: format!("invalid term syntax in `{raw}`: empty sum"),
370                }
371                .to_string()
372            })?;
373            let mut acc = expand_expr(first, raw)?;
374            while let Some(op) = iter.next() {
375                if op.as_rule() != Rule::add_op {
376                    return Err(FormulaDslError::ParseError {
377                        reason: format!(
378                            "invalid term syntax in `{raw}`: expected add operator, got `{:?}`",
379                            op.as_rule()
380                        ),
381                    }
382                    .into());
383                }
384                let op_str = op.as_str().trim();
385                let operand = iter.next().ok_or_else(|| {
386                    FormulaDslError::ParseError {
387                        reason: format!("invalid term syntax in `{raw}`: dangling `{op_str}`"),
388                    }
389                    .to_string()
390                })?;
391                if op_str == "-" {
392                    return Err(FormulaDslError::IncompatibleTerm {
393                        reason: format!(
394                            "binary `-` is not supported inside a formula term in `{raw}` \
395                             (use multiple `+` terms or drop the unwanted predictor explicitly)"
396                        ),
397                    }
398                    .into());
399                }
400                let mut rhs = expand_expr(operand, raw)?;
401                acc.append(&mut rhs);
402            }
403            Ok(acc)
404        }
405        Rule::product => {
406            let mut iter = pair.into_inner();
407            let first = iter.next().ok_or_else(|| {
408                FormulaDslError::ParseError {
409                    reason: format!("invalid term syntax in `{raw}`: empty product"),
410                }
411                .to_string()
412            })?;
413            let mut acc = expand_expr(first, raw)?;
414            while let Some(op) = iter.next() {
415                if op.as_rule() != Rule::mul_op {
416                    return Err(FormulaDslError::ParseError {
417                        reason: format!(
418                            "invalid term syntax in `{raw}`: expected `*` or `/`, got `{:?}`",
419                            op.as_rule()
420                        ),
421                    }
422                    .into());
423                }
424                let op_str = op.as_str().trim();
425                let operand = iter.next().ok_or_else(|| {
426                    FormulaDslError::ParseError {
427                        reason: format!("invalid term syntax in `{raw}`: dangling `{op_str}`"),
428                    }
429                    .to_string()
430                })?;
431                let rhs = expand_expr(operand, raw)?;
432                acc = match op_str {
433                    "*" => wr_cross(acc, rhs),
434                    "/" => wr_nest(acc, rhs),
435                    other => {
436                        return Err(FormulaDslError::ParseError {
437                            reason: format!(
438                                "invalid term syntax in `{raw}`: unrecognized mul operator `{other}`"
439                            ),
440                        }
441                        .into());
442                    }
443                };
444            }
445            Ok(acc)
446        }
447        Rule::interact => {
448            let mut iter = pair.into_inner();
449            let first = iter.next().ok_or_else(|| {
450                FormulaDslError::ParseError {
451                    reason: format!("invalid term syntax in `{raw}`: empty interact"),
452                }
453                .to_string()
454            })?;
455            let mut acc = expand_expr(first, raw)?;
456            while let Some(op) = iter.next() {
457                if op.as_rule() != Rule::interact_op {
458                    return Err(FormulaDslError::ParseError {
459                        reason: format!(
460                            "invalid term syntax in `{raw}`: expected `:`, got `{:?}`",
461                            op.as_rule()
462                        ),
463                    }
464                    .into());
465                }
466                let operand = iter.next().ok_or_else(|| {
467                    FormulaDslError::ParseError {
468                        reason: format!("invalid term syntax in `{raw}`: dangling `:`"),
469                    }
470                    .to_string()
471                })?;
472                let rhs = expand_expr(operand, raw)?;
473                acc = wr_interact(acc, rhs, raw)?;
474            }
475            Ok(acc)
476        }
477        Rule::power => {
478            let mut iter = pair.into_inner();
479            let first = iter.next().ok_or_else(|| {
480                FormulaDslError::ParseError {
481                    reason: format!("invalid term syntax in `{raw}`: empty power"),
482                }
483                .to_string()
484            })?;
485            let base = expand_expr(first, raw)?;
486            let Some(op) = iter.next() else {
487                return Ok(base);
488            };
489            if op.as_rule() != Rule::pow_op {
490                return Err(FormulaDslError::ParseError {
491                    reason: format!(
492                        "invalid term syntax in `{raw}`: expected `^`, got `{:?}`",
493                        op.as_rule()
494                    ),
495                }
496                .into());
497            }
498            let exponent_pair = iter.next().ok_or_else(|| {
499                FormulaDslError::ParseError {
500                    reason: format!("invalid term syntax in `{raw}`: dangling `^`"),
501                }
502                .to_string()
503            })?;
504            let exp_text = exponent_pair.as_str().trim();
505            let n: usize = exp_text.parse().map_err(|_| {
506                FormulaDslError::ParseError {
507                    reason: format!(
508                        "invalid term syntax in `{raw}`: `^` exponent must be a positive integer, got `{exp_text}`"
509                    ),
510                }
511                .to_string()
512            })?;
513            if n == 0 {
514                return Err(FormulaDslError::ParseError {
515                    reason: format!(
516                        "invalid term syntax in `{raw}`: `^0` is not a meaningful formula expansion"
517                    ),
518                }
519                .into());
520            }
521            if let Some(extra_op) = iter.next() {
522                let extra = extra_op.as_str().trim();
523                return Err(FormulaDslError::ParseError {
524                    reason: format!(
525                        "invalid term syntax in `{raw}`: chained `^` operators are not supported; \
526                         use one positive integer exponent, got another `{extra}`"
527                    ),
528                }
529                .into());
530            }
531            Ok(wr_power(base, n))
532        }
533        Rule::unary => {
534            let unary_start = pair.as_span().start();
535            for inner in pair.into_inner() {
536                if inner.as_rule() == Rule::primary {
537                    let prefix_len = inner.as_span().start().saturating_sub(unary_start);
538                    if prefix_len > 0 {
539                        let prefix = &raw[unary_start..inner.as_span().start()];
540                        if prefix.chars().any(|ch| matches!(ch, '+' | '-')) {
541                            return Err(FormulaDslError::IncompatibleTerm {
542                                reason: format!(
543                                    "unary `+`/`-` is not supported inside a formula term in `{raw}`"
544                                ),
545                            }
546                            .into());
547                        }
548                    }
549                    return expand_expr(inner, raw);
550                }
551            }
552            Err(FormulaDslError::ParseError {
553                reason: format!("invalid term syntax in `{raw}`: empty unary"),
554            }
555            .into())
556        }
557        Rule::primary => {
558            let span = pair.as_str().trim().to_string();
559            let inner = pair.into_inner().next();
560            match inner {
561                Some(child) if child.as_rule() == Rule::expr => expand_expr(child, raw),
562                Some(child) => Ok(vec![vec![child.as_str().trim().to_string()]]),
563                None => Ok(vec![vec![span]]),
564            }
565        }
566        _ => Err(FormulaDslError::ParseError {
567            reason: format!(
568                "invalid term syntax in `{raw}`: unexpected node `{:?}`",
569                pair.as_rule()
570            ),
571        }
572        .into()),
573    }
574}
575
576fn wr_cross(left: Vec<WrAtomList>, right: Vec<WrAtomList>) -> Vec<WrAtomList> {
577    // a*b = a + b + a:b
578    let mut out = Vec::with_capacity(left.len() + right.len() + left.len() * right.len());
579    out.extend(left.iter().cloned());
580    out.extend(right.iter().cloned());
581    for l in &left {
582        for r in &right {
583            let mut merged: WrAtomList = l.iter().cloned().chain(r.iter().cloned()).collect();
584            merged.sort();
585            merged.dedup();
586            out.push(merged);
587        }
588    }
589    out
590}
591
592fn wr_nest(left: Vec<WrAtomList>, right: Vec<WrAtomList>) -> Vec<WrAtomList> {
593    // a/b = a + a:b
594    let mut out = Vec::with_capacity(left.len() + left.len() * right.len());
595    out.extend(left.iter().cloned());
596    for l in &left {
597        for r in &right {
598            let mut merged: WrAtomList = l.iter().cloned().chain(r.iter().cloned()).collect();
599            merged.sort();
600            merged.dedup();
601            out.push(merged);
602        }
603    }
604    out
605}
606
607fn wr_interact(
608    left: Vec<WrAtomList>,
609    right: Vec<WrAtomList>,
610    raw: &str,
611) -> Result<Vec<WrAtomList>, String> {
612    // a:b — Cartesian merge of every (l, r) pair.
613    let mut out = Vec::with_capacity(left.len() * right.len());
614    for l in &left {
615        for r in &right {
616            let combined: WrAtomList = l.iter().cloned().chain(r.iter().cloned()).collect();
617            // Reject interactions whose atoms contain function-call syntax.
618            // These would build design columns that are not simple products
619            // (factors, smooths) and need a dedicated constructor.
620            for atom in &combined {
621                if atom.contains('(') {
622                    return Err(FormulaDslError::IncompatibleTerm {
623                        reason: format!(
624                            "interaction operator `:` with function-call atom is not supported in `{raw}`. \
625                             Use te(...) for smooth interactions or group()/factor() with a separate \
626                             interaction strategy for categorical effects."
627                        ),
628                    }
629                    .into());
630                }
631            }
632            let mut merged = combined;
633            merged.sort();
634            merged.dedup();
635            out.push(merged);
636        }
637    }
638    Ok(out)
639}
640
641fn wr_power(base: Vec<WrAtomList>, n: usize) -> Vec<WrAtomList> {
642    // (e)^n produces every non-empty subset of `base` of size ≤ n,
643    // each subset being one interaction. The standard WR semantics
644    // treat the base as a *set* of atoms — interactions inside `base`
645    // are kept as-is and not re-crossed.
646    if base.is_empty() {
647        return Vec::new();
648    }
649    let m = base.len();
650    let mut out = Vec::<WrAtomList>::new();
651    let max_size = n.min(m);
652    for size in 1..=max_size {
653        // Enumerate combinations of `size` elements out of m.
654        let mut indices: Vec<usize> = (0..size).collect();
655        loop {
656            let mut merged = WrAtomList::new();
657            for &i in &indices {
658                merged.extend(base[i].iter().cloned());
659            }
660            merged.sort();
661            merged.dedup();
662            out.push(merged);
663            // Next combination
664            let mut k = size;
665            while k > 0 {
666                k -= 1;
667                if indices[k] != k + m - size {
668                    indices[k] += 1;
669                    for j in (k + 1)..size {
670                        indices[j] = indices[j - 1] + 1;
671                    }
672                    break;
673                }
674                if k == 0 {
675                    k = usize::MAX;
676                    break;
677                }
678            }
679            if k == usize::MAX {
680                break;
681            }
682        }
683    }
684    out
685}
686
687fn is_exact_ident(raw: &str) -> bool {
688    let mut chars = raw.chars();
689    let Some(first) = chars.next() else {
690        return false;
691    };
692    if !first.is_ascii_alphabetic() && first != '_' {
693        return false;
694    }
695    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.')
696}
697
698pub fn parse_function_call(input: &str) -> Result<FunctionCallSpec, String> {
699    validate_balanced_delimiters(input, "invalid function call syntax")?;
700    let mut parsed = FormulaParser::parse(Rule::top_function_call, input).map_err(|e| {
701        FormulaDslError::ParseError {
702            reason: format!("invalid function call syntax: {e}"),
703        }
704    })?;
705    let top = parsed.next().ok_or_else(|| FormulaDslError::ParseError {
706        reason: "invalid function call syntax: empty parse".to_string(),
707    })?;
708    let call = top
709        .into_inner()
710        .find(|p| p.as_rule() == Rule::function_call)
711        .ok_or_else(|| FormulaDslError::ParseError {
712            reason: "invalid function call syntax: missing call".to_string(),
713        })?;
714    parse_call_pair(call)
715}
716
717fn parse_call_pair(call: Pair<'_, Rule>) -> Result<FunctionCallSpec, String> {
718    let mut name: Option<String> = None;
719    let mut args = Vec::<CallArgSpec>::new();
720    for part in call.into_inner() {
721        match part.as_rule() {
722            Rule::ident => {
723                if name.is_none() {
724                    name = Some(part.as_str().trim().to_string());
725                }
726            }
727            Rule::arg_list => {
728                for a in part.into_inner() {
729                    if a.as_rule() != Rule::arg {
730                        continue;
731                    }
732                    let mut a_inner = a.into_inner();
733                    let Some(first) = a_inner.next() else {
734                        continue;
735                    };
736                    match first.as_rule() {
737                        Rule::named_arg => {
738                            let mut ni = first.into_inner();
739                            let key = ni
740                                .next()
741                                .ok_or_else(|| FormulaDslError::ParseError {
742                                    reason: "invalid named argument key".to_string(),
743                                })?
744                                .as_str()
745                                .trim()
746                                .to_ascii_lowercase();
747                            let value = ni
748                                .next()
749                                .ok_or_else(|| FormulaDslError::ParseError {
750                                    reason: "invalid named argument value".to_string(),
751                                })?
752                                .as_str()
753                                .trim()
754                                .to_string();
755                            args.push(CallArgSpec::Named { key, value });
756                        }
757                        Rule::expr => {
758                            args.push(CallArgSpec::Positional(first.as_str().trim().to_string()));
759                        }
760                        _ => {}
761                    }
762                }
763            }
764            _ => {}
765        }
766    }
767    let name = name.ok_or_else(|| FormulaDslError::ParseError {
768        reason: "invalid function call: missing name".to_string(),
769    })?;
770    Ok(FunctionCallSpec { name, args })
771}
772
773#[cfg(test)]
774mod tests {
775    use super::{
776        CallArgSpec, ParsedTerm, parse_formula, parse_formula_dsl, parse_function_call,
777        parse_linkwiggle_formulaspec, parsed_term_column_names, parsed_terms_reference_column,
778        validate_marginal_slope_z_column_exclusion,
779    };
780    use std::collections::{BTreeMap, BTreeSet};
781
782    #[test]
783    fn parsed_term_column_names_includes_by_smooth_grouping_variable() {
784        // The model's input contract must include a smooth's `by=` column, not
785        // just its positional variable. `s(x, by=g)` consumes both `x` and `g`
786        // (`term_builder` reads `options["by"]`); dropping `g` from the
787        // required/consumable set would either omit a genuine predictor at fit
788        // (CLI projected load) or — worse — make predict silently project the
789        // `g` column away (#840 regression). A bare main effect and an
790        // interaction round out the variant coverage.
791        let parsed =
792            parse_formula("y ~ s(x, by=g) + z + a:b").expect("formula with a by= smooth parses");
793        let mut cols = BTreeSet::<String>::new();
794        parsed_term_column_names(&parsed.terms, &mut cols);
795        for expected in ["x", "g", "z", "a", "b"] {
796            assert!(
797                cols.contains(expected),
798                "parsed_term_column_names dropped '{expected}'; got {cols:?}"
799            );
800        }
801        // The response is never a *term* column (it is handled separately).
802        assert!(
803            !cols.contains("y"),
804            "response leaked into term columns: {cols:?}"
805        );
806    }
807
808    #[test]
809    fn linkwiggle_parser_does_not_bake_in_cubic_only_restriction() {
810        // Regression for #384: the cubic-only constraint belongs to the
811        // score-warp / link-deviation `DeviationRuntime`, NOT to this shared
812        // parser. `parse_linkwiggle_formulaspec` also feeds `timewiggle` and
813        // the location-scale survival path, whose general monotone I-spline
814        // value basis honors any `degree >= 2`. So the parser must accept
815        // non-cubic degrees and carry them through verbatim; the cubic gate is
816        // applied downstream only where the cubic-only runtime is built. A
817        // prior fix wrongly forced `degree == 3` here, breaking timewiggle and
818        // location-scale callers — this pins that the parser stays general.
819        for deg in [2usize, 4, 5, 10] {
820            let mut options = BTreeMap::new();
821            options.insert("degree".to_string(), deg.to_string());
822            options.insert("internal_knots".to_string(), "3".to_string());
823            let raw = format!("timewiggle(degree={deg}, internal_knots=3)");
824            let spec = parse_linkwiggle_formulaspec(&options, &raw)
825                .expect("non-cubic wiggle degree must parse at the shared layer");
826            assert_eq!(
827                spec.degree, deg,
828                "parser must carry the requested degree through verbatim"
829            );
830        }
831
832        // The only universal lower bound the shared parser enforces is that a
833        // polynomial degree is positive.
834        let mut zero = BTreeMap::new();
835        zero.insert("degree".to_string(), "0".to_string());
836        zero.insert("internal_knots".to_string(), "3".to_string());
837        let err = parse_linkwiggle_formulaspec(&zero, "linkwiggle(degree=0, internal_knots=3)")
838            .expect_err("degree=0 must be rejected");
839        assert!(
840            err.contains("degree >= 1"),
841            "error should state the positive-degree lower bound, got: {err}"
842        );
843    }
844
845    #[test]
846    fn parses_nested_formula_terms() {
847        let parsed =
848            parse_formula_dsl("log(y) ~ x1 + s(log(x2 + 1), bs=\"tps\", k=10) + te(x3, x4)")
849                .expect("parse");
850        assert_eq!(parsed.response_expr, "log(y)");
851        assert_eq!(parsed.rhs_terms.len(), 3);
852        assert_eq!(parsed.rhs_terms[0], "x1");
853        assert_eq!(parsed.rhs_terms[1], "s(log(x2 + 1), bs=\"tps\", k=10)");
854        assert_eq!(parsed.rhs_terms[2], "te(x3, x4)");
855    }
856
857    #[test]
858    fn parses_cyclic_formula_aliases() {
859        let parsed = parse_formula("y ~ cyclic(theta, period_start=0, period_end=6.283)")
860            .expect("parse cyclic formula");
861        match &parsed.terms[0] {
862            super::ParsedTerm::Smooth { vars, options, .. } => {
863                assert_eq!(vars, &vec!["theta".to_string()]);
864                assert_eq!(options.get("type").map(String::as_str), Some("cyclic"));
865                assert_eq!(options.get("period_start").map(String::as_str), Some("0"));
866            }
867            other => panic!("expected cyclic smooth term, got {other:?}"),
868        }
869    }
870
871    #[test]
872    fn sphere_aliases_all_dispatch_to_intrinsic_s2_basis() {
873        // Regression for #383: `s2(lat, lon)` must build the same intrinsic
874        // S² (sphere) basis as `sphere()`/`sos()`/`spherical()`. Previously the
875        // `s2` arm returned a Smooth without `type=sphere`, so it silently fell
876        // back to a generic Euclidean 2-D smooth and diverged in the
877        // spatial-kappa optimizer. All four aliases must be byte-for-byte
878        // equivalent in their dispatch (vars + `type=sphere`).
879        for alias in ["sphere", "sos", "spherical", "s2"] {
880            let parsed = parse_formula(&format!("y ~ {alias}(lat, lon)"))
881                .unwrap_or_else(|e| panic!("parse {alias}: {e}"));
882            match &parsed.terms[0] {
883                super::ParsedTerm::Smooth { vars, options, .. } => {
884                    assert_eq!(
885                        vars,
886                        &vec!["lat".to_string(), "lon".to_string()],
887                        "{alias} should keep (lat, lon) as its variables"
888                    );
889                    assert_eq!(
890                        options.get("type").map(String::as_str),
891                        Some("sphere"),
892                        "{alias} must dispatch to the intrinsic sphere basis (type=sphere)"
893                    );
894                }
895                other => panic!("expected sphere smooth term for {alias}, got {other:?}"),
896            }
897        }
898    }
899
900    #[test]
901    fn parses_function_callwithnamed_and_positional_args() {
902        let call = parse_function_call("s(log(x + 1), type=\"duchon\", centers=12)").expect("call");
903        assert_eq!(call.name, "s");
904        assert_eq!(call.args.len(), 3);
905        assert_eq!(
906            call.args[0],
907            CallArgSpec::Positional("log(x + 1)".to_string())
908        );
909        assert_eq!(
910            call.args[1],
911            CallArgSpec::Named {
912                key: "type".to_string(),
913                value: "\"duchon\"".to_string()
914            }
915        );
916    }
917
918    #[test]
919    fn parses_tensor_boundary_list_options() {
920        let call = parse_function_call(
921            "te(day_of_week, hour, boundary=['periodic', 'periodic'], period=[7, 24])",
922        )
923        .expect("call");
924        assert_eq!(call.name, "te");
925        assert_eq!(call.args.len(), 4);
926        assert_eq!(
927            call.args[2],
928            CallArgSpec::Named {
929                key: "boundary".to_string(),
930                value: "['periodic', 'periodic']".to_string(),
931            }
932        );
933    }
934
935    #[test]
936    fn parse_formula_dsl_reports_unbalanced_parentheses() {
937        let err = parse_formula_dsl("y ~ s(x, k=10").expect_err("expected parse failure");
938        assert!(err.contains("unbalanced parentheses"));
939    }
940
941    #[test]
942    fn parse_function_call_reports_unbalanced_parentheses() {
943        let err = parse_function_call("s(x, k=10").expect_err("expected parse failure");
944        assert!(err.contains("unbalanced parentheses"));
945    }
946
947    #[test]
948    fn parse_formula_accepts_tuple_smooth_options() {
949        let parsed = parse_formula("z ~ te(x, y, k=(20, 20))")
950            .expect("tuple-valued smooth option should parse");
951        assert_eq!(parsed.terms.len(), 1);
952
953        let dsl = parse_formula_dsl("z ~ te(x, y, k=(20, 20))")
954            .expect("tuple-valued smooth option should parse in the DSL layer");
955        assert_eq!(dsl.rhs_terms, vec!["te(x, y, k=(20, 20))"]);
956
957        let call = parse_function_call("te(x, y, k=(20, 20))")
958            .expect("tuple-valued smooth option should parse as a function call");
959        assert_eq!(
960            call.args[2],
961            CallArgSpec::Named {
962                key: "k".to_string(),
963                value: "(20, 20)".to_string(),
964            }
965        );
966    }
967
968    #[test]
969    fn parse_formula_rejects_unsupported_top_level_rhs_expressions() {
970        // Binary `-`, unary `-`, bare parens, and `-1` intercept removal are
971        // not supported as top-level RHS expressions; they must surface
972        // through the bare-identifier check in `parse_term` with the same
973        // diagnostic. WR operators `:`, `*`, `/`, and `^` are intentionally
974        // supported by `expand_wr_term` and are exercised by
975        // `parse_formula_supports_wr_slash_nesting` /
976        // `parse_formula_supports_wr_star_crossing`; they must NOT appear in
977        // this list.
978        for formula in ["y ~ x - z", "y ~ -x", "y ~ (x)", "y ~ x - 1"] {
979            let err = parse_formula(formula).expect_err("expected formula parse failure");
980            assert!(err.to_string().contains("unsupported top-level RHS term"));
981        }
982    }
983
984    /// Wilkinson-Rogers `a/b` (nesting) is documented and implemented by
985    /// `expand_wr_term` to yield `{a, a:b}`. This test pins that contract end
986    /// to end so a regression in either the grammar's `mul_op = { "*" | "/" }`
987    /// branch or in `expand_expr`'s `Rule::product` handling surfaces here
988    /// instead of slipping into a silent re-interpretation as a rejection.
989    #[test]
990    fn parse_formula_supports_wr_slash_nesting() {
991        let parsed = parse_formula("y ~ x / z").expect("`/` is supported as WR nesting");
992        assert_eq!(parsed.response, "y");
993        assert_eq!(parsed.terms.len(), 2);
994        let names: Vec<String> = parsed
995            .terms
996            .iter()
997            .map(|t| match t {
998                ParsedTerm::Linear { name, .. } => format!("Linear({name})"),
999                ParsedTerm::Interaction { vars } => format!("Interaction({})", vars.join(":")),
1000                other => format!("Other({other:?})"),
1001            })
1002            .collect();
1003        assert_eq!(
1004            names,
1005            vec!["Linear(x)".to_string(), "Interaction(x:z)".to_string()]
1006        );
1007    }
1008
1009    /// Wilkinson-Rogers `a*b` (crossing) is documented and implemented by
1010    /// `expand_wr_term` to yield `{a, b, a:b}`. Pin the contract so a future
1011    /// refactor of `mul_op` / `Rule::product` handling cannot silently change
1012    /// the set of model terms.
1013    #[test]
1014    fn parse_formula_supports_wr_star_crossing() {
1015        let parsed = parse_formula("y ~ x * z").expect("`*` is supported as WR crossing");
1016        assert_eq!(parsed.response, "y");
1017        assert_eq!(parsed.terms.len(), 3);
1018        let names: Vec<String> = parsed
1019            .terms
1020            .iter()
1021            .map(|t| match t {
1022                ParsedTerm::Linear { name, .. } => format!("Linear({name})"),
1023                ParsedTerm::Interaction { vars } => format!("Interaction({})", vars.join(":")),
1024                other => format!("Other({other:?})"),
1025            })
1026            .collect();
1027        assert_eq!(
1028            names,
1029            vec![
1030                "Linear(x)".to_string(),
1031                "Linear(z)".to_string(),
1032                "Interaction(x:z)".to_string(),
1033            ]
1034        );
1035    }
1036
1037    #[test]
1038    fn parse_formula_rejects_unary_signs_inside_wr_expansion() {
1039        for formula in ["y ~ x:-z", "y ~ a*-b", "y ~ x/-z", "y ~ x:+z"] {
1040            let err = parse_formula(formula)
1041                .expect_err("WR expansion must not silently drop unary signs");
1042            let msg = err.to_string();
1043            assert!(
1044                msg.contains("unary `+`/`-` is not supported"),
1045                "unexpected error for {formula}: {msg}"
1046            );
1047        }
1048    }
1049
1050    #[test]
1051    fn parse_formula_supports_wr_power_crossing() {
1052        let parsed = parse_formula("y ~ (x + z)^2").expect("`^` is supported as WR power");
1053        assert_eq!(parsed.response, "y");
1054        assert_eq!(parsed.terms.len(), 3);
1055        let names: Vec<String> = parsed
1056            .terms
1057            .iter()
1058            .map(|t| match t {
1059                ParsedTerm::Linear { name, .. } => format!("Linear({name})"),
1060                ParsedTerm::Interaction { vars } => format!("Interaction({})", vars.join(":")),
1061                other => format!("Other({other:?})"),
1062            })
1063            .collect();
1064        assert_eq!(
1065            names,
1066            vec![
1067                "Linear(x)".to_string(),
1068                "Linear(z)".to_string(),
1069                "Interaction(x:z)".to_string(),
1070            ]
1071        );
1072    }
1073
1074    #[test]
1075    fn parse_formula_rejects_chained_wr_power() {
1076        let err = parse_formula("y ~ (x + z)^2^3")
1077            .expect_err("chained WR powers must not silently drop later exponents");
1078        let msg = err.to_string();
1079        assert!(
1080            msg.contains("chained `^` operators are not supported"),
1081            "error should explain that chained powers are rejected, got: {msg}"
1082        );
1083    }
1084
1085    #[test]
1086    fn parsed_terms_reference_column_sees_the_by_smooth_variable() {
1087        // Regression for #807: the by= grouping variable lives in
1088        // options["by"], not the smooth's positional vars. The reference
1089        // predicate must still recognise it, both so the CLI loads the column
1090        // and so the marginal-slope z-column exclusion check (which reuses this
1091        // predicate) cannot be fooled into aliasing a reserved z onto a by=.
1092        let parsed = parse_formula("y ~ s(x, by=g)").expect("parse by-smooth");
1093        assert!(
1094            parsed_terms_reference_column(&parsed.terms, "g"),
1095            "s(x, by=g) references column g via options[\"by\"]"
1096        );
1097        assert!(parsed_terms_reference_column(&parsed.terms, "x"));
1098        assert!(!parsed_terms_reference_column(&parsed.terms, "absent"));
1099    }
1100
1101    #[test]
1102    fn marginal_slope_z_column_validator_detects_linear_and_smooth_reuse() {
1103        let main = parse_formula("y ~ x + z").expect("parse main");
1104        let logslope = parse_formula("y ~ s(z, type=duchon, centers=6)").expect("parse logslope");
1105
1106        assert!(parsed_terms_reference_column(&main.terms, "z"));
1107        assert!(parsed_terms_reference_column(&logslope.terms, "z"));
1108
1109        let err = validate_marginal_slope_z_column_exclusion(
1110            &main,
1111            &parse_formula("y ~ 1").expect("parse clean logslope"),
1112            "z",
1113            "bernoulli marginal-slope",
1114            "--logslope-formula",
1115        )
1116        .expect_err("main formula should be rejected");
1117        assert!(err.contains("cannot also appear in the main formula"));
1118
1119        let err = validate_marginal_slope_z_column_exclusion(
1120            &parse_formula("y ~ x").expect("parse clean main"),
1121            &logslope,
1122            "z",
1123            "bernoulli marginal-slope",
1124            "--logslope-formula",
1125        )
1126        .expect_err("logslope formula should be rejected");
1127        assert!(err.contains("cannot also appear in --logslope-formula"));
1128    }
1129
1130    #[test]
1131    fn logslope_surface_declarations_are_additive() {
1132        let parsed = parse_formula("y ~ s(pc1) + logslope(z2, s(pc2)) + logslope(z3, x3)")
1133            .expect("parse additive logslope surfaces");
1134        assert_eq!(parsed.terms.len(), 1);
1135        assert_eq!(parsed.logslope_surfaces.len(), 2);
1136        assert_eq!(parsed.logslope_surfaces[0].z_column, "z2");
1137        assert_eq!(parsed.logslope_surfaces[0].terms.len(), 1);
1138        assert_eq!(parsed.logslope_surfaces[1].z_column, "z3");
1139        assert_eq!(parsed.logslope_surfaces[1].terms.len(), 1);
1140    }
1141
1142    #[test]
1143    fn marginal_slope_z_column_validator_reserves_all_surface_z_columns() {
1144        let main = parse_formula("y ~ x").expect("parse main");
1145        let logslope = parse_formula("y ~ s(pc1) + logslope(z2, s(z3)) + logslope(z3, x)")
1146            .expect("parse logslope surfaces");
1147        let err = validate_marginal_slope_z_column_exclusion(
1148            &main,
1149            &logslope,
1150            "z1",
1151            "bernoulli marginal-slope",
1152            "--logslope-formula",
1153        )
1154        .expect_err("surface formula should reject another reserved z coordinate");
1155        assert!(err.contains("reserves z column 'z3'"));
1156    }
1157
1158    /// Extract the single `RandomEffect` term's unseen-level policy from a
1159    /// one-term formula, panicking if the term is not a random-effect block.
1160    fn random_effect_lenient_unseen(formula: &str) -> bool {
1161        let parsed = parse_formula(formula).expect("parse random-effect formula");
1162        let re = parsed
1163            .terms
1164            .iter()
1165            .find_map(|t| match t {
1166                ParsedTerm::RandomEffect { lenient_unseen, .. } => Some(*lenient_unseen),
1167                _ => None,
1168            });
1169        re.unwrap_or_else(|| panic!("{formula} did not lower to a RandomEffect term"))
1170    }
1171
1172    #[test]
1173    fn factor_wrapper_is_strict_on_unseen_levels_while_group_re_are_lenient() {
1174        // Regression for #2137 (sibling of #2102): `factor(g)` is a FIXED
1175        // categorical factor (R `factor()` / patsy `C()`), so an out-of-vocabulary
1176        // level at predict is a schema mismatch that must raise — NOT be shrunk to
1177        // the centering point. `group(g)`/`re(g)`/`s(g, bs="re")` are genuine
1178        // random effects that tolerate a held-out group (→ population mean). The
1179        // parse arm once hardcoded `lenient_unseen: true` for all four wrappers,
1180        // so `factor(g)` silently averaged an unseen level. Pin the per-wrapper
1181        // policy at the parse layer, where the whole distinction now lives.
1182        assert!(
1183            !random_effect_lenient_unseen("y ~ factor(g)"),
1184            "factor(g) is a fixed categorical factor: strict (lenient_unseen=false) on unseen levels"
1185        );
1186        for lenient in ["y ~ group(g)", "y ~ re(g)", "y ~ s(g, bs=re)"] {
1187            assert!(
1188                random_effect_lenient_unseen(lenient),
1189                "{lenient} is a genuine random effect: lenient (lenient_unseen=true) on unseen levels"
1190            );
1191        }
1192    }
1193}
1194
1195// ---------------------------------------------------------------------------
1196// Higher-level formula parsing: ParsedFormula, ParsedTerm, and friends
1197// ---------------------------------------------------------------------------
1198
1199#[derive(Clone, Debug)]
1200pub struct LinkWiggleFormulaSpec {
1201    pub degree: usize,
1202    pub num_internal_knots: usize,
1203    pub penalty_orders: Vec<usize>,
1204    pub double_penalty: bool,
1205}
1206
1207pub fn default_linkwiggle_formulaspec() -> LinkWiggleFormulaSpec {
1208    let cfg = WigglePenaltyConfig::cubic_triple_operator_default();
1209    LinkWiggleFormulaSpec {
1210        degree: cfg.degree,
1211        num_internal_knots: cfg.num_internal_knots,
1212        penalty_orders: cfg.penalty_orders,
1213        double_penalty: cfg.double_penalty,
1214    }
1215}
1216
1217#[derive(Clone, Debug)]
1218pub struct LinkFormulaSpec {
1219    pub link: String,
1220    pub mixture_rho: Option<String>,
1221    pub sas_init: Option<String>,
1222    pub beta_logistic_init: Option<String>,
1223}
1224
1225#[derive(Clone, Debug)]
1226pub struct SurvivalFormulaSpec {
1227    pub spec: Option<String>,
1228    pub survival_distribution: Option<String>,
1229}
1230
1231#[derive(Clone, Debug)]
1232pub struct ParsedFormula {
1233    pub response: String,
1234    pub terms: Vec<ParsedTerm>,
1235    pub logslope_surfaces: Vec<LogSlopeSurfaceSpec>,
1236    pub linkwiggle: Option<LinkWiggleFormulaSpec>,
1237    pub timewiggle: Option<LinkWiggleFormulaSpec>,
1238    pub linkspec: Option<LinkFormulaSpec>,
1239    pub survivalspec: Option<SurvivalFormulaSpec>,
1240}
1241
1242#[derive(Clone, Debug)]
1243pub struct LogSlopeSurfaceSpec {
1244    pub z_column: String,
1245    pub terms: Vec<ParsedTerm>,
1246}
1247
1248pub fn marginal_slope_logslope_surfaces(
1249    logslope_formula: &ParsedFormula,
1250    default_z_column: &str,
1251) -> Result<Vec<LogSlopeSurfaceSpec>, String> {
1252    let mut surfaces = Vec::new();
1253    if !logslope_formula.terms.is_empty() {
1254        surfaces.push(LogSlopeSurfaceSpec {
1255            z_column: default_z_column.to_string(),
1256            terms: logslope_formula.terms.clone(),
1257        });
1258    }
1259    surfaces.extend(logslope_formula.logslope_surfaces.clone());
1260    if surfaces.is_empty() {
1261        surfaces.push(LogSlopeSurfaceSpec {
1262            z_column: default_z_column.to_string(),
1263            terms: Vec::new(),
1264        });
1265    }
1266    let mut seen = std::collections::BTreeSet::<String>::new();
1267    for surface in &surfaces {
1268        if !seen.insert(surface.z_column.clone()) {
1269            return Err(FormulaDslError::IncompatibleTerm {
1270                reason: format!(
1271                    "logslope formula declares z column '{}' more than once; each z coordinate needs exactly one log-slope surface",
1272                    surface.z_column
1273                ),
1274            }
1275            .into());
1276        }
1277    }
1278    Ok(surfaces)
1279}
1280
1281#[derive(Clone, Debug)]
1282pub enum ParsedTerm {
1283    Linear {
1284        name: String,
1285        explicit: bool,
1286        coefficient_min: Option<f64>,
1287        coefficient_max: Option<f64>,
1288    },
1289    BoundedLinear {
1290        name: String,
1291        min: f64,
1292        max: f64,
1293        prior: BoundedCoefficientPriorSpec,
1294    },
1295    RandomEffect {
1296        name: String,
1297        /// Unseen-level policy, fixed at parse time by the wrapper the user
1298        /// wrote. `group(g)`/`re(g)`/`s(g, bs="re")` are genuine **random
1299        /// effects**: a held-out group is shrunk to the population mean, so an
1300        /// unseen level at predict is tolerated (`true`). `factor(g)` is a
1301        /// **fixed** categorical factor (R `factor()` / patsy `C()`
1302        /// convention): like a bare `+ g` categorical main effect, an unseen
1303        /// level is a schema mismatch that must raise rather than collapse onto
1304        /// the factor's centering point (`false`, #2137/#2102). Both wrappers
1305        /// share the penalized-categorical materialization; only this policy
1306        /// distinguishes them, so seen-level fits are identical.
1307        lenient_unseen: bool,
1308    },
1309    Smooth {
1310        label: String,
1311        vars: Vec<String>,
1312        kind: SmoothKind,
1313        options: BTreeMap<String, String>,
1314    },
1315    LinkWiggle {
1316        options: BTreeMap<String, String>,
1317    },
1318    TimeWiggle {
1319        options: BTreeMap<String, String>,
1320    },
1321    LinkConfig {
1322        options: BTreeMap<String, String>,
1323    },
1324    SurvivalConfig {
1325        options: BTreeMap<String, String>,
1326    },
1327    LogSlopeSurface {
1328        z_column: String,
1329        terms: Vec<ParsedTerm>,
1330    },
1331    /// Wilkinson-Rogers interaction term `a:b[:c...]`.
1332    ///
1333    /// `vars` is a sorted, deduplicated list of base column names. Each element
1334    /// must be a bare identifier — interactions with function-call atoms
1335    /// (smooths, factors, etc.) are rejected upstream because their design
1336    /// columns are not simple products. The design column is the elementwise
1337    /// product of the referenced numeric columns.
1338    Interaction {
1339        vars: Vec<String>,
1340    },
1341}
1342
1343/// Collect the names of every data column the parsed terms consume.
1344///
1345/// This is the canonical formula→columns walk shared by the fit-time and
1346/// predict-time required-column computations (the CLI and PyFFI surfaces both
1347/// route through it). It includes a smooth's positional `vars` *and* its `by=`
1348/// grouping/scaling column (`s(x, by=g)`), which `term_builder` reads from
1349/// `options["by"]` but which is not among the positional variables — omitting
1350/// it would drop a genuine predictor from the model's input contract.
1351pub fn parsed_term_column_names(
1352    terms: &[ParsedTerm],
1353    out: &mut std::collections::BTreeSet<String>,
1354) {
1355    for term in terms {
1356        match term {
1357            ParsedTerm::Linear { name, .. }
1358            | ParsedTerm::BoundedLinear { name, .. }
1359            | ParsedTerm::RandomEffect { name, .. } => {
1360                out.insert(name.clone());
1361            }
1362            ParsedTerm::Smooth { vars, options, .. } => {
1363                out.extend(vars.iter().cloned());
1364                if let Some(by) = options.get("by") {
1365                    out.insert(by.clone());
1366                }
1367            }
1368            ParsedTerm::Interaction { vars } => {
1369                out.extend(vars.iter().cloned());
1370            }
1371            ParsedTerm::LinkWiggle { .. }
1372            | ParsedTerm::TimeWiggle { .. }
1373            | ParsedTerm::LinkConfig { .. }
1374            | ParsedTerm::SurvivalConfig { .. } => {}
1375            ParsedTerm::LogSlopeSurface { z_column, terms } => {
1376                out.insert(z_column.clone());
1377                parsed_term_column_names(terms, out);
1378            }
1379        }
1380    }
1381}
1382
1383pub fn parsed_terms_reference_column(terms: &[ParsedTerm], column_name: &str) -> bool {
1384    terms.iter().any(|term| match term {
1385        ParsedTerm::Linear { name, .. }
1386        | ParsedTerm::BoundedLinear { name, .. }
1387        | ParsedTerm::RandomEffect { name, .. } => name == column_name,
1388        ParsedTerm::Smooth { vars, options, .. } => {
1389            vars.iter().any(|var| var == column_name)
1390                || options.get("by").is_some_and(|by| by == column_name)
1391        }
1392        ParsedTerm::Interaction { vars } => vars.iter().any(|var| var == column_name),
1393        ParsedTerm::LinkWiggle { .. }
1394        | ParsedTerm::TimeWiggle { .. }
1395        | ParsedTerm::LinkConfig { .. }
1396        | ParsedTerm::SurvivalConfig { .. } => false,
1397        ParsedTerm::LogSlopeSurface { z_column, terms } => {
1398            z_column == column_name || parsed_terms_reference_column(terms, column_name)
1399        }
1400    })
1401}
1402
1403pub fn validate_marginal_slope_z_column_exclusion(
1404    main_formula: &ParsedFormula,
1405    logslope_formula: &ParsedFormula,
1406    z_column: &str,
1407    context: &str,
1408    logslope_label: &str,
1409) -> Result<(), String> {
1410    let surfaces = marginal_slope_logslope_surfaces(logslope_formula, z_column)?;
1411    // The CLI/configured z column is reserved even when the log-slope formula
1412    // is intercept-only (`~ 1`) and therefore contributes no surface terms.
1413    // Explicit logslope(...) declarations may reserve additional z coordinates.
1414    let mut reserved_z_columns = std::collections::BTreeSet::<&str>::new();
1415    reserved_z_columns.insert(z_column);
1416    reserved_z_columns.extend(surfaces.iter().map(|surface| surface.z_column.as_str()));
1417
1418    for reserved in &reserved_z_columns {
1419        if parsed_terms_reference_column(&main_formula.terms, reserved) {
1420            return Err(FormulaDslError::IncompatibleTerm {
1421                reason: format!(
1422                    "{context} reserves z column '{reserved}' as the auxiliary latent score; it cannot also appear in the main formula"
1423                ),
1424            }
1425            .into());
1426        }
1427    }
1428    for reserved in &reserved_z_columns {
1429        if parsed_terms_reference_column(&logslope_formula.terms, reserved) {
1430            return Err(FormulaDslError::IncompatibleTerm {
1431                reason: format!(
1432                    "{context} reserves z column '{reserved}' as the auxiliary latent score; it cannot also appear in {logslope_label}"
1433                ),
1434            }
1435            .into());
1436        }
1437        for surface in &surfaces {
1438            if parsed_terms_reference_column(&surface.terms, reserved) {
1439                return Err(FormulaDslError::IncompatibleTerm {
1440                    reason: format!(
1441                        "{context} reserves z column '{reserved}' as an auxiliary latent score; it cannot also appear in {logslope_label}"
1442                    ),
1443                }
1444                .into());
1445            }
1446        }
1447    }
1448    Ok(())
1449}
1450
1451#[derive(Clone, Copy, Debug)]
1452pub enum SmoothKind {
1453    S,
1454    Te,
1455    /// Tensor smooth (`t2(...)`) using mgcv's separable penalty decomposition:
1456    /// the tensor coefficient space is split into marginal penalized/null-space
1457    /// tensor subspaces, with one smoothing parameter per non-null subspace.
1458    T2,
1459    /// Tensor *interaction* smooth (`ti(...)`): a tensor-product smooth whose
1460    /// marginal main effects are excluded, so the term captures only the pure
1461    /// interaction between its variables. Materializes through the same tensor
1462    /// path as [`SmoothKind::Te`] but with per-margin sum-to-zero
1463    /// identifiability (`TensorBSplineIdentifiability::MarginalSumToZero`).
1464    Ti,
1465}
1466
1467#[derive(Clone, Copy, Debug)]
1468pub enum LinkMode {
1469    Strict,
1470    Flexible,
1471}
1472
1473#[derive(Clone, Debug)]
1474pub struct LinkChoice {
1475    pub mode: LinkMode,
1476    pub link: LinkFunction,
1477    pub mixture_components: Option<Vec<LinkComponent>>,
1478}
1479
1480// ---------------------------------------------------------------------------
1481// Link wiggle / link choice helpers
1482// ---------------------------------------------------------------------------
1483
1484pub fn effectivelinkwiggle_formulaspec(
1485    formula_linkwiggle: Option<&LinkWiggleFormulaSpec>,
1486    link_choice: Option<&LinkChoice>,
1487) -> Option<LinkWiggleFormulaSpec> {
1488    formula_linkwiggle.cloned().or_else(|| {
1489        link_choice.and_then(|choice| {
1490            if matches!(choice.mode, LinkMode::Flexible) {
1491                Some(default_linkwiggle_formulaspec())
1492            } else {
1493                None
1494            }
1495        })
1496    })
1497}
1498
1499pub const fn linkname_supports_joint_wiggle(link: LinkFunction) -> bool {
1500    !matches!(link, LinkFunction::Sas | LinkFunction::BetaLogistic)
1501}
1502
1503pub const fn linkchoice_supports_joint_wiggle(choice: &LinkChoice) -> bool {
1504    match &choice.mixture_components {
1505        None => linkname_supports_joint_wiggle(choice.link),
1506        Some(_) => false,
1507    }
1508}
1509
1510pub fn require_linkchoice_supports_joint_wiggle(
1511    choice: &LinkChoice,
1512    context: &str,
1513) -> Result<(), String> {
1514    if linkchoice_supports_joint_wiggle(choice) {
1515        Ok(())
1516    } else {
1517        Err(joint_wiggle_unsupported_link_message(context))
1518    }
1519}
1520
1521pub const fn likelihood_spec_supports_joint_wiggle(likelihood: &LikelihoodSpec) -> bool {
1522    inverse_link_supports_joint_wiggle(&likelihood.link)
1523}
1524
1525pub fn require_likelihood_spec_supports_joint_wiggle(
1526    likelihood: &LikelihoodSpec,
1527    context: &str,
1528) -> Result<(), String> {
1529    if likelihood_spec_supports_joint_wiggle(likelihood) {
1530        Ok(())
1531    } else {
1532        Err(joint_wiggle_unsupported_link_message(context))
1533    }
1534}
1535
1536/// Family-agnostic capability of the joint link-wiggle machinery: which base
1537/// inverse links a monotone warp can be fit over AND reconstructed from at
1538/// predict time. Every state-less standard link qualifies — the warp fit and
1539/// its saved-model reconstruction evaluate the base inverse link purely through
1540/// the generic `inverse_link_jet_for_inverse_link` jet dispatch, which carries
1541/// LogLog and Cauchit exactly as it does Logit/Probit/CLogLog. LogLog/Cauchit
1542/// were previously omitted, so a binomial `flexible(loglog)`/`flexible(cauchit)`
1543/// fit that this-gate-agnostically *converged* (see
1544/// `binomial_inverse_link_supports_joint_wiggle`) then failed at predict when
1545/// `FittedModel::saved_link_wiggle` re-checked the saved link here (#2155). The
1546/// state-bearing links (SAS/BetaLogistic/Mixture/LatentCLogLog) carry fitted
1547/// warp/skew state of their own and are intentionally excluded.
1548pub const fn inverse_link_supports_joint_wiggle(link: &InverseLink) -> bool {
1549    matches!(
1550        link,
1551        InverseLink::Standard(StandardLink::Identity)
1552            | InverseLink::Standard(StandardLink::Log)
1553            | InverseLink::Standard(StandardLink::Logit)
1554            | InverseLink::Standard(StandardLink::Probit)
1555            | InverseLink::Standard(StandardLink::CLogLog)
1556            | InverseLink::Standard(StandardLink::LogLog)
1557            | InverseLink::Standard(StandardLink::Cauchit)
1558    )
1559}
1560
1561pub fn require_inverse_link_supports_joint_wiggle(
1562    link: &InverseLink,
1563    context: &str,
1564) -> Result<(), String> {
1565    if inverse_link_supports_joint_wiggle(link) {
1566        Ok(())
1567    } else {
1568        Err(joint_wiggle_unsupported_link_message(context))
1569    }
1570}
1571
1572/// Which binomial base links the joint link-wiggle (flexible-link) solver can
1573/// fit. All five standard binomial probability links qualify: the wiggle kernel
1574/// (`BinomialMeanWiggleFamily`) evaluates the base inverse link purely through
1575/// the generic `inverse_link_jet_for_inverse_link` dispatch, which carries full
1576/// jets for LogLog and Cauchit exactly as it does for Logit/Probit/CLogLog — so
1577/// `flexible(loglog)` / `flexible(cauchit)` fit through the same machinery
1578/// (#2155). Previously this gate listed only logit/probit/cloglog while the
1579/// permissive parse gate `linkname_supports_joint_wiggle` admitted loglog/cauchit,
1580/// so the config was accepted then aborted deep in the solver. The state-bearing
1581/// links (SAS/BetaLogistic/Mixture/LatentCLogLog) and identity/log stay out: the
1582/// warp is defined only over a fixed state-less base probability link.
1583pub const fn binomial_inverse_link_supports_joint_wiggle(link: &InverseLink) -> bool {
1584    matches!(
1585        link,
1586        InverseLink::Standard(StandardLink::Logit)
1587            | InverseLink::Standard(StandardLink::Probit)
1588            | InverseLink::Standard(StandardLink::CLogLog)
1589            | InverseLink::Standard(StandardLink::LogLog)
1590            | InverseLink::Standard(StandardLink::Cauchit)
1591    )
1592}
1593
1594pub fn require_binomial_inverse_link_supports_joint_wiggle(
1595    link: &InverseLink,
1596    context: &str,
1597) -> Result<(), String> {
1598    if binomial_inverse_link_supports_joint_wiggle(link) {
1599        Ok(())
1600    } else {
1601        Err(FormulaDslError::IncompatibleTerm {
1602            reason: format!(
1603                "{context} does not support identity, log, latent-cloglog, SAS, BetaLogistic, or Mixture links; wiggle is only available for jointly fitted standard binomial probability links (logit/probit/cloglog/loglog/cauchit)"
1604            ),
1605        }
1606        .into())
1607    }
1608}
1609
1610pub fn joint_wiggle_unsupported_link_message(context: &str) -> String {
1611    format!(
1612        "{context} does not support latent-cloglog, SAS, BetaLogistic, or Mixture links; wiggle is only available for jointly fitted standard links"
1613    )
1614}
1615
1616// ---------------------------------------------------------------------------
1617// Option-map helpers (shared by formula parsing and term construction)
1618// ---------------------------------------------------------------------------
1619
1620pub fn option_usize(map: &BTreeMap<String, String>, key: &str) -> Option<usize> {
1621    map.get(key).and_then(|v| v.parse::<usize>().ok())
1622}
1623
1624/// Local sibling of `term_builder::validate_known_options` used by the
1625/// parser-side `linear / bounded / constrain / nonnegative / nonpositive`
1626/// branches (which build their `ParsedTerm` here and never enter
1627/// `term_builder::build_smooth_basis`). Without this, typos like
1628/// `bounded(x, min=0, max=1, foo=bar)` silently succeed because the
1629/// `foo` key was just never read.
1630fn validate_known_term_options(
1631    term_name: &str,
1632    options: &BTreeMap<String, String>,
1633    known: &[&str],
1634    raw: &str,
1635) -> Result<(), String> {
1636    let known_set: std::collections::BTreeSet<&&str> = known.iter().collect();
1637    for key in options.keys() {
1638        if !known_set.contains(&key.as_str()) {
1639            let known_sorted = {
1640                let mut v = known.to_vec();
1641                v.sort_unstable();
1642                v.join(", ")
1643            };
1644            let known_hint = if known.is_empty() {
1645                "no options".to_string()
1646            } else {
1647                format!("[{known_sorted}]")
1648            };
1649            return Err(FormulaDslError::InvalidArgument {
1650                reason: format!(
1651                    "{term_name}() does not accept option `{key}` (in `{raw}`); known options: {known_hint}"
1652                ),
1653            }
1654            .into());
1655        }
1656    }
1657    Ok(())
1658}
1659
1660pub fn option_usize_any(map: &BTreeMap<String, String>, keys: &[&str]) -> Option<usize> {
1661    for key in keys {
1662        if let Some(v) = option_usize(map, key) {
1663            return Some(v);
1664        }
1665    }
1666    None
1667}
1668
1669/// Strict integer option: returns `Ok(None)` if not present, `Ok(Some(n))` if
1670/// it parses as a non-negative integer, and `Err(msg)` if the user supplied a
1671/// value that isn't a valid usize (negative, decimal, garbage). Without this
1672/// the lenient `option_usize` silently drops invalid values and reverts to
1673/// the default — `k=-1` and `k=1.5` were both accepted as "k not specified"
1674/// instead of being flagged as user mistakes.
1675pub fn option_usize_strict(
1676    map: &BTreeMap<String, String>,
1677    key: &str,
1678) -> Result<Option<usize>, String> {
1679    match map.get(key) {
1680        None => Ok(None),
1681        Some(raw) => raw.parse::<usize>().map(Some).map_err(|err| {
1682            FormulaDslError::InvalidArgument {
1683                reason: format!(
1684                    "option `{key}={raw}` is not a non-negative integer; \
1685                     expected a whole number >= 0: {err}"
1686                ),
1687            }
1688            .into()
1689        }),
1690    }
1691}
1692
1693/// Strict variant of `option_usize_any` that errors on the first present-but-
1694/// unparseable key rather than silently falling through.
1695pub fn option_usize_any_strict(
1696    map: &BTreeMap<String, String>,
1697    keys: &[&str],
1698) -> Result<Option<usize>, String> {
1699    for key in keys {
1700        if let Some(v) = option_usize_strict(map, key)? {
1701            return Ok(Some(v));
1702        }
1703    }
1704    Ok(None)
1705}
1706
1707pub fn option_f64(map: &BTreeMap<String, String>, key: &str) -> Option<f64> {
1708    map.get(key).and_then(|v| v.parse::<f64>().ok())
1709}
1710
1711/// Strict float option: `Ok(None)` if absent, `Ok(Some(n))` if parses as a
1712/// finite f64, `Err` if the user passed an unparseable value (rather than
1713/// silently dropping it like the lenient `option_f64`).
1714pub fn option_f64_strict(map: &BTreeMap<String, String>, key: &str) -> Result<Option<f64>, String> {
1715    match map.get(key) {
1716        None => Ok(None),
1717        Some(raw) => match raw.parse::<f64>() {
1718            Ok(v) if v.is_finite() => Ok(Some(v)),
1719            Ok(v) => Err(FormulaDslError::InvalidArgument {
1720                reason: format!("option `{key}={raw}` parses as {v} which is not a finite number"),
1721            }
1722            .into()),
1723            Err(err) => Err(FormulaDslError::InvalidArgument {
1724                reason: format!(
1725                    "option `{key}={raw}` is not a valid number; expected a finite decimal: {err}"
1726                ),
1727            }
1728            .into()),
1729        },
1730    }
1731}
1732
1733pub fn option_bool(map: &BTreeMap<String, String>, key: &str) -> Option<bool> {
1734    map.get(key)
1735        .and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
1736            "true" | "1" | "yes" | "y" => Some(true),
1737            "false" | "0" | "no" | "n" => Some(false),
1738            _ => None,
1739        })
1740}
1741
1742/// Strict boolean option: `Ok(None)` if absent, `Ok(Some(b))` for a recognized
1743/// truthy/falsy token, and `Err(msg)` for a present-but-unparseable value. The
1744/// lenient `option_bool` maps an unrecognized value to `None`, which callers
1745/// then silently treat as "not specified" — masking user typos like
1746/// `double_penalty=ture`.
1747pub fn option_bool_strict(
1748    map: &BTreeMap<String, String>,
1749    key: &str,
1750) -> Result<Option<bool>, String> {
1751    match map.get(key) {
1752        None => Ok(None),
1753        Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1754            "true" | "1" | "yes" | "y" => Ok(Some(true)),
1755            "false" | "0" | "no" | "n" => Ok(Some(false)),
1756            _ => Err(FormulaDslError::InvalidArgument {
1757                reason: format!(
1758                    "option `{key}={raw}` is not a boolean; \
1759                     expected one of true/false/yes/no/1/0"
1760                ),
1761            }
1762            .into()),
1763        },
1764    }
1765}
1766
1767pub fn strip_quotes(v: &str) -> &str {
1768    let b = v.as_bytes();
1769    if b.len() >= 2
1770        && ((b[0] == b'\'' && b[b.len() - 1] == b'\'') || (b[0] == b'"' && b[b.len() - 1] == b'"'))
1771    {
1772        &v[1..v.len() - 1]
1773    } else {
1774        v
1775    }
1776}
1777
1778// ---------------------------------------------------------------------------
1779// Sub-parsers for formula option blocks
1780// ---------------------------------------------------------------------------
1781
1782fn parse_linear_constraint_bounds(
1783    options: &BTreeMap<String, String>,
1784    raw: &str,
1785) -> Result<(Option<f64>, Option<f64>), String> {
1786    let min = parse_optional_f64_option_alias(options, &["min", "lower"], raw, "linear")?;
1787    let max = parse_optional_f64_option_alias(options, &["max", "upper"], raw, "linear")?;
1788    if let (Some(min), Some(max)) = (min, max)
1789        && (!min.is_finite() || !max.is_finite() || min > max)
1790    {
1791        return Err(FormulaDslError::InvalidArgument {
1792            reason: format!(
1793                "linear coefficient constraints require finite min <= max, got min={min}, max={max}: {raw}"
1794            ),
1795        }
1796        .into());
1797    }
1798    Ok((min, max))
1799}
1800
1801fn parse_required_f64_option(
1802    options: &BTreeMap<String, String>,
1803    key: &str,
1804    raw: &str,
1805) -> Result<f64, String> {
1806    let value = options
1807        .get(key)
1808        .ok_or_else(|| FormulaDslError::MalformedConfig {
1809            reason: format!("bounded() is missing required '{key}' argument: {raw}"),
1810        })?;
1811    value.parse::<f64>().map_err(|err| {
1812        FormulaDslError::InvalidArgument {
1813            reason: format!(
1814                "bounded() argument '{key}' must be a finite number, got '{}': {err}: {raw}",
1815                value
1816            ),
1817        }
1818        .into()
1819    })
1820}
1821
1822fn parse_optional_f64_option(
1823    options: &BTreeMap<String, String>,
1824    key: &str,
1825    raw: &str,
1826) -> Result<Option<f64>, String> {
1827    match options.get(key) {
1828        Some(value) => value.parse::<f64>().map(Some).map_err(|err| {
1829            FormulaDslError::InvalidArgument {
1830                reason: format!(
1831                    "bounded() argument '{key}' must be a finite number, got '{}': {err}: {raw}",
1832                    value
1833                ),
1834            }
1835            .into()
1836        }),
1837        None => Ok(None),
1838    }
1839}
1840
1841fn parse_optional_f64_option_alias(
1842    options: &BTreeMap<String, String>,
1843    keys: &[&str],
1844    raw: &str,
1845    fn_label: &str,
1846) -> Result<Option<f64>, String> {
1847    let mut found: Option<(&str, f64)> = None;
1848    for key in keys {
1849        if let Some(value) = options.get(*key) {
1850            let parsed = value
1851                .parse::<f64>()
1852                .map_err(|err| FormulaDslError::InvalidArgument {
1853                    reason: format!(
1854                        "{fn_label}() argument '{key}' must be a finite number, got '{}': {err}: {raw}",
1855                        value
1856                    ),
1857                })?;
1858            if found.is_some() {
1859                return Err(FormulaDslError::IncompatibleTerm {
1860                    reason: format!(
1861                        "{fn_label}() cannot specify both '{}' and '{}': {raw}",
1862                        found.expect("present").0,
1863                        key
1864                    ),
1865                }
1866                .into());
1867            }
1868            found = Some((key, parsed));
1869        }
1870    }
1871    Ok(found.map(|(_, v)| v))
1872}
1873
1874fn parse_linkwiggle_penalty_orders(raw: Option<&str>) -> Result<Vec<usize>, String> {
1875    let Some(raw) = raw.map(str::trim) else {
1876        return Ok(WigglePenaltyConfig::cubic_triple_operator_default().penalty_orders);
1877    };
1878    if raw.is_empty() {
1879        return Ok(WigglePenaltyConfig::cubic_triple_operator_default().penalty_orders);
1880    }
1881    let mut out = Vec::<usize>::new();
1882    for token in raw.split(',') {
1883        let t = token.trim().to_ascii_lowercase();
1884        if t.is_empty() {
1885            continue;
1886        }
1887        match t.as_str() {
1888            "all" => {
1889                out.extend([1, 2, 3]);
1890            }
1891            "slope" | "1" => out.push(1),
1892            "curvature" | "2" => out.push(2),
1893            "curvature-change" | "curvature_change" | "3" => out.push(3),
1894            _ => {
1895                return Err(FormulaDslError::InvalidArgument {
1896                    reason: format!(
1897                        "invalid linkwiggle penalty_order '{t}'; use all|slope|curvature|curvature-change or 1/2/3"
1898                    ),
1899                }
1900                .into());
1901            }
1902        }
1903    }
1904    if out.is_empty() {
1905        out.extend(WigglePenaltyConfig::cubic_triple_operator_default().penalty_orders);
1906    }
1907    out.sort_unstable();
1908    out.dedup();
1909    Ok(out)
1910}
1911
1912pub fn parse_linkwiggle_formulaspec(
1913    options: &BTreeMap<String, String>,
1914    raw: &str,
1915) -> Result<LinkWiggleFormulaSpec, String> {
1916    let allowed = [
1917        "degree",
1918        "internal_knots",
1919        "penalty_order",
1920        "double_penalty",
1921    ];
1922    let unknown = options
1923        .keys()
1924        .filter(|key| !allowed.contains(&key.as_str()))
1925        .cloned()
1926        .collect::<Vec<_>>();
1927    let term_name = raw.split('(').next().unwrap_or("linkwiggle");
1928    if !unknown.is_empty() {
1929        return Err(FormulaDslError::InvalidArgument {
1930            reason: format!(
1931                "{}() does not support option(s) {}: {raw}",
1932                term_name,
1933                unknown.join(", ")
1934            ),
1935        }
1936        .into());
1937    }
1938    let defaults = WigglePenaltyConfig::cubic_triple_operator_default();
1939    // Strict parsing: a present-but-unparseable value (`degree=abc`, `=-3`,
1940    // `=6.5`) must be rejected, not silently dropped and replaced by the
1941    // default as the lossy `option_usize`/`option_bool` readers would do.
1942    //
1943    // This parser is shared by *all* wiggle grammars: `linkwiggle` and
1944    // `timewiggle` (see `parse_formula`), the standard-model flexible-link
1945    // wiggle, and the marginal-slope score-warp / link-deviation routing.
1946    // The general monotone I-spline value basis (`monotone_wiggle_*` in
1947    // `families::gamlss`, used by `timewiggle` and the location-scale survival
1948    // path) honors arbitrary `degree >= 2`, while only the cubic-only
1949    // score-warp / link-deviation `DeviationRuntime` is restricted to 3.
1950    // A consumer-specific limit therefore must NOT be baked into this shared
1951    // parser — it is enforced at the routing layer that feeds the cubic-only
1952    // runtime (`deviation_block_config_from_formula_linkwiggle`). Here we only
1953    // enforce the universal lower bound that a polynomial degree is positive.
1954    let degree = option_usize_strict(options, "degree")?.unwrap_or(defaults.degree);
1955    if degree < 1 {
1956        return Err(FormulaDslError::InvalidArgument {
1957            reason: format!("{term_name}() requires degree >= 1: {raw}"),
1958        }
1959        .into());
1960    }
1961    let num_internal_knots =
1962        option_usize_strict(options, "internal_knots")?.unwrap_or(defaults.num_internal_knots);
1963    if num_internal_knots == 0 {
1964        return Err(FormulaDslError::InvalidArgument {
1965            reason: format!("{term_name}() requires internal_knots > 0: {raw}"),
1966        }
1967        .into());
1968    }
1969    let penalty_orders =
1970        parse_linkwiggle_penalty_orders(options.get("penalty_order").map(String::as_str))?;
1971    let double_penalty =
1972        option_bool_strict(options, "double_penalty")?.unwrap_or(defaults.double_penalty);
1973    Ok(LinkWiggleFormulaSpec {
1974        degree,
1975        num_internal_knots,
1976        penalty_orders,
1977        double_penalty,
1978    })
1979}
1980
1981fn parse_link_formulaspec(
1982    options: &BTreeMap<String, String>,
1983    raw: &str,
1984) -> Result<LinkFormulaSpec, String> {
1985    let link = options
1986        .get("type")
1987        .map(|s| s.trim().to_string())
1988        .ok_or_else(|| FormulaDslError::MalformedConfig {
1989            reason: format!("link() requires type=<link-name>: {raw}"),
1990        })?;
1991    if link.is_empty() {
1992        return Err(FormulaDslError::MalformedConfig {
1993            reason: format!("link() requires a non-empty type: {raw}"),
1994        }
1995        .into());
1996    }
1997    let mixture_rho = options.get("rho").map(|s| s.trim().to_string());
1998    let sas_init = options.get("sas_init").map(|s| s.trim().to_string());
1999    let beta_logistic_init = options
2000        .get("beta_logistic_init")
2001        .map(|s| s.trim().to_string());
2002    Ok(LinkFormulaSpec {
2003        link,
2004        mixture_rho,
2005        sas_init,
2006        beta_logistic_init,
2007    })
2008}
2009
2010fn parse_survival_formulaspec(
2011    options: &BTreeMap<String, String>,
2012    raw: &str,
2013) -> Result<SurvivalFormulaSpec, String> {
2014    if options.is_empty() {
2015        return Err(FormulaDslError::MalformedConfig {
2016            reason: format!(
2017                "survmodel() requires at least one named option (e.g., spec=..., distribution=...): {raw}"
2018            ),
2019        }
2020        .into());
2021    }
2022    Ok(SurvivalFormulaSpec {
2023        spec: options.get("spec").map(|s| s.trim().to_string()),
2024        survival_distribution: options.get("distribution").map(|s| s.trim().to_string()),
2025    })
2026}
2027
2028fn parse_bounded_priorspec(
2029    options: &BTreeMap<String, String>,
2030    min: f64,
2031    max: f64,
2032    raw: &str,
2033) -> Result<BoundedCoefficientPriorSpec, String> {
2034    let prior_mode = options.get("prior").map(|s| s.to_ascii_lowercase());
2035    let pull = options.get("pull").map(|s| s.to_ascii_lowercase());
2036    let target = parse_optional_f64_option(options, "target", raw)?;
2037    let strength = parse_optional_f64_option(options, "strength", raw)?;
2038
2039    let target_mode = target.is_some() || strength.is_some();
2040    if prior_mode.is_some() && pull.is_some() {
2041        return Err(FormulaDslError::IncompatibleTerm {
2042            reason: format!("bounded() cannot combine prior=... with pull=...: {raw}"),
2043        }
2044        .into());
2045    }
2046    if prior_mode.is_some() && target_mode {
2047        return Err(FormulaDslError::IncompatibleTerm {
2048            reason: format!("bounded() cannot combine prior=... with target/strength: {raw}"),
2049        }
2050        .into());
2051    }
2052    if pull.is_some() && target_mode {
2053        return Err(FormulaDslError::IncompatibleTerm {
2054            reason: format!("bounded() cannot combine pull=... with target/strength: {raw}"),
2055        }
2056        .into());
2057    }
2058
2059    if let Some(priorname) = prior_mode {
2060        return match priorname.as_str() {
2061            "none" => Ok(BoundedCoefficientPriorSpec::None),
2062            "uniform" | "log-jacobian" | "log_jacobian" | "jacobian" => {
2063                Ok(BoundedCoefficientPriorSpec::Uniform)
2064            }
2065            "center" => Ok(BoundedCoefficientPriorSpec::Beta { a: 2.0, b: 2.0 }),
2066            _ => Err(FormulaDslError::InvalidArgument {
2067                reason: format!(
2068                    "bounded() prior must currently be one of none|uniform|log-jacobian|center, got '{}': {raw}",
2069                    priorname
2070                ),
2071            }
2072            .into()),
2073        };
2074    }
2075
2076    if let Some(pull_mode) = pull {
2077        return match pull_mode.as_str() {
2078            "uniform" | "log-jacobian" | "log_jacobian" | "jacobian" => {
2079                Ok(BoundedCoefficientPriorSpec::Uniform)
2080            }
2081            "center" => Ok(BoundedCoefficientPriorSpec::Beta { a: 2.0, b: 2.0 }),
2082            _ => Err(FormulaDslError::InvalidArgument {
2083                reason: format!(
2084                    "bounded() pull must currently be 'uniform'/'log-jacobian' or 'center', got '{}': {raw}",
2085                    pull_mode
2086                ),
2087            }
2088            .into()),
2089        };
2090    }
2091
2092    if target_mode {
2093        let targetvalue = target.ok_or_else(|| FormulaDslError::MalformedConfig {
2094            reason: format!("bounded() target is required with strength: {raw}"),
2095        })?;
2096        let strengthvalue = strength.ok_or_else(|| FormulaDslError::MalformedConfig {
2097            reason: format!("bounded() strength is required with target: {raw}"),
2098        })?;
2099        if !(min < targetvalue && targetvalue < max) {
2100            return Err(FormulaDslError::InvalidArgument {
2101                reason: format!("bounded() target must lie strictly inside ({min}, {max}): {raw}"),
2102            }
2103            .into());
2104        }
2105        if !strengthvalue.is_finite() || strengthvalue <= 0.0 {
2106            return Err(FormulaDslError::InvalidArgument {
2107                reason: format!("bounded() strength must be finite and > 0: {raw}"),
2108            }
2109            .into());
2110        }
2111        let z = (targetvalue - min) / (max - min);
2112        let a = 1.0 + strengthvalue * z;
2113        let b = 1.0 + strengthvalue * (1.0 - z);
2114        return Ok(BoundedCoefficientPriorSpec::Beta { a, b });
2115    }
2116
2117    Ok(BoundedCoefficientPriorSpec::None)
2118}
2119
2120// ---------------------------------------------------------------------------
2121// Top-level formula and term parsers
2122// ---------------------------------------------------------------------------
2123
2124pub fn formula_rhs_text(formula: &str) -> Result<String, String> {
2125    let parsed = parse_formula_dsl(formula)?;
2126    if parsed.rhs_terms.is_empty() {
2127        return Err(FormulaDslError::ParseError {
2128            reason: "formula right-hand side cannot be empty".to_string(),
2129        }
2130        .into());
2131    }
2132    Ok(parsed.rhs_terms.join(" + "))
2133}
2134
2135/// Parsed Surv(...) response specification.
2136///
2137/// `entry` is `None` for the 2-arg right-censored shorthand
2138/// `Surv(time, event)`, which matches the R survival/mgcv default: every
2139/// subject has entry time zero. Callers materialize a zero entry column
2140/// when this is `None`.
2141pub fn parse_surv_response(
2142    lhs: &str,
2143) -> Result<Option<(Option<String>, String, String)>, FormulaDslError> {
2144    let trimmed = lhs.trim();
2145    let call = match parse_function_call(trimmed) {
2146        Ok(call) => call,
2147        Err(_) => return Ok(None),
2148    };
2149    if !call.name.eq_ignore_ascii_case("surv") {
2150        return Ok(None);
2151    }
2152    let vars = call
2153        .args
2154        .iter()
2155        .filter_map(|arg| match arg {
2156            CallArgSpec::Positional(v) => Some(v.trim().to_string()),
2157            CallArgSpec::Named { .. } => None,
2158        })
2159        .filter(|s| !s.is_empty())
2160        .collect::<Vec<_>>();
2161    match vars.len() {
2162        // Right-censored shorthand: Surv(time, event) ≡ Surv(0, time, event)
2163        // with a synthetic zero entry column. This matches R's
2164        // `survival::Surv(time, event)` default for left-truncation-free data.
2165        2 => Ok(Some((None, vars[0].clone(), vars[1].clone()))),
2166        3 => Ok(Some((
2167            Some(vars[0].clone()),
2168            vars[1].clone(),
2169            vars[2].clone(),
2170        ))),
2171        n => Err(FormulaDslError::InvalidArgument {
2172            reason: format!(
2173                "Surv(...) expects either Surv(time, event) (right-censored) or \
2174                 Surv(entry, exit, event) (left-truncated); got {n} columns"
2175            ),
2176        }),
2177    }
2178}
2179
2180/// Parsed `SurvInterval(L, R, event)` interval-censored response.
2181///
2182/// Returns `Some((left_col, right_col, event_col))` when the left-hand side is a
2183/// `SurvInterval(...)` call, `None` otherwise (so a plain `Surv(...)` or a bare
2184/// column response falls through to the other response parsers).
2185///
2186/// Interval censoring observes only a bracket `T ∈ (L, R]` — the exact event
2187/// time is never seen — and its row contribution is the survival-mass difference
2188/// `log[S(L) − S(R)]`, distinct from both the exact-event point density and the
2189/// single-sided right-censored survival. A *dedicated call name* (rather than
2190/// overloading the 3-argument `Surv(entry, exit, event)` delayed-entry form,
2191/// which is also 3-argument and semantically incompatible) is the unambiguous
2192/// DSL spelling: it mirrors flexsurv's `Surv(L, R, type="interval2")` intent
2193/// without colliding with the existing left-truncation grammar.
2194pub fn parse_surv_interval_response(
2195    lhs: &str,
2196) -> Result<Option<(String, String, String)>, FormulaDslError> {
2197    let trimmed = lhs.trim();
2198    let call = match parse_function_call(trimmed) {
2199        Ok(call) => call,
2200        Err(_) => return Ok(None),
2201    };
2202    if !call.name.eq_ignore_ascii_case("survinterval") {
2203        return Ok(None);
2204    }
2205    let vars = call
2206        .args
2207        .iter()
2208        .filter_map(|arg| match arg {
2209            CallArgSpec::Positional(v) => Some(v.trim().to_string()),
2210            CallArgSpec::Named { .. } => None,
2211        })
2212        .filter(|s| !s.is_empty())
2213        .collect::<Vec<_>>();
2214    match vars.len() {
2215        3 => Ok(Some((vars[0].clone(), vars[1].clone(), vars[2].clone()))),
2216        n => Err(FormulaDslError::InvalidArgument {
2217            reason: format!(
2218                "SurvInterval(...) expects SurvInterval(L, R, event) (interval-censored, the \
2219                 observed bracket T ∈ (L, R]); got {n} columns"
2220            ),
2221        }),
2222    }
2223}
2224
2225fn top_level_formula_separator(input: &str) -> Result<Option<usize>, String> {
2226    let mut depth = 0_i32;
2227    let mut in_single = false;
2228    let mut in_double = false;
2229
2230    for (idx, ch) in input.char_indices() {
2231        match ch {
2232            '\'' if !in_double => in_single = !in_single,
2233            '"' if !in_single => in_double = !in_double,
2234            '(' | '[' | '{' if !in_single && !in_double => depth += 1,
2235            ')' | ']' | '}' if !in_single && !in_double && depth > 0 => depth -= 1,
2236            '~' if !in_single && !in_double && depth == 0 => return Ok(Some(idx)),
2237            _ => {}
2238        }
2239    }
2240
2241    if in_single || in_double || depth != 0 {
2242        return Err(FormulaDslError::ParseError {
2243            reason: "invalid auxiliary formula syntax: unbalanced parentheses or quotes"
2244                .to_string(),
2245        }
2246        .into());
2247    }
2248    Ok(None)
2249}
2250
2251pub fn parse_matching_auxiliary_formula(
2252    formula: &str,
2253    response: &str,
2254    flag_name: &str,
2255) -> Result<(String, ParsedFormula), FormulaDslError> {
2256    let rhs = formula.trim();
2257    if top_level_formula_separator(rhs)?.is_some() {
2258        return Err(FormulaDslError::InvalidArgument {
2259            reason: format!(
2260                "{flag_name} expects only the terms after '~', not a full 'response ~ terms' formula; use {flag_name} 's(x)' instead of {flag_name} 'y ~ s(x)' (or pass '1' for an intercept-only noise model)"
2261            ),
2262        });
2263    }
2264    let parsed_formula = parse_formula(&format!("{response} ~ {rhs}"))?;
2265    Ok((rhs.to_string(), parsed_formula))
2266}
2267
2268pub fn validate_auxiliary_formula_controls(
2269    parsed_formula: &ParsedFormula,
2270    flag_name: &str,
2271) -> Result<(), String> {
2272    if parsed_formula.linkwiggle.is_some() {
2273        return Err(FormulaDslError::IncompatibleTerm {
2274            reason: format!(
2275                "linkwiggle(...) is only supported in the main formula, not {flag_name}"
2276            ),
2277        }
2278        .into());
2279    }
2280    if parsed_formula.timewiggle.is_some() {
2281        return Err(FormulaDslError::IncompatibleTerm {
2282            reason: format!(
2283                "timewiggle(...) is only supported in the main survival formula, not {flag_name}"
2284            ),
2285        }
2286        .into());
2287    }
2288    if parsed_formula.linkspec.is_some() {
2289        return Err(FormulaDslError::IncompatibleTerm {
2290            reason: format!("link(...) is only supported in the main formula, not {flag_name}"),
2291        }
2292        .into());
2293    }
2294    if parsed_formula.survivalspec.is_some() {
2295        return Err(FormulaDslError::IncompatibleTerm {
2296            reason: format!(
2297                "survmodel(...) is only supported in the main survival formula, not {flag_name}"
2298            ),
2299        }
2300        .into());
2301    }
2302    if !parsed_formula.logslope_surfaces.is_empty() && flag_name != "--logslope-formula" {
2303        return Err(FormulaDslError::IncompatibleTerm {
2304            reason: format!(
2305                "logslope(...) is only supported in --logslope-formula, not {flag_name}"
2306            ),
2307        }
2308        .into());
2309    }
2310    Ok::<(), _>(())
2311}
2312
2313pub fn parse_formula(formula: &str) -> Result<ParsedFormula, FormulaDslError> {
2314    let parsed_dsl =
2315        parse_formula_dsl(formula).map_err(|reason| FormulaDslError::ParseError { reason })?;
2316    let lhs = parsed_dsl.response_expr.trim();
2317    if lhs.is_empty() {
2318        return Err(FormulaDslError::ParseError {
2319            reason: "formula response (left-hand side) cannot be empty".to_string(),
2320        });
2321    }
2322    let mut terms = Vec::<ParsedTerm>::new();
2323    let mut linkwiggle: Option<LinkWiggleFormulaSpec> = None;
2324    let mut timewiggle: Option<LinkWiggleFormulaSpec> = None;
2325    let mut linkspec: Option<LinkFormulaSpec> = None;
2326    let mut survivalspec: Option<SurvivalFormulaSpec> = None;
2327    let mut logslope_surfaces = Vec::<LogSlopeSurfaceSpec>::new();
2328    // Track seen-term-keys so we can reject exact duplicates like
2329    // `y ~ smooth(x) + smooth(x)` upfront — without this the duplicate
2330    // produces a rank-deficient design and the user has no idea why their
2331    // fit is over-parameterized.
2332    let mut seen_term_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2333    let mut expanded_terms = Vec::<String>::new();
2334    for raw in parsed_dsl.rhs_terms {
2335        let trimmed = raw.trim();
2336        if trimmed.is_empty() {
2337            expanded_terms.push(String::new());
2338            continue;
2339        }
2340        // Single function-call terms (smooths, group(), etc.) are opaque to
2341        // WR expansion; pass them through verbatim so parse_term sees the
2342        // exact source string. Bare identifiers and any term mentioning
2343        // `:`, `*`, `/`, `^` are routed through the AST-driven WR expander.
2344        let is_call = parse_function_call(trimmed).is_ok();
2345        let needs_expansion = !is_call
2346            && trimmed
2347                .chars()
2348                .scan(0i32, |depth, ch| {
2349                    let d_before = *depth;
2350                    match ch {
2351                        '(' | '[' | '{' => *depth += 1,
2352                        ')' | ']' | '}' if *depth > 0 => *depth -= 1,
2353                        _ => {}
2354                    }
2355                    Some((d_before, ch))
2356                })
2357                .any(|(d, ch)| d == 0 && matches!(ch, ':' | '*' | '/' | '^'));
2358        if needs_expansion {
2359            for atoms in
2360                expand_wr_term(trimmed).map_err(|reason| FormulaDslError::ParseError { reason })?
2361            {
2362                if atoms.is_empty() {
2363                    continue;
2364                }
2365                expanded_terms.push(atoms.join(":"));
2366            }
2367        } else {
2368            expanded_terms.push(trimmed.to_string());
2369        }
2370    }
2371
2372    for raw in expanded_terms {
2373        let t = raw.trim();
2374        if t.is_empty() || t == "1" {
2375            continue;
2376        }
2377        if t == "0" || t == "-1" {
2378            return Err(FormulaDslError::IncompatibleTerm {
2379                reason: "formula terms '0'/'-1' (intercept removal) are not supported yet"
2380                    .to_string(),
2381            });
2382        }
2383        // Normalize whitespace so `smooth(x)` and `smooth( x )` match,
2384        // but preserve whitespace inside string literals so that
2385        // `bs="a b"` and `bs="ab"` do not collide.
2386        let key: String = {
2387            let mut acc = String::with_capacity(t.len());
2388            let mut in_single = false;
2389            let mut in_double = false;
2390            for ch in t.chars() {
2391                match ch {
2392                    '\'' if !in_double => {
2393                        in_single = !in_single;
2394                        acc.push(ch);
2395                    }
2396                    '"' if !in_single => {
2397                        in_double = !in_double;
2398                        acc.push(ch);
2399                    }
2400                    c if c.is_whitespace() && !in_single && !in_double => {}
2401                    _ => acc.push(ch),
2402                }
2403            }
2404            acc
2405        };
2406        if !seen_term_keys.insert(key.clone()) {
2407            return Err(FormulaDslError::IncompatibleTerm {
2408                reason: format!(
2409                    "formula `{formula}` lists term `{t}` more than once. \
2410                     Duplicate terms produce a rank-deficient design; \
2411                     keep one copy or differentiate them (e.g. distinct k=, bs= options)."
2412                ),
2413            });
2414        }
2415        match parse_term(t)? {
2416            ParsedTerm::LinkWiggle { options } => {
2417                if linkwiggle.is_some() {
2418                    return Err(FormulaDslError::IncompatibleTerm {
2419                        reason: "formula can include at most one linkwiggle(...) term".to_string(),
2420                    });
2421                }
2422                linkwiggle = Some(parse_linkwiggle_formulaspec(&options, t)?);
2423            }
2424            ParsedTerm::TimeWiggle { options } => {
2425                if timewiggle.is_some() {
2426                    return Err(FormulaDslError::IncompatibleTerm {
2427                        reason: "formula can include at most one timewiggle(...) term".to_string(),
2428                    });
2429                }
2430                timewiggle = Some(parse_linkwiggle_formulaspec(&options, t)?);
2431            }
2432            ParsedTerm::LinkConfig { options } => {
2433                if linkspec.is_some() {
2434                    return Err(FormulaDslError::IncompatibleTerm {
2435                        reason: "formula can include at most one link(...) term".to_string(),
2436                    });
2437                }
2438                linkspec = Some(parse_link_formulaspec(&options, t)?);
2439            }
2440            ParsedTerm::SurvivalConfig { options } => {
2441                if survivalspec.is_some() {
2442                    return Err(FormulaDslError::IncompatibleTerm {
2443                        reason: "formula can include at most one survmodel(...) term".to_string(),
2444                    });
2445                }
2446                survivalspec = Some(parse_survival_formulaspec(&options, t)?);
2447            }
2448            ParsedTerm::LogSlopeSurface { z_column, terms } => {
2449                logslope_surfaces.push(LogSlopeSurfaceSpec { z_column, terms });
2450            }
2451            other => terms.push(other),
2452        }
2453    }
2454    // Reject self-referential formulas like `y ~ smooth(y)` or `y ~ y`: the
2455    // response is its own predictor, which is a trivial identity fit and
2456    // almost certainly a user mistake. Only flag the simple-identifier case
2457    // (so Surv(entry, exit, event) ~ smooth(time) is left alone — the
2458    // response is the Surv triple, not the bare "time" column).
2459    if lhs.chars().all(|c| c.is_alphanumeric() || c == '_')
2460        && !lhs.is_empty()
2461        && parsed_terms_reference_column(&terms, lhs)
2462    {
2463        return Err(FormulaDslError::IncompatibleTerm {
2464            reason: format!(
2465                "formula `{formula}` uses response column `{lhs}` as its own predictor. \
2466                 This fits y as a function of itself and is almost certainly a typo. \
2467                 Drop the term that mentions `{lhs}` from the right-hand side."
2468            ),
2469        });
2470    }
2471    Ok(ParsedFormula {
2472        response: lhs.to_string(),
2473        terms,
2474        logslope_surfaces,
2475        linkwiggle,
2476        timewiggle,
2477        linkspec,
2478        survivalspec,
2479    })
2480}
2481
2482pub fn parse_term(raw: &str) -> Result<ParsedTerm, String> {
2483    fn split_call_args(call: &FunctionCallSpec) -> (Vec<String>, BTreeMap<String, String>) {
2484        let mut vars = Vec::<String>::new();
2485        let mut options = BTreeMap::<String, String>::new();
2486        for arg in &call.args {
2487            match arg {
2488                CallArgSpec::Positional(v) => vars.push(v.trim().to_string()),
2489                CallArgSpec::Named { key, value } => {
2490                    options.insert(key.to_ascii_lowercase(), strip_quotes(value).to_string());
2491                }
2492            }
2493        }
2494        (vars, options)
2495    }
2496
2497    // Wilkinson-Rogers `:` interaction term. The expander in `parse_formula`
2498    // produces `a:b[:c...]` for these; parse_term is also reached directly
2499    // from tests, so handle the syntax here as well.
2500    if raw.contains(':')
2501        && !raw.contains('(')
2502        && raw.split(':').all(|piece| is_exact_ident(piece.trim()))
2503    {
2504        let vars: Vec<String> = raw
2505            .split(':')
2506            .map(|piece| piece.trim().to_string())
2507            .collect();
2508        if vars.len() >= 2 {
2509            let mut sorted = vars.clone();
2510            sorted.sort();
2511            sorted.dedup();
2512            if sorted.len() != vars.len() {
2513                return Err(FormulaDslError::IncompatibleTerm {
2514                    reason: format!(
2515                        "interaction term `{raw}` references the same variable more than once"
2516                    ),
2517                }
2518                .into());
2519            }
2520            return Ok(ParsedTerm::Interaction { vars: sorted });
2521        }
2522    }
2523
2524    let call = parse_function_call(raw).ok();
2525    if let Some(call) = call {
2526        let name = call.name.to_ascii_lowercase();
2527        let (vars, mut options) = split_call_args(&call);
2528        match name.as_str() {
2529            "constrain" | "constraint" | "box" => {
2530                if vars.len() != 1 {
2531                    return Err(FormulaDslError::InvalidArgument {
2532                        reason: format!(
2533                            "constrain()/constraint()/box() expects exactly one variable: {raw}"
2534                        ),
2535                    }
2536                    .into());
2537                }
2538                validate_known_term_options(
2539                    "constrain",
2540                    &options,
2541                    &["min", "lower", "max", "upper"],
2542                    raw,
2543                )?;
2544                let (coefficient_min, coefficient_max) =
2545                    parse_linear_constraint_bounds(&options, raw)?;
2546                if coefficient_min.is_none() && coefficient_max.is_none() {
2547                    return Err(FormulaDslError::MalformedConfig {
2548                        reason: format!(
2549                            "constrain()/constraint()/box() requires at least one of min/lower/max/upper: {raw}"
2550                        ),
2551                    }
2552                    .into());
2553                }
2554                return Ok(ParsedTerm::Linear {
2555                    name: vars[0].clone(),
2556                    explicit: true,
2557                    coefficient_min,
2558                    coefficient_max,
2559                });
2560            }
2561            "nonnegative" | "nonnegative_coef" => {
2562                if vars.len() != 1 {
2563                    return Err(FormulaDslError::InvalidArgument {
2564                        reason: format!("nonnegative() expects exactly one variable: {raw}"),
2565                    }
2566                    .into());
2567                }
2568                validate_known_term_options("nonnegative", &options, &[], raw)?;
2569                return Ok(ParsedTerm::Linear {
2570                    name: vars[0].clone(),
2571                    explicit: true,
2572                    coefficient_min: Some(0.0),
2573                    coefficient_max: None,
2574                });
2575            }
2576            "nonpositive" | "nonpositive_coef" => {
2577                if vars.len() != 1 {
2578                    return Err(FormulaDslError::InvalidArgument {
2579                        reason: format!("nonpositive() expects exactly one variable: {raw}"),
2580                    }
2581                    .into());
2582                }
2583                validate_known_term_options("nonpositive", &options, &[], raw)?;
2584                return Ok(ParsedTerm::Linear {
2585                    name: vars[0].clone(),
2586                    explicit: true,
2587                    coefficient_min: None,
2588                    coefficient_max: Some(0.0),
2589                });
2590            }
2591            "bounded" => {
2592                if vars.len() != 1 {
2593                    return Err(FormulaDslError::InvalidArgument {
2594                        reason: format!("bounded() expects exactly one variable: {raw}"),
2595                    }
2596                    .into());
2597                }
2598                validate_known_term_options(
2599                    "bounded",
2600                    &options,
2601                    &["min", "max", "prior", "pull", "target", "strength"],
2602                    raw,
2603                )?;
2604                let min = parse_required_f64_option(&options, "min", raw)?;
2605                let max = parse_required_f64_option(&options, "max", raw)?;
2606                if !min.is_finite() || !max.is_finite() || min >= max {
2607                    return Err(FormulaDslError::InvalidArgument {
2608                        reason: format!(
2609                            "bounded() requires finite min < max, got min={min}, max={max}: {raw}"
2610                        ),
2611                    }
2612                    .into());
2613                }
2614                let prior = parse_bounded_priorspec(&options, min, max, raw)?;
2615                return Ok(ParsedTerm::BoundedLinear {
2616                    name: vars[0].clone(),
2617                    min,
2618                    max,
2619                    prior,
2620                });
2621            }
2622            "group" | "re" | "factor" => {
2623                if vars.len() != 1 {
2624                    return Err(FormulaDslError::InvalidArgument {
2625                        reason: format!(
2626                            "{name}() expects exactly one variable, got '{}': {raw}",
2627                            vars.join(",")
2628                        ),
2629                    }
2630                    .into());
2631                }
2632                // `factor(g)` is a FIXED categorical factor (R `factor()` /
2633                // patsy `C()`): it forces categorical encoding of the column
2634                // but, like a bare `+ g` main effect, is strict on unseen
2635                // levels. `group(g)`/`re(g)` are genuine random effects that
2636                // shrink a held-out group to the population mean, so they
2637                // tolerate unseen levels. Both share the penalized-categorical
2638                // block; only the unseen policy differs (#2137/#2102).
2639                let lenient_unseen = name != "factor";
2640                return Ok(ParsedTerm::RandomEffect {
2641                    name: vars[0].clone(),
2642                    lenient_unseen,
2643                });
2644            }
2645            "tensor" | "interaction" | "te" => {
2646                if vars.len() < 2 {
2647                    return Err(FormulaDslError::InvalidArgument {
2648                        reason: format!(
2649                            "tensor()/interaction()/te() requires at least two variables: {raw}"
2650                        ),
2651                    }
2652                    .into());
2653                }
2654                return Ok(ParsedTerm::Smooth {
2655                    label: raw.to_string(),
2656                    vars,
2657                    kind: SmoothKind::Te,
2658                    options,
2659                });
2660            }
2661            "t2" => {
2662                if vars.len() < 2 {
2663                    return Err(FormulaDslError::InvalidArgument {
2664                        reason: format!("t2() requires at least two variables: {raw}"),
2665                    }
2666                    .into());
2667                }
2668                return Ok(ParsedTerm::Smooth {
2669                    label: raw.to_string(),
2670                    vars,
2671                    kind: SmoothKind::T2,
2672                    options,
2673                });
2674            }
2675            "ti" => {
2676                // Tensor interaction smooth (mgcv `ti`): structurally a
2677                // tensor-product smooth, but the marginal main effects are
2678                // excluded so only the pure interaction is modeled. Shares the
2679                // tensor materialization path with `te`; the distinct
2680                // `SmoothKind::Ti` drives per-margin sum-to-zero
2681                // identifiability in the term builder.
2682                if vars.len() < 2 {
2683                    return Err(FormulaDslError::InvalidArgument {
2684                        reason: format!("ti() requires at least two variables: {raw}"),
2685                    }
2686                    .into());
2687                }
2688                return Ok(ParsedTerm::Smooth {
2689                    label: raw.to_string(),
2690                    vars,
2691                    kind: SmoothKind::Ti,
2692                    options,
2693                });
2694            }
2695            "fs" | "sz" => {
2696                if vars.len() != 2 {
2697                    return Err(format!("{}() expects exactly two variables: {raw}", name));
2698                }
2699                options.insert("bs".to_string(), name.clone());
2700                return Ok(ParsedTerm::Smooth {
2701                    label: raw.to_string(),
2702                    vars,
2703                    kind: SmoothKind::S,
2704                    options,
2705                });
2706            }
2707            "thinplate" | "thin_plate" | "tps" => {
2708                if vars.len() < 2 {
2709                    return Err(FormulaDslError::InvalidArgument {
2710                        reason: format!(
2711                            "thinplate()/thin_plate()/tps() requires at least two variables: {raw}"
2712                        ),
2713                    }
2714                    .into());
2715                }
2716                options.insert("type".to_string(), "tps".to_string());
2717                return Ok(ParsedTerm::Smooth {
2718                    label: raw.to_string(),
2719                    vars,
2720                    kind: SmoothKind::S,
2721                    options,
2722                });
2723            }
2724            "smooth" | "s" | "cyclic" | "periodic" | "cc" | "cp" => {
2725                if vars.is_empty() {
2726                    return Err(FormulaDslError::InvalidArgument {
2727                        reason: format!("smooth()/s() requires at least one variable: {raw}"),
2728                    }
2729                    .into());
2730                }
2731                // mgcv idiom: `s(g, bs='re')` with a single variable is a
2732                // random intercept on the factor `g`. Route it to the
2733                // dedicated random-effect machinery (which expects a single
2734                // categorical column) rather than to the factor-smooth path
2735                // (which requires a numeric companion).
2736                let bs_is_re = options
2737                    .get("bs")
2738                    .or_else(|| options.get("type"))
2739                    .map(|v| {
2740                        v.trim()
2741                            .trim_matches(|c| c == '\'' || c == '"')
2742                            .to_ascii_lowercase()
2743                    })
2744                    .as_deref()
2745                    == Some("re");
2746                if bs_is_re && vars.len() == 1 {
2747                    // `s(g, bs="re")` is a genuine random effect: lenient on
2748                    // unseen levels (held-out group → population mean).
2749                    return Ok(ParsedTerm::RandomEffect {
2750                        name: vars[0].clone(),
2751                        lenient_unseen: true,
2752                    });
2753                }
2754                if matches!(name.as_str(), "cyclic" | "periodic" | "cc" | "cp") {
2755                    options.insert("type".to_string(), "cyclic".to_string());
2756                }
2757                if matches!(name.as_str(), "fs" | "sz") {
2758                    options.insert("bs".to_string(), name.clone());
2759                }
2760                return Ok(ParsedTerm::Smooth {
2761                    label: raw.to_string(),
2762                    vars,
2763                    kind: SmoothKind::S,
2764                    options,
2765                });
2766            }
2767            "sphere" | "sos" | "spherical" | "s2" => {
2768                // `s2()` is an alias for the intrinsic S² (sphere) smooth, just
2769                // like `sphere()`/`sos()`/`spherical()`. All four share the
2770                // Wahba/harmonic sphere basis, so they must dispatch through
2771                // the identical `type=sphere` route; otherwise `s2()` would
2772                // silently fall back to a generic Euclidean 2-D smooth over
2773                // (lat, lon) and diverge in the spatial-kappa optimizer.
2774                if vars.len() != 2 {
2775                    return Err(FormulaDslError::InvalidArgument {
2776                        reason: format!(
2777                            "{name}() expects exactly two variables: latitude and longitude; got {} in {raw}",
2778                            vars.len()
2779                        ),
2780                    }
2781                    .into());
2782                }
2783                options.insert("type".to_string(), "sphere".to_string());
2784                return Ok(ParsedTerm::Smooth {
2785                    label: raw.to_string(),
2786                    vars,
2787                    kind: SmoothKind::S,
2788                    options,
2789                });
2790            }
2791            "mjs" | "measurejet" | "measure_jet" | "web" => {
2792                // Measure-jet spline smooth (`basis::measure_jet_smooth` docs)
2793                // for responses varying along an unknown low-dimensional set
2794                // inside a higher-dimensional ambient space. All aliases
2795                // dispatch through the identical `type=measurejet` route,
2796                // mirroring the sphere/curvature alias rule.
2797                if vars.is_empty() {
2798                    return Err(FormulaDslError::InvalidArgument {
2799                        reason: format!("{name}() requires at least one variable: {raw}"),
2800                    }
2801                    .into());
2802                }
2803                options.insert("type".to_string(), "measurejet".to_string());
2804                return Ok(ParsedTerm::Smooth {
2805                    label: raw.to_string(),
2806                    vars,
2807                    kind: SmoothKind::S,
2808                    options,
2809                });
2810            }
2811            "curv" | "curvature" | "constant_curvature" | "mkappa" => {
2812                // Constant-curvature (M_κ) geodesic-kernel smooth (#944): the
2813                // κ-generic sibling of sphere()/s2(), interpolating
2814                // S^d → ℝ^d → H^d through `kappa=` (default 0 = flat). All
2815                // four aliases must dispatch through the identical
2816                // `type=curvature` route, mirroring the sphere alias rule.
2817                if vars.is_empty() {
2818                    return Err(FormulaDslError::InvalidArgument {
2819                        reason: format!("{name}() requires at least one variable: {raw}"),
2820                    }
2821                    .into());
2822                }
2823                options.insert("type".to_string(), "curvature".to_string());
2824                return Ok(ParsedTerm::Smooth {
2825                    label: raw.to_string(),
2826                    vars,
2827                    kind: SmoothKind::S,
2828                    options,
2829                });
2830            }
2831            "matern" => {
2832                if vars.is_empty() {
2833                    return Err(FormulaDslError::InvalidArgument {
2834                        reason: format!("matern() requires at least one variable: {raw}"),
2835                    }
2836                    .into());
2837                }
2838                options.insert("type".to_string(), "matern".to_string());
2839                return Ok(ParsedTerm::Smooth {
2840                    label: raw.to_string(),
2841                    vars,
2842                    kind: SmoothKind::S,
2843                    options,
2844                });
2845            }
2846            "duchon" => {
2847                if vars.is_empty() {
2848                    return Err(FormulaDslError::InvalidArgument {
2849                        reason: format!("duchon() requires at least one variable: {raw}"),
2850                    }
2851                    .into());
2852                }
2853                if option_bool(&options, "cyclic").unwrap_or(false)
2854                    || option_bool(&options, "periodic").unwrap_or(false)
2855                {
2856                    options.insert("cyclic".to_string(), "true".to_string());
2857                }
2858                options.insert("type".to_string(), "duchon".to_string());
2859                return Ok(ParsedTerm::Smooth {
2860                    label: raw.to_string(),
2861                    vars,
2862                    kind: SmoothKind::S,
2863                    options,
2864                });
2865            }
2866            "pca" => {
2867                if vars.is_empty() {
2868                    return Err(FormulaDslError::InvalidArgument {
2869                        reason: format!("pca() requires at least one variable: {raw}"),
2870                    }
2871                    .into());
2872                }
2873                options.insert("type".to_string(), "pca".to_string());
2874                return Ok(ParsedTerm::Smooth {
2875                    label: raw.to_string(),
2876                    vars,
2877                    kind: SmoothKind::S,
2878                    options,
2879                });
2880            }
2881            "linkwiggle" => {
2882                if !vars.is_empty() {
2883                    return Err(FormulaDslError::InvalidArgument {
2884                        reason: format!(
2885                            "linkwiggle() takes named options only; positional args are not supported: {raw}"
2886                        ),
2887                    }
2888                    .into());
2889                }
2890                return Ok(ParsedTerm::LinkWiggle { options });
2891            }
2892            "timewiggle" => {
2893                if !vars.is_empty() {
2894                    return Err(FormulaDslError::InvalidArgument {
2895                        reason: format!(
2896                            "timewiggle() takes named options only; positional args are not supported: {raw}"
2897                        ),
2898                    }
2899                    .into());
2900                }
2901                return Ok(ParsedTerm::TimeWiggle { options });
2902            }
2903            "link" => {
2904                if !vars.is_empty() {
2905                    return Err(FormulaDslError::InvalidArgument {
2906                        reason: format!(
2907                            "link() takes named options only; positional args are not supported: {raw}"
2908                        ),
2909                    }
2910                    .into());
2911                }
2912                return Ok(ParsedTerm::LinkConfig { options });
2913            }
2914            "survmodel" => {
2915                if !vars.is_empty() {
2916                    return Err(FormulaDslError::InvalidArgument {
2917                        reason: format!(
2918                            "survmodel() takes named options only; positional args are not supported: {raw}"
2919                        ),
2920                    }
2921                    .into());
2922                }
2923                return Ok(ParsedTerm::SurvivalConfig { options });
2924            }
2925            "logslope" | "log_slope" | "log_slope_surface" => {
2926                validate_known_term_options("logslope", &options, &[], raw)?;
2927                if vars.len() < 2 {
2928                    return Err(FormulaDslError::InvalidArgument {
2929                        reason: format!(
2930                            "logslope() expects a z column followed by one or more RHS terms; add one logslope(z, ...) declaration per vector-z coordinate: {raw}"
2931                        ),
2932                    }
2933                    .into());
2934                }
2935                let z_column = vars[0].trim();
2936                if !is_exact_ident(z_column) {
2937                    return Err(FormulaDslError::InvalidArgument {
2938                        reason: format!(
2939                            "logslope() z column must be a bare column name, got `{z_column}` in {raw}"
2940                        ),
2941                    }
2942                    .into());
2943                }
2944                let rhs = vars[1..].join(" + ");
2945                let parsed = parse_formula(&format!("__logslope__ ~ {rhs}"))?;
2946                if !parsed.logslope_surfaces.is_empty() {
2947                    return Err(FormulaDslError::IncompatibleTerm {
2948                        reason: format!(
2949                            "logslope() declarations cannot be nested inside another logslope(): {raw}"
2950                        ),
2951                    }
2952                    .into());
2953                }
2954                validate_auxiliary_formula_controls(&parsed, "logslope()")?;
2955                return Ok(ParsedTerm::LogSlopeSurface {
2956                    z_column: z_column.to_string(),
2957                    terms: parsed.terms,
2958                });
2959            }
2960            "linear" => {
2961                if vars.len() != 1 {
2962                    return Err(FormulaDslError::InvalidArgument {
2963                        reason: format!("linear() expects exactly one variable: {raw}"),
2964                    }
2965                    .into());
2966                }
2967                validate_known_term_options(
2968                    "linear",
2969                    &options,
2970                    &["min", "lower", "max", "upper"],
2971                    raw,
2972                )?;
2973                let (coefficient_min, coefficient_max) =
2974                    parse_linear_constraint_bounds(&options, raw)?;
2975                return Ok(ParsedTerm::Linear {
2976                    name: vars[0].clone(),
2977                    explicit: true,
2978                    coefficient_min,
2979                    coefficient_max,
2980                });
2981            }
2982            _ => {
2983                return Err(format!(
2984                    "unknown term function `{name}` in '{raw}'. Supported: bounded(), linear(), constrain()/constraint()/box(), nonnegative(), nonpositive(), smooth()/s(), cyclic()/periodic()/cc()/cp(), thinplate()/thin_plate()/tps(), tensor()/interaction()/te(), t2(), ti(), fs(), sz(), group()/re()/factor(), sphere()/sos()/spherical(), s2(), matern(), duchon(), pca(), logslope()/log_slope(), linkwiggle(), timewiggle(), link(), survmodel()"
2985                ));
2986            }
2987        }
2988    }
2989
2990    let ident = raw.trim();
2991    if !is_exact_ident(ident) {
2992        return Err(FormulaDslError::UnknownIdentifier {
2993            reason: format!("unsupported top-level RHS term: {raw}"),
2994        }
2995        .into());
2996    }
2997
2998    Ok(ParsedTerm::Linear {
2999        name: ident.to_string(),
3000        explicit: false,
3001        coefficient_min: None,
3002        coefficient_max: None,
3003    })
3004}
3005
3006// ---------------------------------------------------------------------------
3007// Link choice parsing
3008// ---------------------------------------------------------------------------
3009
3010pub fn parse_link_choice(
3011    raw: Option<&str>,
3012    flexible_flag: bool,
3013) -> Result<Option<LinkChoice>, FormulaDslError> {
3014    if raw.is_none() && !flexible_flag {
3015        return Ok(None);
3016    }
3017    let Some(v) = raw else {
3018        return Ok(Some(LinkChoice {
3019            mode: LinkMode::Flexible,
3020            link: LinkFunction::Probit,
3021            mixture_components: None,
3022        }));
3023    };
3024    let t = v.trim().to_ascii_lowercase();
3025    if let Some(inner) = t
3026        .strip_prefix("flexible(")
3027        .and_then(|s| s.strip_suffix(')'))
3028    {
3029        if let Some(components_inner) = inner
3030            .strip_prefix("blended(")
3031            .and_then(|s| s.strip_suffix(')'))
3032            .or_else(|| {
3033                inner
3034                    .strip_prefix("mixture(")
3035                    .and_then(|s| s.strip_suffix(')'))
3036            })
3037        {
3038            parse_link_component_list(components_inner)?;
3039            return Err(FormulaDslError::IncompatibleTerm {
3040                reason:
3041                    "flexible(...) does not support blended(...)/mixture(...) links; wiggle is only supported for jointly fit standard links"
3042                        .to_string(),
3043            });
3044        }
3045        let link = parse_linkname(inner)?;
3046        if !linkname_supports_joint_wiggle(link) {
3047            return Err(FormulaDslError::IncompatibleTerm {
3048                reason:
3049                    "flexible(...) does not support sas/beta-logistic links; wiggle is only supported for jointly fit standard links"
3050                        .to_string(),
3051            });
3052        }
3053        return Ok(Some(LinkChoice {
3054            mode: LinkMode::Flexible,
3055            link,
3056            mixture_components: None,
3057        }));
3058    }
3059    if let Some(inner) = t
3060        .strip_prefix("blended(")
3061        .and_then(|s| s.strip_suffix(')'))
3062        .or_else(|| t.strip_prefix("mixture(").and_then(|s| s.strip_suffix(')')))
3063    {
3064        if flexible_flag {
3065            return Err(FormulaDslError::IncompatibleTerm {
3066                reason:
3067                    "--flexible-link cannot be combined with --link blended(...)/mixture(...); blended inverse links are not flexible-link mode"
3068                        .to_string(),
3069            });
3070        }
3071        let components = parse_link_component_list(inner)?;
3072        return Ok(Some(LinkChoice {
3073            mode: LinkMode::Strict,
3074            link: LinkFunction::Logit,
3075            mixture_components: Some(components),
3076        }));
3077    }
3078
3079    let link = parse_linkname(&t)?;
3080    if flexible_flag && !linkname_supports_joint_wiggle(link) {
3081        return Err(FormulaDslError::IncompatibleTerm {
3082            reason:
3083                "--flexible-link does not support sas/beta-logistic links; wiggle is only supported for jointly fit standard links"
3084                    .to_string(),
3085        });
3086    }
3087    Ok(Some(LinkChoice {
3088        mode: if flexible_flag {
3089            LinkMode::Flexible
3090        } else {
3091            LinkMode::Strict
3092        },
3093        link,
3094        mixture_components: None,
3095    }))
3096}
3097
3098pub fn parse_linkname(v: &str) -> Result<LinkFunction, FormulaDslError> {
3099    match v.trim() {
3100        "identity" => Ok(LinkFunction::Identity),
3101        "log" => Ok(LinkFunction::Log),
3102        "logit" | "binomial-logit" => Ok(LinkFunction::Logit),
3103        "probit" | "binomial-probit" => Ok(LinkFunction::Probit),
3104        "cloglog" | "binomial-cloglog" => Ok(LinkFunction::CLogLog),
3105        "loglog" => Ok(LinkFunction::LogLog),
3106        "cauchit" => Ok(LinkFunction::Cauchit),
3107        "sas" => Ok(LinkFunction::Sas),
3108        "beta-logistic" => Ok(LinkFunction::BetaLogistic),
3109        other => Err(FormulaDslError::UnknownIdentifier {
3110            reason: format!(
3111                "unsupported link type '{other}'; \
3112                 use one of identity|log|logit|probit|cloglog|loglog|cauchit|binomial-logit|binomial-probit|binomial-cloglog|sas|beta-logistic|blended(...)/mixture(...) or flexible(...). \
3113                 Both `--link <type>` (CLI flag) and `link(type=<type>)` (formula term) accept the same set."
3114            ),
3115        }),
3116    }
3117}
3118
3119pub fn parse_link_component(v: &str) -> Result<LinkComponent, String> {
3120    match v.trim() {
3121        "logit" => Ok(LinkComponent::Logit),
3122        "probit" => Ok(LinkComponent::Probit),
3123        "cloglog" => Ok(LinkComponent::CLogLog),
3124        "loglog" => Ok(LinkComponent::LogLog),
3125        "cauchit" => Ok(LinkComponent::Cauchit),
3126        other => Err(FormulaDslError::UnknownIdentifier {
3127            reason: format!(
3128                "unsupported blended-link component '{other}'; use probit|logit|cloglog|loglog|cauchit"
3129            ),
3130        }
3131        .into()),
3132    }
3133}
3134
3135pub fn parse_link_component_list(v: &str) -> Result<Vec<LinkComponent>, String> {
3136    let mut out = Vec::new();
3137    for part in v.split(',') {
3138        let trimmed = part.trim();
3139        if trimmed.is_empty() {
3140            continue;
3141        }
3142        let comp = parse_link_component(trimmed)?;
3143        if out.contains(&comp) {
3144            return Err(FormulaDslError::IncompatibleTerm {
3145                reason: "blended(...) cannot contain duplicate components".to_string(),
3146            }
3147            .into());
3148        }
3149        out.push(comp);
3150    }
3151    if out.len() < 2 {
3152        return Err(FormulaDslError::InvalidArgument {
3153            reason: "blended(...) requires at least two components".to_string(),
3154        }
3155        .into());
3156    }
3157    Ok(out)
3158}