Skip to main content

gam_terms/inference/
formula_dsl.rs

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