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