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#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum FormulaDslError {
86 ParseError { reason: String },
90 UnknownIdentifier { reason: String },
94 InvalidArgument { reason: String },
97 IncompatibleTerm { reason: String },
101 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
117impl 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
176fn 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 _ => '{',
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 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
285type 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
990 fn parse_formula_supports_wr_slash_nesting() {
991 let parsed = parse_formula("y ~ x / z").expect("`/` is supported as WR nesting");
992 assert_eq!(parsed.response, "y");
993 assert_eq!(parsed.terms.len(), 2);
994 let names: Vec<String> = parsed
995 .terms
996 .iter()
997 .map(|t| match t {
998 ParsedTerm::Linear { name, .. } => format!("Linear({name})"),
999 ParsedTerm::Interaction { vars } => format!("Interaction({})", vars.join(":")),
1000 other => format!("Other({other:?})"),
1001 })
1002 .collect();
1003 assert_eq!(
1004 names,
1005 vec!["Linear(x)".to_string(), "Interaction(x:z)".to_string()]
1006 );
1007 }
1008
1009 #[test]
1014 fn parse_formula_supports_wr_star_crossing() {
1015 let parsed = parse_formula("y ~ x * z").expect("`*` is supported as WR crossing");
1016 assert_eq!(parsed.response, "y");
1017 assert_eq!(parsed.terms.len(), 3);
1018 let names: Vec<String> = parsed
1019 .terms
1020 .iter()
1021 .map(|t| match t {
1022 ParsedTerm::Linear { name, .. } => format!("Linear({name})"),
1023 ParsedTerm::Interaction { vars } => format!("Interaction({})", vars.join(":")),
1024 other => format!("Other({other:?})"),
1025 })
1026 .collect();
1027 assert_eq!(
1028 names,
1029 vec![
1030 "Linear(x)".to_string(),
1031 "Linear(z)".to_string(),
1032 "Interaction(x:z)".to_string(),
1033 ]
1034 );
1035 }
1036
1037 #[test]
1038 fn parse_formula_rejects_unary_signs_inside_wr_expansion() {
1039 for formula in ["y ~ x:-z", "y ~ a*-b", "y ~ x/-z", "y ~ x:+z"] {
1040 let err = parse_formula(formula)
1041 .expect_err("WR expansion must not silently drop unary signs");
1042 let msg = err.to_string();
1043 assert!(
1044 msg.contains("unary `+`/`-` is not supported"),
1045 "unexpected error for {formula}: {msg}"
1046 );
1047 }
1048 }
1049
1050 #[test]
1051 fn parse_formula_supports_wr_power_crossing() {
1052 let parsed = parse_formula("y ~ (x + z)^2").expect("`^` is supported as WR power");
1053 assert_eq!(parsed.response, "y");
1054 assert_eq!(parsed.terms.len(), 3);
1055 let names: Vec<String> = parsed
1056 .terms
1057 .iter()
1058 .map(|t| match t {
1059 ParsedTerm::Linear { name, .. } => format!("Linear({name})"),
1060 ParsedTerm::Interaction { vars } => format!("Interaction({})", vars.join(":")),
1061 other => format!("Other({other:?})"),
1062 })
1063 .collect();
1064 assert_eq!(
1065 names,
1066 vec![
1067 "Linear(x)".to_string(),
1068 "Linear(z)".to_string(),
1069 "Interaction(x:z)".to_string(),
1070 ]
1071 );
1072 }
1073
1074 #[test]
1075 fn parse_formula_rejects_chained_wr_power() {
1076 let err = parse_formula("y ~ (x + z)^2^3")
1077 .expect_err("chained WR powers must not silently drop later exponents");
1078 let msg = err.to_string();
1079 assert!(
1080 msg.contains("chained `^` operators are not supported"),
1081 "error should explain that chained powers are rejected, got: {msg}"
1082 );
1083 }
1084
1085 #[test]
1086 fn parsed_terms_reference_column_sees_the_by_smooth_variable() {
1087 let parsed = parse_formula("y ~ s(x, by=g)").expect("parse by-smooth");
1093 assert!(
1094 parsed_terms_reference_column(&parsed.terms, "g"),
1095 "s(x, by=g) references column g via options[\"by\"]"
1096 );
1097 assert!(parsed_terms_reference_column(&parsed.terms, "x"));
1098 assert!(!parsed_terms_reference_column(&parsed.terms, "absent"));
1099 }
1100
1101 #[test]
1102 fn marginal_slope_z_column_validator_detects_linear_and_smooth_reuse() {
1103 let main = parse_formula("y ~ x + z").expect("parse main");
1104 let logslope = parse_formula("y ~ s(z, type=duchon, centers=6)").expect("parse logslope");
1105
1106 assert!(parsed_terms_reference_column(&main.terms, "z"));
1107 assert!(parsed_terms_reference_column(&logslope.terms, "z"));
1108
1109 let err = validate_marginal_slope_z_column_exclusion(
1110 &main,
1111 &parse_formula("y ~ 1").expect("parse clean logslope"),
1112 "z",
1113 "bernoulli marginal-slope",
1114 "--logslope-formula",
1115 )
1116 .expect_err("main formula should be rejected");
1117 assert!(err.contains("cannot also appear in the main formula"));
1118
1119 let err = validate_marginal_slope_z_column_exclusion(
1120 &parse_formula("y ~ x").expect("parse clean main"),
1121 &logslope,
1122 "z",
1123 "bernoulli marginal-slope",
1124 "--logslope-formula",
1125 )
1126 .expect_err("logslope formula should be rejected");
1127 assert!(err.contains("cannot also appear in --logslope-formula"));
1128 }
1129
1130 #[test]
1131 fn logslope_surface_declarations_are_additive() {
1132 let parsed = parse_formula("y ~ s(pc1) + logslope(z2, s(pc2)) + logslope(z3, x3)")
1133 .expect("parse additive logslope surfaces");
1134 assert_eq!(parsed.terms.len(), 1);
1135 assert_eq!(parsed.logslope_surfaces.len(), 2);
1136 assert_eq!(parsed.logslope_surfaces[0].z_column, "z2");
1137 assert_eq!(parsed.logslope_surfaces[0].terms.len(), 1);
1138 assert_eq!(parsed.logslope_surfaces[1].z_column, "z3");
1139 assert_eq!(parsed.logslope_surfaces[1].terms.len(), 1);
1140 }
1141
1142 #[test]
1143 fn marginal_slope_z_column_validator_reserves_all_surface_z_columns() {
1144 let main = parse_formula("y ~ x").expect("parse main");
1145 let logslope = parse_formula("y ~ s(pc1) + logslope(z2, s(z3)) + logslope(z3, x)")
1146 .expect("parse logslope surfaces");
1147 let err = validate_marginal_slope_z_column_exclusion(
1148 &main,
1149 &logslope,
1150 "z1",
1151 "bernoulli marginal-slope",
1152 "--logslope-formula",
1153 )
1154 .expect_err("surface formula should reject another reserved z coordinate");
1155 assert!(err.contains("reserves z column 'z3'"));
1156 }
1157
1158 fn random_effect_lenient_unseen(formula: &str) -> bool {
1161 let parsed = parse_formula(formula).expect("parse random-effect formula");
1162 let re = parsed
1163 .terms
1164 .iter()
1165 .find_map(|t| match t {
1166 ParsedTerm::RandomEffect { lenient_unseen, .. } => Some(*lenient_unseen),
1167 _ => None,
1168 });
1169 re.unwrap_or_else(|| panic!("{formula} did not lower to a RandomEffect term"))
1170 }
1171
1172 #[test]
1173 fn factor_wrapper_is_strict_on_unseen_levels_while_group_re_are_lenient() {
1174 assert!(
1183 !random_effect_lenient_unseen("y ~ factor(g)"),
1184 "factor(g) is a fixed categorical factor: strict (lenient_unseen=false) on unseen levels"
1185 );
1186 for lenient in ["y ~ group(g)", "y ~ re(g)", "y ~ s(g, bs=re)"] {
1187 assert!(
1188 random_effect_lenient_unseen(lenient),
1189 "{lenient} is a genuine random effect: lenient (lenient_unseen=true) on unseen levels"
1190 );
1191 }
1192 }
1193}
1194
1195#[derive(Clone, Debug)]
1200pub struct LinkWiggleFormulaSpec {
1201 pub degree: usize,
1202 pub num_internal_knots: usize,
1203 pub penalty_orders: Vec<usize>,
1204 pub double_penalty: bool,
1205}
1206
1207pub fn default_linkwiggle_formulaspec() -> LinkWiggleFormulaSpec {
1208 let cfg = WigglePenaltyConfig::cubic_triple_operator_default();
1209 LinkWiggleFormulaSpec {
1210 degree: cfg.degree,
1211 num_internal_knots: cfg.num_internal_knots,
1212 penalty_orders: cfg.penalty_orders,
1213 double_penalty: cfg.double_penalty,
1214 }
1215}
1216
1217#[derive(Clone, Debug)]
1218pub struct LinkFormulaSpec {
1219 pub link: String,
1220 pub mixture_rho: Option<String>,
1221 pub sas_init: Option<String>,
1222 pub beta_logistic_init: Option<String>,
1223}
1224
1225#[derive(Clone, Debug)]
1226pub struct SurvivalFormulaSpec {
1227 pub spec: Option<String>,
1228 pub survival_distribution: Option<String>,
1229}
1230
1231#[derive(Clone, Debug)]
1232pub struct ParsedFormula {
1233 pub response: String,
1234 pub terms: Vec<ParsedTerm>,
1235 pub logslope_surfaces: Vec<LogSlopeSurfaceSpec>,
1236 pub linkwiggle: Option<LinkWiggleFormulaSpec>,
1237 pub timewiggle: Option<LinkWiggleFormulaSpec>,
1238 pub linkspec: Option<LinkFormulaSpec>,
1239 pub survivalspec: Option<SurvivalFormulaSpec>,
1240}
1241
1242#[derive(Clone, Debug)]
1243pub struct LogSlopeSurfaceSpec {
1244 pub z_column: String,
1245 pub terms: Vec<ParsedTerm>,
1246}
1247
1248pub fn marginal_slope_logslope_surfaces(
1249 logslope_formula: &ParsedFormula,
1250 default_z_column: &str,
1251) -> Result<Vec<LogSlopeSurfaceSpec>, String> {
1252 let mut surfaces = Vec::new();
1253 if !logslope_formula.terms.is_empty() {
1254 surfaces.push(LogSlopeSurfaceSpec {
1255 z_column: default_z_column.to_string(),
1256 terms: logslope_formula.terms.clone(),
1257 });
1258 }
1259 surfaces.extend(logslope_formula.logslope_surfaces.clone());
1260 if surfaces.is_empty() {
1261 surfaces.push(LogSlopeSurfaceSpec {
1262 z_column: default_z_column.to_string(),
1263 terms: Vec::new(),
1264 });
1265 }
1266 let mut seen = std::collections::BTreeSet::<String>::new();
1267 for surface in &surfaces {
1268 if !seen.insert(surface.z_column.clone()) {
1269 return Err(FormulaDslError::IncompatibleTerm {
1270 reason: format!(
1271 "logslope formula declares z column '{}' more than once; each z coordinate needs exactly one log-slope surface",
1272 surface.z_column
1273 ),
1274 }
1275 .into());
1276 }
1277 }
1278 Ok(surfaces)
1279}
1280
1281#[derive(Clone, Debug)]
1282pub enum ParsedTerm {
1283 Linear {
1284 name: String,
1285 explicit: bool,
1286 coefficient_min: Option<f64>,
1287 coefficient_max: Option<f64>,
1288 },
1289 BoundedLinear {
1290 name: String,
1291 min: f64,
1292 max: f64,
1293 prior: BoundedCoefficientPriorSpec,
1294 },
1295 RandomEffect {
1296 name: String,
1297 lenient_unseen: bool,
1308 },
1309 Smooth {
1310 label: String,
1311 vars: Vec<String>,
1312 kind: SmoothKind,
1313 options: BTreeMap<String, String>,
1314 },
1315 LinkWiggle {
1316 options: BTreeMap<String, String>,
1317 },
1318 TimeWiggle {
1319 options: BTreeMap<String, String>,
1320 },
1321 LinkConfig {
1322 options: BTreeMap<String, String>,
1323 },
1324 SurvivalConfig {
1325 options: BTreeMap<String, String>,
1326 },
1327 LogSlopeSurface {
1328 z_column: String,
1329 terms: Vec<ParsedTerm>,
1330 },
1331 Interaction {
1339 vars: Vec<String>,
1340 },
1341}
1342
1343pub fn parsed_term_column_names(
1352 terms: &[ParsedTerm],
1353 out: &mut std::collections::BTreeSet<String>,
1354) {
1355 for term in terms {
1356 match term {
1357 ParsedTerm::Linear { name, .. }
1358 | ParsedTerm::BoundedLinear { name, .. }
1359 | ParsedTerm::RandomEffect { name, .. } => {
1360 out.insert(name.clone());
1361 }
1362 ParsedTerm::Smooth { vars, options, .. } => {
1363 out.extend(vars.iter().cloned());
1364 if let Some(by) = options.get("by") {
1365 out.insert(by.clone());
1366 }
1367 }
1368 ParsedTerm::Interaction { vars } => {
1369 out.extend(vars.iter().cloned());
1370 }
1371 ParsedTerm::LinkWiggle { .. }
1372 | ParsedTerm::TimeWiggle { .. }
1373 | ParsedTerm::LinkConfig { .. }
1374 | ParsedTerm::SurvivalConfig { .. } => {}
1375 ParsedTerm::LogSlopeSurface { z_column, terms } => {
1376 out.insert(z_column.clone());
1377 parsed_term_column_names(terms, out);
1378 }
1379 }
1380 }
1381}
1382
1383pub fn parsed_terms_reference_column(terms: &[ParsedTerm], column_name: &str) -> bool {
1384 terms.iter().any(|term| match term {
1385 ParsedTerm::Linear { name, .. }
1386 | ParsedTerm::BoundedLinear { name, .. }
1387 | ParsedTerm::RandomEffect { name, .. } => name == column_name,
1388 ParsedTerm::Smooth { vars, options, .. } => {
1389 vars.iter().any(|var| var == column_name)
1390 || options.get("by").is_some_and(|by| by == column_name)
1391 }
1392 ParsedTerm::Interaction { vars } => vars.iter().any(|var| var == column_name),
1393 ParsedTerm::LinkWiggle { .. }
1394 | ParsedTerm::TimeWiggle { .. }
1395 | ParsedTerm::LinkConfig { .. }
1396 | ParsedTerm::SurvivalConfig { .. } => false,
1397 ParsedTerm::LogSlopeSurface { z_column, terms } => {
1398 z_column == column_name || parsed_terms_reference_column(terms, column_name)
1399 }
1400 })
1401}
1402
1403pub fn validate_marginal_slope_z_column_exclusion(
1404 main_formula: &ParsedFormula,
1405 logslope_formula: &ParsedFormula,
1406 z_column: &str,
1407 context: &str,
1408 logslope_label: &str,
1409) -> Result<(), String> {
1410 let surfaces = marginal_slope_logslope_surfaces(logslope_formula, z_column)?;
1411 let mut reserved_z_columns = std::collections::BTreeSet::<&str>::new();
1415 reserved_z_columns.insert(z_column);
1416 reserved_z_columns.extend(surfaces.iter().map(|surface| surface.z_column.as_str()));
1417
1418 for reserved in &reserved_z_columns {
1419 if parsed_terms_reference_column(&main_formula.terms, reserved) {
1420 return Err(FormulaDslError::IncompatibleTerm {
1421 reason: format!(
1422 "{context} reserves z column '{reserved}' as the auxiliary latent score; it cannot also appear in the main formula"
1423 ),
1424 }
1425 .into());
1426 }
1427 }
1428 for reserved in &reserved_z_columns {
1429 if parsed_terms_reference_column(&logslope_formula.terms, reserved) {
1430 return Err(FormulaDslError::IncompatibleTerm {
1431 reason: format!(
1432 "{context} reserves z column '{reserved}' as the auxiliary latent score; it cannot also appear in {logslope_label}"
1433 ),
1434 }
1435 .into());
1436 }
1437 for surface in &surfaces {
1438 if parsed_terms_reference_column(&surface.terms, reserved) {
1439 return Err(FormulaDslError::IncompatibleTerm {
1440 reason: format!(
1441 "{context} reserves z column '{reserved}' as an auxiliary latent score; it cannot also appear in {logslope_label}"
1442 ),
1443 }
1444 .into());
1445 }
1446 }
1447 }
1448 Ok(())
1449}
1450
1451#[derive(Clone, Copy, Debug)]
1452pub enum SmoothKind {
1453 S,
1454 Te,
1455 T2,
1459 Ti,
1465}
1466
1467#[derive(Clone, Copy, Debug)]
1468pub enum LinkMode {
1469 Strict,
1470 Flexible,
1471}
1472
1473#[derive(Clone, Debug)]
1474pub struct LinkChoice {
1475 pub mode: LinkMode,
1476 pub link: LinkFunction,
1477 pub mixture_components: Option<Vec<LinkComponent>>,
1478}
1479
1480pub fn effectivelinkwiggle_formulaspec(
1485 formula_linkwiggle: Option<&LinkWiggleFormulaSpec>,
1486 link_choice: Option<&LinkChoice>,
1487) -> Option<LinkWiggleFormulaSpec> {
1488 formula_linkwiggle.cloned().or_else(|| {
1489 link_choice.and_then(|choice| {
1490 if matches!(choice.mode, LinkMode::Flexible) {
1491 Some(default_linkwiggle_formulaspec())
1492 } else {
1493 None
1494 }
1495 })
1496 })
1497}
1498
1499pub const fn linkname_supports_joint_wiggle(link: LinkFunction) -> bool {
1500 !matches!(link, LinkFunction::Sas | LinkFunction::BetaLogistic)
1501}
1502
1503pub const fn linkchoice_supports_joint_wiggle(choice: &LinkChoice) -> bool {
1504 match &choice.mixture_components {
1505 None => linkname_supports_joint_wiggle(choice.link),
1506 Some(_) => false,
1507 }
1508}
1509
1510pub fn require_linkchoice_supports_joint_wiggle(
1511 choice: &LinkChoice,
1512 context: &str,
1513) -> Result<(), String> {
1514 if linkchoice_supports_joint_wiggle(choice) {
1515 Ok(())
1516 } else {
1517 Err(joint_wiggle_unsupported_link_message(context))
1518 }
1519}
1520
1521pub const fn likelihood_spec_supports_joint_wiggle(likelihood: &LikelihoodSpec) -> bool {
1522 inverse_link_supports_joint_wiggle(&likelihood.link)
1523}
1524
1525pub fn require_likelihood_spec_supports_joint_wiggle(
1526 likelihood: &LikelihoodSpec,
1527 context: &str,
1528) -> Result<(), String> {
1529 if likelihood_spec_supports_joint_wiggle(likelihood) {
1530 Ok(())
1531 } else {
1532 Err(joint_wiggle_unsupported_link_message(context))
1533 }
1534}
1535
1536pub const fn inverse_link_supports_joint_wiggle(link: &InverseLink) -> bool {
1549 matches!(
1550 link,
1551 InverseLink::Standard(StandardLink::Identity)
1552 | InverseLink::Standard(StandardLink::Log)
1553 | InverseLink::Standard(StandardLink::Logit)
1554 | InverseLink::Standard(StandardLink::Probit)
1555 | InverseLink::Standard(StandardLink::CLogLog)
1556 | InverseLink::Standard(StandardLink::LogLog)
1557 | InverseLink::Standard(StandardLink::Cauchit)
1558 )
1559}
1560
1561pub fn require_inverse_link_supports_joint_wiggle(
1562 link: &InverseLink,
1563 context: &str,
1564) -> Result<(), String> {
1565 if inverse_link_supports_joint_wiggle(link) {
1566 Ok(())
1567 } else {
1568 Err(joint_wiggle_unsupported_link_message(context))
1569 }
1570}
1571
1572pub const fn binomial_inverse_link_supports_joint_wiggle(link: &InverseLink) -> bool {
1584 matches!(
1585 link,
1586 InverseLink::Standard(StandardLink::Logit)
1587 | InverseLink::Standard(StandardLink::Probit)
1588 | InverseLink::Standard(StandardLink::CLogLog)
1589 | InverseLink::Standard(StandardLink::LogLog)
1590 | InverseLink::Standard(StandardLink::Cauchit)
1591 )
1592}
1593
1594pub fn require_binomial_inverse_link_supports_joint_wiggle(
1595 link: &InverseLink,
1596 context: &str,
1597) -> Result<(), String> {
1598 if binomial_inverse_link_supports_joint_wiggle(link) {
1599 Ok(())
1600 } else {
1601 Err(FormulaDslError::IncompatibleTerm {
1602 reason: format!(
1603 "{context} does not support identity, log, latent-cloglog, SAS, BetaLogistic, or Mixture links; wiggle is only available for jointly fitted standard binomial probability links (logit/probit/cloglog/loglog/cauchit)"
1604 ),
1605 }
1606 .into())
1607 }
1608}
1609
1610pub fn joint_wiggle_unsupported_link_message(context: &str) -> String {
1611 format!(
1612 "{context} does not support latent-cloglog, SAS, BetaLogistic, or Mixture links; wiggle is only available for jointly fitted standard links"
1613 )
1614}
1615
1616pub fn option_usize(map: &BTreeMap<String, String>, key: &str) -> Option<usize> {
1621 map.get(key).and_then(|v| v.parse::<usize>().ok())
1622}
1623
1624fn validate_known_term_options(
1631 term_name: &str,
1632 options: &BTreeMap<String, String>,
1633 known: &[&str],
1634 raw: &str,
1635) -> Result<(), String> {
1636 let known_set: std::collections::BTreeSet<&&str> = known.iter().collect();
1637 for key in options.keys() {
1638 if !known_set.contains(&key.as_str()) {
1639 let known_sorted = {
1640 let mut v = known.to_vec();
1641 v.sort_unstable();
1642 v.join(", ")
1643 };
1644 let known_hint = if known.is_empty() {
1645 "no options".to_string()
1646 } else {
1647 format!("[{known_sorted}]")
1648 };
1649 return Err(FormulaDslError::InvalidArgument {
1650 reason: format!(
1651 "{term_name}() does not accept option `{key}` (in `{raw}`); known options: {known_hint}"
1652 ),
1653 }
1654 .into());
1655 }
1656 }
1657 Ok(())
1658}
1659
1660pub fn option_usize_any(map: &BTreeMap<String, String>, keys: &[&str]) -> Option<usize> {
1661 for key in keys {
1662 if let Some(v) = option_usize(map, key) {
1663 return Some(v);
1664 }
1665 }
1666 None
1667}
1668
1669pub fn option_usize_strict(
1676 map: &BTreeMap<String, String>,
1677 key: &str,
1678) -> Result<Option<usize>, String> {
1679 match map.get(key) {
1680 None => Ok(None),
1681 Some(raw) => raw.parse::<usize>().map(Some).map_err(|err| {
1682 FormulaDslError::InvalidArgument {
1683 reason: format!(
1684 "option `{key}={raw}` is not a non-negative integer; \
1685 expected a whole number >= 0: {err}"
1686 ),
1687 }
1688 .into()
1689 }),
1690 }
1691}
1692
1693pub fn option_usize_any_strict(
1696 map: &BTreeMap<String, String>,
1697 keys: &[&str],
1698) -> Result<Option<usize>, String> {
1699 for key in keys {
1700 if let Some(v) = option_usize_strict(map, key)? {
1701 return Ok(Some(v));
1702 }
1703 }
1704 Ok(None)
1705}
1706
1707pub fn option_f64(map: &BTreeMap<String, String>, key: &str) -> Option<f64> {
1708 map.get(key).and_then(|v| v.parse::<f64>().ok())
1709}
1710
1711pub fn option_f64_strict(map: &BTreeMap<String, String>, key: &str) -> Result<Option<f64>, String> {
1715 match map.get(key) {
1716 None => Ok(None),
1717 Some(raw) => match raw.parse::<f64>() {
1718 Ok(v) if v.is_finite() => Ok(Some(v)),
1719 Ok(v) => Err(FormulaDslError::InvalidArgument {
1720 reason: format!("option `{key}={raw}` parses as {v} which is not a finite number"),
1721 }
1722 .into()),
1723 Err(err) => Err(FormulaDslError::InvalidArgument {
1724 reason: format!(
1725 "option `{key}={raw}` is not a valid number; expected a finite decimal: {err}"
1726 ),
1727 }
1728 .into()),
1729 },
1730 }
1731}
1732
1733pub fn option_bool(map: &BTreeMap<String, String>, key: &str) -> Option<bool> {
1734 map.get(key)
1735 .and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
1736 "true" | "1" | "yes" | "y" => Some(true),
1737 "false" | "0" | "no" | "n" => Some(false),
1738 _ => None,
1739 })
1740}
1741
1742pub fn option_bool_strict(
1748 map: &BTreeMap<String, String>,
1749 key: &str,
1750) -> Result<Option<bool>, String> {
1751 match map.get(key) {
1752 None => Ok(None),
1753 Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1754 "true" | "1" | "yes" | "y" => Ok(Some(true)),
1755 "false" | "0" | "no" | "n" => Ok(Some(false)),
1756 _ => Err(FormulaDslError::InvalidArgument {
1757 reason: format!(
1758 "option `{key}={raw}` is not a boolean; \
1759 expected one of true/false/yes/no/1/0"
1760 ),
1761 }
1762 .into()),
1763 },
1764 }
1765}
1766
1767pub fn strip_quotes(v: &str) -> &str {
1768 let b = v.as_bytes();
1769 if b.len() >= 2
1770 && ((b[0] == b'\'' && b[b.len() - 1] == b'\'') || (b[0] == b'"' && b[b.len() - 1] == b'"'))
1771 {
1772 &v[1..v.len() - 1]
1773 } else {
1774 v
1775 }
1776}
1777
1778fn parse_linear_constraint_bounds(
1783 options: &BTreeMap<String, String>,
1784 raw: &str,
1785) -> Result<(Option<f64>, Option<f64>), String> {
1786 let min = parse_optional_f64_option_alias(options, &["min", "lower"], raw, "linear")?;
1787 let max = parse_optional_f64_option_alias(options, &["max", "upper"], raw, "linear")?;
1788 if let (Some(min), Some(max)) = (min, max)
1789 && (!min.is_finite() || !max.is_finite() || min > max)
1790 {
1791 return Err(FormulaDslError::InvalidArgument {
1792 reason: format!(
1793 "linear coefficient constraints require finite min <= max, got min={min}, max={max}: {raw}"
1794 ),
1795 }
1796 .into());
1797 }
1798 Ok((min, max))
1799}
1800
1801fn parse_required_f64_option(
1802 options: &BTreeMap<String, String>,
1803 key: &str,
1804 raw: &str,
1805) -> Result<f64, String> {
1806 let value = options
1807 .get(key)
1808 .ok_or_else(|| FormulaDslError::MalformedConfig {
1809 reason: format!("bounded() is missing required '{key}' argument: {raw}"),
1810 })?;
1811 value.parse::<f64>().map_err(|err| {
1812 FormulaDslError::InvalidArgument {
1813 reason: format!(
1814 "bounded() argument '{key}' must be a finite number, got '{}': {err}: {raw}",
1815 value
1816 ),
1817 }
1818 .into()
1819 })
1820}
1821
1822fn parse_optional_f64_option(
1823 options: &BTreeMap<String, String>,
1824 key: &str,
1825 raw: &str,
1826) -> Result<Option<f64>, String> {
1827 match options.get(key) {
1828 Some(value) => value.parse::<f64>().map(Some).map_err(|err| {
1829 FormulaDslError::InvalidArgument {
1830 reason: format!(
1831 "bounded() argument '{key}' must be a finite number, got '{}': {err}: {raw}",
1832 value
1833 ),
1834 }
1835 .into()
1836 }),
1837 None => Ok(None),
1838 }
1839}
1840
1841fn parse_optional_f64_option_alias(
1842 options: &BTreeMap<String, String>,
1843 keys: &[&str],
1844 raw: &str,
1845 fn_label: &str,
1846) -> Result<Option<f64>, String> {
1847 let mut found: Option<(&str, f64)> = None;
1848 for key in keys {
1849 if let Some(value) = options.get(*key) {
1850 let parsed = value
1851 .parse::<f64>()
1852 .map_err(|err| FormulaDslError::InvalidArgument {
1853 reason: format!(
1854 "{fn_label}() argument '{key}' must be a finite number, got '{}': {err}: {raw}",
1855 value
1856 ),
1857 })?;
1858 if found.is_some() {
1859 return Err(FormulaDslError::IncompatibleTerm {
1860 reason: format!(
1861 "{fn_label}() cannot specify both '{}' and '{}': {raw}",
1862 found.expect("present").0,
1863 key
1864 ),
1865 }
1866 .into());
1867 }
1868 found = Some((key, parsed));
1869 }
1870 }
1871 Ok(found.map(|(_, v)| v))
1872}
1873
1874fn parse_linkwiggle_penalty_orders(raw: Option<&str>) -> Result<Vec<usize>, String> {
1875 let Some(raw) = raw.map(str::trim) else {
1876 return Ok(WigglePenaltyConfig::cubic_triple_operator_default().penalty_orders);
1877 };
1878 if raw.is_empty() {
1879 return Ok(WigglePenaltyConfig::cubic_triple_operator_default().penalty_orders);
1880 }
1881 let mut out = Vec::<usize>::new();
1882 for token in raw.split(',') {
1883 let t = token.trim().to_ascii_lowercase();
1884 if t.is_empty() {
1885 continue;
1886 }
1887 match t.as_str() {
1888 "all" => {
1889 out.extend([1, 2, 3]);
1890 }
1891 "slope" | "1" => out.push(1),
1892 "curvature" | "2" => out.push(2),
1893 "curvature-change" | "curvature_change" | "3" => out.push(3),
1894 _ => {
1895 return Err(FormulaDslError::InvalidArgument {
1896 reason: format!(
1897 "invalid linkwiggle penalty_order '{t}'; use all|slope|curvature|curvature-change or 1/2/3"
1898 ),
1899 }
1900 .into());
1901 }
1902 }
1903 }
1904 if out.is_empty() {
1905 out.extend(WigglePenaltyConfig::cubic_triple_operator_default().penalty_orders);
1906 }
1907 out.sort_unstable();
1908 out.dedup();
1909 Ok(out)
1910}
1911
1912pub fn parse_linkwiggle_formulaspec(
1913 options: &BTreeMap<String, String>,
1914 raw: &str,
1915) -> Result<LinkWiggleFormulaSpec, String> {
1916 let allowed = [
1917 "degree",
1918 "internal_knots",
1919 "penalty_order",
1920 "double_penalty",
1921 ];
1922 let unknown = options
1923 .keys()
1924 .filter(|key| !allowed.contains(&key.as_str()))
1925 .cloned()
1926 .collect::<Vec<_>>();
1927 let term_name = raw.split('(').next().unwrap_or("linkwiggle");
1928 if !unknown.is_empty() {
1929 return Err(FormulaDslError::InvalidArgument {
1930 reason: format!(
1931 "{}() does not support option(s) {}: {raw}",
1932 term_name,
1933 unknown.join(", ")
1934 ),
1935 }
1936 .into());
1937 }
1938 let defaults = WigglePenaltyConfig::cubic_triple_operator_default();
1939 let degree = option_usize_strict(options, "degree")?.unwrap_or(defaults.degree);
1955 if degree < 1 {
1956 return Err(FormulaDslError::InvalidArgument {
1957 reason: format!("{term_name}() requires degree >= 1: {raw}"),
1958 }
1959 .into());
1960 }
1961 let num_internal_knots =
1962 option_usize_strict(options, "internal_knots")?.unwrap_or(defaults.num_internal_knots);
1963 if num_internal_knots == 0 {
1964 return Err(FormulaDslError::InvalidArgument {
1965 reason: format!("{term_name}() requires internal_knots > 0: {raw}"),
1966 }
1967 .into());
1968 }
1969 let penalty_orders =
1970 parse_linkwiggle_penalty_orders(options.get("penalty_order").map(String::as_str))?;
1971 let double_penalty =
1972 option_bool_strict(options, "double_penalty")?.unwrap_or(defaults.double_penalty);
1973 Ok(LinkWiggleFormulaSpec {
1974 degree,
1975 num_internal_knots,
1976 penalty_orders,
1977 double_penalty,
1978 })
1979}
1980
1981fn parse_link_formulaspec(
1982 options: &BTreeMap<String, String>,
1983 raw: &str,
1984) -> Result<LinkFormulaSpec, String> {
1985 let link = options
1986 .get("type")
1987 .map(|s| s.trim().to_string())
1988 .ok_or_else(|| FormulaDslError::MalformedConfig {
1989 reason: format!("link() requires type=<link-name>: {raw}"),
1990 })?;
1991 if link.is_empty() {
1992 return Err(FormulaDslError::MalformedConfig {
1993 reason: format!("link() requires a non-empty type: {raw}"),
1994 }
1995 .into());
1996 }
1997 let mixture_rho = options.get("rho").map(|s| s.trim().to_string());
1998 let sas_init = options.get("sas_init").map(|s| s.trim().to_string());
1999 let beta_logistic_init = options
2000 .get("beta_logistic_init")
2001 .map(|s| s.trim().to_string());
2002 Ok(LinkFormulaSpec {
2003 link,
2004 mixture_rho,
2005 sas_init,
2006 beta_logistic_init,
2007 })
2008}
2009
2010fn parse_survival_formulaspec(
2011 options: &BTreeMap<String, String>,
2012 raw: &str,
2013) -> Result<SurvivalFormulaSpec, String> {
2014 if options.is_empty() {
2015 return Err(FormulaDslError::MalformedConfig {
2016 reason: format!(
2017 "survmodel() requires at least one named option (e.g., spec=..., distribution=...): {raw}"
2018 ),
2019 }
2020 .into());
2021 }
2022 Ok(SurvivalFormulaSpec {
2023 spec: options.get("spec").map(|s| s.trim().to_string()),
2024 survival_distribution: options.get("distribution").map(|s| s.trim().to_string()),
2025 })
2026}
2027
2028fn parse_bounded_priorspec(
2029 options: &BTreeMap<String, String>,
2030 min: f64,
2031 max: f64,
2032 raw: &str,
2033) -> Result<BoundedCoefficientPriorSpec, String> {
2034 let prior_mode = options.get("prior").map(|s| s.to_ascii_lowercase());
2035 let pull = options.get("pull").map(|s| s.to_ascii_lowercase());
2036 let target = parse_optional_f64_option(options, "target", raw)?;
2037 let strength = parse_optional_f64_option(options, "strength", raw)?;
2038
2039 let target_mode = target.is_some() || strength.is_some();
2040 if prior_mode.is_some() && pull.is_some() {
2041 return Err(FormulaDslError::IncompatibleTerm {
2042 reason: format!("bounded() cannot combine prior=... with pull=...: {raw}"),
2043 }
2044 .into());
2045 }
2046 if prior_mode.is_some() && target_mode {
2047 return Err(FormulaDslError::IncompatibleTerm {
2048 reason: format!("bounded() cannot combine prior=... with target/strength: {raw}"),
2049 }
2050 .into());
2051 }
2052 if pull.is_some() && target_mode {
2053 return Err(FormulaDslError::IncompatibleTerm {
2054 reason: format!("bounded() cannot combine pull=... with target/strength: {raw}"),
2055 }
2056 .into());
2057 }
2058
2059 if let Some(priorname) = prior_mode {
2060 return match priorname.as_str() {
2061 "none" => Ok(BoundedCoefficientPriorSpec::None),
2062 "uniform" | "log-jacobian" | "log_jacobian" | "jacobian" => {
2063 Ok(BoundedCoefficientPriorSpec::Uniform)
2064 }
2065 "center" => Ok(BoundedCoefficientPriorSpec::Beta { a: 2.0, b: 2.0 }),
2066 _ => Err(FormulaDslError::InvalidArgument {
2067 reason: format!(
2068 "bounded() prior must currently be one of none|uniform|log-jacobian|center, got '{}': {raw}",
2069 priorname
2070 ),
2071 }
2072 .into()),
2073 };
2074 }
2075
2076 if let Some(pull_mode) = pull {
2077 return match pull_mode.as_str() {
2078 "uniform" | "log-jacobian" | "log_jacobian" | "jacobian" => {
2079 Ok(BoundedCoefficientPriorSpec::Uniform)
2080 }
2081 "center" => Ok(BoundedCoefficientPriorSpec::Beta { a: 2.0, b: 2.0 }),
2082 _ => Err(FormulaDslError::InvalidArgument {
2083 reason: format!(
2084 "bounded() pull must currently be 'uniform'/'log-jacobian' or 'center', got '{}': {raw}",
2085 pull_mode
2086 ),
2087 }
2088 .into()),
2089 };
2090 }
2091
2092 if target_mode {
2093 let targetvalue = target.ok_or_else(|| FormulaDslError::MalformedConfig {
2094 reason: format!("bounded() target is required with strength: {raw}"),
2095 })?;
2096 let strengthvalue = strength.ok_or_else(|| FormulaDslError::MalformedConfig {
2097 reason: format!("bounded() strength is required with target: {raw}"),
2098 })?;
2099 if !(min < targetvalue && targetvalue < max) {
2100 return Err(FormulaDslError::InvalidArgument {
2101 reason: format!("bounded() target must lie strictly inside ({min}, {max}): {raw}"),
2102 }
2103 .into());
2104 }
2105 if !strengthvalue.is_finite() || strengthvalue <= 0.0 {
2106 return Err(FormulaDslError::InvalidArgument {
2107 reason: format!("bounded() strength must be finite and > 0: {raw}"),
2108 }
2109 .into());
2110 }
2111 let z = (targetvalue - min) / (max - min);
2112 let a = 1.0 + strengthvalue * z;
2113 let b = 1.0 + strengthvalue * (1.0 - z);
2114 return Ok(BoundedCoefficientPriorSpec::Beta { a, b });
2115 }
2116
2117 Ok(BoundedCoefficientPriorSpec::None)
2118}
2119
2120pub fn formula_rhs_text(formula: &str) -> Result<String, String> {
2125 let parsed = parse_formula_dsl(formula)?;
2126 if parsed.rhs_terms.is_empty() {
2127 return Err(FormulaDslError::ParseError {
2128 reason: "formula right-hand side cannot be empty".to_string(),
2129 }
2130 .into());
2131 }
2132 Ok(parsed.rhs_terms.join(" + "))
2133}
2134
2135pub fn parse_surv_response(
2142 lhs: &str,
2143) -> Result<Option<(Option<String>, String, String)>, FormulaDslError> {
2144 let trimmed = lhs.trim();
2145 let call = match parse_function_call(trimmed) {
2146 Ok(call) => call,
2147 Err(_) => return Ok(None),
2148 };
2149 if !call.name.eq_ignore_ascii_case("surv") {
2150 return Ok(None);
2151 }
2152 let vars = call
2153 .args
2154 .iter()
2155 .filter_map(|arg| match arg {
2156 CallArgSpec::Positional(v) => Some(v.trim().to_string()),
2157 CallArgSpec::Named { .. } => None,
2158 })
2159 .filter(|s| !s.is_empty())
2160 .collect::<Vec<_>>();
2161 match vars.len() {
2162 2 => Ok(Some((None, vars[0].clone(), vars[1].clone()))),
2166 3 => Ok(Some((
2167 Some(vars[0].clone()),
2168 vars[1].clone(),
2169 vars[2].clone(),
2170 ))),
2171 n => Err(FormulaDslError::InvalidArgument {
2172 reason: format!(
2173 "Surv(...) expects either Surv(time, event) (right-censored) or \
2174 Surv(entry, exit, event) (left-truncated); got {n} columns"
2175 ),
2176 }),
2177 }
2178}
2179
2180pub fn parse_surv_interval_response(
2195 lhs: &str,
2196) -> Result<Option<(String, String, String)>, FormulaDslError> {
2197 let trimmed = lhs.trim();
2198 let call = match parse_function_call(trimmed) {
2199 Ok(call) => call,
2200 Err(_) => return Ok(None),
2201 };
2202 if !call.name.eq_ignore_ascii_case("survinterval") {
2203 return Ok(None);
2204 }
2205 let vars = call
2206 .args
2207 .iter()
2208 .filter_map(|arg| match arg {
2209 CallArgSpec::Positional(v) => Some(v.trim().to_string()),
2210 CallArgSpec::Named { .. } => None,
2211 })
2212 .filter(|s| !s.is_empty())
2213 .collect::<Vec<_>>();
2214 match vars.len() {
2215 3 => Ok(Some((vars[0].clone(), vars[1].clone(), vars[2].clone()))),
2216 n => Err(FormulaDslError::InvalidArgument {
2217 reason: format!(
2218 "SurvInterval(...) expects SurvInterval(L, R, event) (interval-censored, the \
2219 observed bracket T ∈ (L, R]); got {n} columns"
2220 ),
2221 }),
2222 }
2223}
2224
2225fn top_level_formula_separator(input: &str) -> Result<Option<usize>, String> {
2226 let mut depth = 0_i32;
2227 let mut in_single = false;
2228 let mut in_double = false;
2229
2230 for (idx, ch) in input.char_indices() {
2231 match ch {
2232 '\'' if !in_double => in_single = !in_single,
2233 '"' if !in_single => in_double = !in_double,
2234 '(' | '[' | '{' if !in_single && !in_double => depth += 1,
2235 ')' | ']' | '}' if !in_single && !in_double && depth > 0 => depth -= 1,
2236 '~' if !in_single && !in_double && depth == 0 => return Ok(Some(idx)),
2237 _ => {}
2238 }
2239 }
2240
2241 if in_single || in_double || depth != 0 {
2242 return Err(FormulaDslError::ParseError {
2243 reason: "invalid auxiliary formula syntax: unbalanced parentheses or quotes"
2244 .to_string(),
2245 }
2246 .into());
2247 }
2248 Ok(None)
2249}
2250
2251pub fn parse_matching_auxiliary_formula(
2252 formula: &str,
2253 response: &str,
2254 flag_name: &str,
2255) -> Result<(String, ParsedFormula), FormulaDslError> {
2256 let rhs = formula.trim();
2257 if top_level_formula_separator(rhs)?.is_some() {
2258 return Err(FormulaDslError::InvalidArgument {
2259 reason: format!(
2260 "{flag_name} expects only the terms after '~', not a full 'response ~ terms' formula; use {flag_name} 's(x)' instead of {flag_name} 'y ~ s(x)' (or pass '1' for an intercept-only noise model)"
2261 ),
2262 });
2263 }
2264 let parsed_formula = parse_formula(&format!("{response} ~ {rhs}"))?;
2265 Ok((rhs.to_string(), parsed_formula))
2266}
2267
2268pub fn validate_auxiliary_formula_controls(
2269 parsed_formula: &ParsedFormula,
2270 flag_name: &str,
2271) -> Result<(), String> {
2272 if parsed_formula.linkwiggle.is_some() {
2273 return Err(FormulaDslError::IncompatibleTerm {
2274 reason: format!(
2275 "linkwiggle(...) is only supported in the main formula, not {flag_name}"
2276 ),
2277 }
2278 .into());
2279 }
2280 if parsed_formula.timewiggle.is_some() {
2281 return Err(FormulaDslError::IncompatibleTerm {
2282 reason: format!(
2283 "timewiggle(...) is only supported in the main survival formula, not {flag_name}"
2284 ),
2285 }
2286 .into());
2287 }
2288 if parsed_formula.linkspec.is_some() {
2289 return Err(FormulaDslError::IncompatibleTerm {
2290 reason: format!("link(...) is only supported in the main formula, not {flag_name}"),
2291 }
2292 .into());
2293 }
2294 if parsed_formula.survivalspec.is_some() {
2295 return Err(FormulaDslError::IncompatibleTerm {
2296 reason: format!(
2297 "survmodel(...) is only supported in the main survival formula, not {flag_name}"
2298 ),
2299 }
2300 .into());
2301 }
2302 if !parsed_formula.logslope_surfaces.is_empty() && flag_name != "--logslope-formula" {
2303 return Err(FormulaDslError::IncompatibleTerm {
2304 reason: format!(
2305 "logslope(...) is only supported in --logslope-formula, not {flag_name}"
2306 ),
2307 }
2308 .into());
2309 }
2310 Ok::<(), _>(())
2311}
2312
2313pub fn parse_formula(formula: &str) -> Result<ParsedFormula, FormulaDslError> {
2314 let parsed_dsl =
2315 parse_formula_dsl(formula).map_err(|reason| FormulaDslError::ParseError { reason })?;
2316 let lhs = parsed_dsl.response_expr.trim();
2317 if lhs.is_empty() {
2318 return Err(FormulaDslError::ParseError {
2319 reason: "formula response (left-hand side) cannot be empty".to_string(),
2320 });
2321 }
2322 let mut terms = Vec::<ParsedTerm>::new();
2323 let mut linkwiggle: Option<LinkWiggleFormulaSpec> = None;
2324 let mut timewiggle: Option<LinkWiggleFormulaSpec> = None;
2325 let mut linkspec: Option<LinkFormulaSpec> = None;
2326 let mut survivalspec: Option<SurvivalFormulaSpec> = None;
2327 let mut logslope_surfaces = Vec::<LogSlopeSurfaceSpec>::new();
2328 let mut seen_term_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2333 let mut expanded_terms = Vec::<String>::new();
2334 for raw in parsed_dsl.rhs_terms {
2335 let trimmed = raw.trim();
2336 if trimmed.is_empty() {
2337 expanded_terms.push(String::new());
2338 continue;
2339 }
2340 let is_call = parse_function_call(trimmed).is_ok();
2345 let needs_expansion = !is_call
2346 && trimmed
2347 .chars()
2348 .scan(0i32, |depth, ch| {
2349 let d_before = *depth;
2350 match ch {
2351 '(' | '[' | '{' => *depth += 1,
2352 ')' | ']' | '}' if *depth > 0 => *depth -= 1,
2353 _ => {}
2354 }
2355 Some((d_before, ch))
2356 })
2357 .any(|(d, ch)| d == 0 && matches!(ch, ':' | '*' | '/' | '^'));
2358 if needs_expansion {
2359 for atoms in
2360 expand_wr_term(trimmed).map_err(|reason| FormulaDslError::ParseError { reason })?
2361 {
2362 if atoms.is_empty() {
2363 continue;
2364 }
2365 expanded_terms.push(atoms.join(":"));
2366 }
2367 } else {
2368 expanded_terms.push(trimmed.to_string());
2369 }
2370 }
2371
2372 for raw in expanded_terms {
2373 let t = raw.trim();
2374 if t.is_empty() || t == "1" {
2375 continue;
2376 }
2377 if t == "0" || t == "-1" {
2378 return Err(FormulaDslError::IncompatibleTerm {
2379 reason: "formula terms '0'/'-1' (intercept removal) are not supported yet"
2380 .to_string(),
2381 });
2382 }
2383 let key: String = {
2387 let mut acc = String::with_capacity(t.len());
2388 let mut in_single = false;
2389 let mut in_double = false;
2390 for ch in t.chars() {
2391 match ch {
2392 '\'' if !in_double => {
2393 in_single = !in_single;
2394 acc.push(ch);
2395 }
2396 '"' if !in_single => {
2397 in_double = !in_double;
2398 acc.push(ch);
2399 }
2400 c if c.is_whitespace() && !in_single && !in_double => {}
2401 _ => acc.push(ch),
2402 }
2403 }
2404 acc
2405 };
2406 if !seen_term_keys.insert(key.clone()) {
2407 return Err(FormulaDslError::IncompatibleTerm {
2408 reason: format!(
2409 "formula `{formula}` lists term `{t}` more than once. \
2410 Duplicate terms produce a rank-deficient design; \
2411 keep one copy or differentiate them (e.g. distinct k=, bs= options)."
2412 ),
2413 });
2414 }
2415 match parse_term(t)? {
2416 ParsedTerm::LinkWiggle { options } => {
2417 if linkwiggle.is_some() {
2418 return Err(FormulaDslError::IncompatibleTerm {
2419 reason: "formula can include at most one linkwiggle(...) term".to_string(),
2420 });
2421 }
2422 linkwiggle = Some(parse_linkwiggle_formulaspec(&options, t)?);
2423 }
2424 ParsedTerm::TimeWiggle { options } => {
2425 if timewiggle.is_some() {
2426 return Err(FormulaDslError::IncompatibleTerm {
2427 reason: "formula can include at most one timewiggle(...) term".to_string(),
2428 });
2429 }
2430 timewiggle = Some(parse_linkwiggle_formulaspec(&options, t)?);
2431 }
2432 ParsedTerm::LinkConfig { options } => {
2433 if linkspec.is_some() {
2434 return Err(FormulaDslError::IncompatibleTerm {
2435 reason: "formula can include at most one link(...) term".to_string(),
2436 });
2437 }
2438 linkspec = Some(parse_link_formulaspec(&options, t)?);
2439 }
2440 ParsedTerm::SurvivalConfig { options } => {
2441 if survivalspec.is_some() {
2442 return Err(FormulaDslError::IncompatibleTerm {
2443 reason: "formula can include at most one survmodel(...) term".to_string(),
2444 });
2445 }
2446 survivalspec = Some(parse_survival_formulaspec(&options, t)?);
2447 }
2448 ParsedTerm::LogSlopeSurface { z_column, terms } => {
2449 logslope_surfaces.push(LogSlopeSurfaceSpec { z_column, terms });
2450 }
2451 other => terms.push(other),
2452 }
2453 }
2454 if lhs.chars().all(|c| c.is_alphanumeric() || c == '_')
2460 && !lhs.is_empty()
2461 && parsed_terms_reference_column(&terms, lhs)
2462 {
2463 return Err(FormulaDslError::IncompatibleTerm {
2464 reason: format!(
2465 "formula `{formula}` uses response column `{lhs}` as its own predictor. \
2466 This fits y as a function of itself and is almost certainly a typo. \
2467 Drop the term that mentions `{lhs}` from the right-hand side."
2468 ),
2469 });
2470 }
2471 Ok(ParsedFormula {
2472 response: lhs.to_string(),
2473 terms,
2474 logslope_surfaces,
2475 linkwiggle,
2476 timewiggle,
2477 linkspec,
2478 survivalspec,
2479 })
2480}
2481
2482pub fn parse_term(raw: &str) -> Result<ParsedTerm, String> {
2483 fn split_call_args(call: &FunctionCallSpec) -> (Vec<String>, BTreeMap<String, String>) {
2484 let mut vars = Vec::<String>::new();
2485 let mut options = BTreeMap::<String, String>::new();
2486 for arg in &call.args {
2487 match arg {
2488 CallArgSpec::Positional(v) => vars.push(v.trim().to_string()),
2489 CallArgSpec::Named { key, value } => {
2490 options.insert(key.to_ascii_lowercase(), strip_quotes(value).to_string());
2491 }
2492 }
2493 }
2494 (vars, options)
2495 }
2496
2497 if raw.contains(':')
2501 && !raw.contains('(')
2502 && raw.split(':').all(|piece| is_exact_ident(piece.trim()))
2503 {
2504 let vars: Vec<String> = raw
2505 .split(':')
2506 .map(|piece| piece.trim().to_string())
2507 .collect();
2508 if vars.len() >= 2 {
2509 let mut sorted = vars.clone();
2510 sorted.sort();
2511 sorted.dedup();
2512 if sorted.len() != vars.len() {
2513 return Err(FormulaDslError::IncompatibleTerm {
2514 reason: format!(
2515 "interaction term `{raw}` references the same variable more than once"
2516 ),
2517 }
2518 .into());
2519 }
2520 return Ok(ParsedTerm::Interaction { vars: sorted });
2521 }
2522 }
2523
2524 let call = parse_function_call(raw).ok();
2525 if let Some(call) = call {
2526 let name = call.name.to_ascii_lowercase();
2527 let (vars, mut options) = split_call_args(&call);
2528 match name.as_str() {
2529 "constrain" | "constraint" | "box" => {
2530 if vars.len() != 1 {
2531 return Err(FormulaDslError::InvalidArgument {
2532 reason: format!(
2533 "constrain()/constraint()/box() expects exactly one variable: {raw}"
2534 ),
2535 }
2536 .into());
2537 }
2538 validate_known_term_options(
2539 "constrain",
2540 &options,
2541 &["min", "lower", "max", "upper"],
2542 raw,
2543 )?;
2544 let (coefficient_min, coefficient_max) =
2545 parse_linear_constraint_bounds(&options, raw)?;
2546 if coefficient_min.is_none() && coefficient_max.is_none() {
2547 return Err(FormulaDslError::MalformedConfig {
2548 reason: format!(
2549 "constrain()/constraint()/box() requires at least one of min/lower/max/upper: {raw}"
2550 ),
2551 }
2552 .into());
2553 }
2554 return Ok(ParsedTerm::Linear {
2555 name: vars[0].clone(),
2556 explicit: true,
2557 coefficient_min,
2558 coefficient_max,
2559 });
2560 }
2561 "nonnegative" | "nonnegative_coef" => {
2562 if vars.len() != 1 {
2563 return Err(FormulaDslError::InvalidArgument {
2564 reason: format!("nonnegative() expects exactly one variable: {raw}"),
2565 }
2566 .into());
2567 }
2568 validate_known_term_options("nonnegative", &options, &[], raw)?;
2569 return Ok(ParsedTerm::Linear {
2570 name: vars[0].clone(),
2571 explicit: true,
2572 coefficient_min: Some(0.0),
2573 coefficient_max: None,
2574 });
2575 }
2576 "nonpositive" | "nonpositive_coef" => {
2577 if vars.len() != 1 {
2578 return Err(FormulaDslError::InvalidArgument {
2579 reason: format!("nonpositive() expects exactly one variable: {raw}"),
2580 }
2581 .into());
2582 }
2583 validate_known_term_options("nonpositive", &options, &[], raw)?;
2584 return Ok(ParsedTerm::Linear {
2585 name: vars[0].clone(),
2586 explicit: true,
2587 coefficient_min: None,
2588 coefficient_max: Some(0.0),
2589 });
2590 }
2591 "bounded" => {
2592 if vars.len() != 1 {
2593 return Err(FormulaDslError::InvalidArgument {
2594 reason: format!("bounded() expects exactly one variable: {raw}"),
2595 }
2596 .into());
2597 }
2598 validate_known_term_options(
2599 "bounded",
2600 &options,
2601 &["min", "max", "prior", "pull", "target", "strength"],
2602 raw,
2603 )?;
2604 let min = parse_required_f64_option(&options, "min", raw)?;
2605 let max = parse_required_f64_option(&options, "max", raw)?;
2606 if !min.is_finite() || !max.is_finite() || min >= max {
2607 return Err(FormulaDslError::InvalidArgument {
2608 reason: format!(
2609 "bounded() requires finite min < max, got min={min}, max={max}: {raw}"
2610 ),
2611 }
2612 .into());
2613 }
2614 let prior = parse_bounded_priorspec(&options, min, max, raw)?;
2615 return Ok(ParsedTerm::BoundedLinear {
2616 name: vars[0].clone(),
2617 min,
2618 max,
2619 prior,
2620 });
2621 }
2622 "group" | "re" | "factor" => {
2623 if vars.len() != 1 {
2624 return Err(FormulaDslError::InvalidArgument {
2625 reason: format!(
2626 "{name}() expects exactly one variable, got '{}': {raw}",
2627 vars.join(",")
2628 ),
2629 }
2630 .into());
2631 }
2632 let lenient_unseen = name != "factor";
2640 return Ok(ParsedTerm::RandomEffect {
2641 name: vars[0].clone(),
2642 lenient_unseen,
2643 });
2644 }
2645 "tensor" | "interaction" | "te" => {
2646 if vars.len() < 2 {
2647 return Err(FormulaDslError::InvalidArgument {
2648 reason: format!(
2649 "tensor()/interaction()/te() requires at least two variables: {raw}"
2650 ),
2651 }
2652 .into());
2653 }
2654 return Ok(ParsedTerm::Smooth {
2655 label: raw.to_string(),
2656 vars,
2657 kind: SmoothKind::Te,
2658 options,
2659 });
2660 }
2661 "t2" => {
2662 if vars.len() < 2 {
2663 return Err(FormulaDslError::InvalidArgument {
2664 reason: format!("t2() requires at least two variables: {raw}"),
2665 }
2666 .into());
2667 }
2668 return Ok(ParsedTerm::Smooth {
2669 label: raw.to_string(),
2670 vars,
2671 kind: SmoothKind::T2,
2672 options,
2673 });
2674 }
2675 "ti" => {
2676 if vars.len() < 2 {
2683 return Err(FormulaDslError::InvalidArgument {
2684 reason: format!("ti() requires at least two variables: {raw}"),
2685 }
2686 .into());
2687 }
2688 return Ok(ParsedTerm::Smooth {
2689 label: raw.to_string(),
2690 vars,
2691 kind: SmoothKind::Ti,
2692 options,
2693 });
2694 }
2695 "fs" | "sz" => {
2696 if vars.len() != 2 {
2697 return Err(format!("{}() expects exactly two variables: {raw}", name));
2698 }
2699 options.insert("bs".to_string(), name.clone());
2700 return Ok(ParsedTerm::Smooth {
2701 label: raw.to_string(),
2702 vars,
2703 kind: SmoothKind::S,
2704 options,
2705 });
2706 }
2707 "thinplate" | "thin_plate" | "tps" => {
2708 if vars.len() < 2 {
2709 return Err(FormulaDslError::InvalidArgument {
2710 reason: format!(
2711 "thinplate()/thin_plate()/tps() requires at least two variables: {raw}"
2712 ),
2713 }
2714 .into());
2715 }
2716 options.insert("type".to_string(), "tps".to_string());
2717 return Ok(ParsedTerm::Smooth {
2718 label: raw.to_string(),
2719 vars,
2720 kind: SmoothKind::S,
2721 options,
2722 });
2723 }
2724 "smooth" | "s" | "cyclic" | "periodic" | "cc" | "cp" => {
2725 if vars.is_empty() {
2726 return Err(FormulaDslError::InvalidArgument {
2727 reason: format!("smooth()/s() requires at least one variable: {raw}"),
2728 }
2729 .into());
2730 }
2731 let bs_is_re = options
2737 .get("bs")
2738 .or_else(|| options.get("type"))
2739 .map(|v| {
2740 v.trim()
2741 .trim_matches(|c| c == '\'' || c == '"')
2742 .to_ascii_lowercase()
2743 })
2744 .as_deref()
2745 == Some("re");
2746 if bs_is_re && vars.len() == 1 {
2747 return Ok(ParsedTerm::RandomEffect {
2750 name: vars[0].clone(),
2751 lenient_unseen: true,
2752 });
2753 }
2754 if matches!(name.as_str(), "cyclic" | "periodic" | "cc" | "cp") {
2755 options.insert("type".to_string(), "cyclic".to_string());
2756 }
2757 if matches!(name.as_str(), "fs" | "sz") {
2758 options.insert("bs".to_string(), name.clone());
2759 }
2760 return Ok(ParsedTerm::Smooth {
2761 label: raw.to_string(),
2762 vars,
2763 kind: SmoothKind::S,
2764 options,
2765 });
2766 }
2767 "sphere" | "sos" | "spherical" | "s2" => {
2768 if vars.len() != 2 {
2775 return Err(FormulaDslError::InvalidArgument {
2776 reason: format!(
2777 "{name}() expects exactly two variables: latitude and longitude; got {} in {raw}",
2778 vars.len()
2779 ),
2780 }
2781 .into());
2782 }
2783 options.insert("type".to_string(), "sphere".to_string());
2784 return Ok(ParsedTerm::Smooth {
2785 label: raw.to_string(),
2786 vars,
2787 kind: SmoothKind::S,
2788 options,
2789 });
2790 }
2791 "mjs" | "measurejet" | "measure_jet" | "web" => {
2792 if vars.is_empty() {
2798 return Err(FormulaDslError::InvalidArgument {
2799 reason: format!("{name}() requires at least one variable: {raw}"),
2800 }
2801 .into());
2802 }
2803 options.insert("type".to_string(), "measurejet".to_string());
2804 return Ok(ParsedTerm::Smooth {
2805 label: raw.to_string(),
2806 vars,
2807 kind: SmoothKind::S,
2808 options,
2809 });
2810 }
2811 "curv" | "curvature" | "constant_curvature" | "mkappa" => {
2812 if vars.is_empty() {
2818 return Err(FormulaDslError::InvalidArgument {
2819 reason: format!("{name}() requires at least one variable: {raw}"),
2820 }
2821 .into());
2822 }
2823 options.insert("type".to_string(), "curvature".to_string());
2824 return Ok(ParsedTerm::Smooth {
2825 label: raw.to_string(),
2826 vars,
2827 kind: SmoothKind::S,
2828 options,
2829 });
2830 }
2831 "matern" => {
2832 if vars.is_empty() {
2833 return Err(FormulaDslError::InvalidArgument {
2834 reason: format!("matern() requires at least one variable: {raw}"),
2835 }
2836 .into());
2837 }
2838 options.insert("type".to_string(), "matern".to_string());
2839 return Ok(ParsedTerm::Smooth {
2840 label: raw.to_string(),
2841 vars,
2842 kind: SmoothKind::S,
2843 options,
2844 });
2845 }
2846 "duchon" => {
2847 if vars.is_empty() {
2848 return Err(FormulaDslError::InvalidArgument {
2849 reason: format!("duchon() requires at least one variable: {raw}"),
2850 }
2851 .into());
2852 }
2853 if option_bool(&options, "cyclic").unwrap_or(false)
2854 || option_bool(&options, "periodic").unwrap_or(false)
2855 {
2856 options.insert("cyclic".to_string(), "true".to_string());
2857 }
2858 options.insert("type".to_string(), "duchon".to_string());
2859 return Ok(ParsedTerm::Smooth {
2860 label: raw.to_string(),
2861 vars,
2862 kind: SmoothKind::S,
2863 options,
2864 });
2865 }
2866 "pca" => {
2867 if vars.is_empty() {
2868 return Err(FormulaDslError::InvalidArgument {
2869 reason: format!("pca() requires at least one variable: {raw}"),
2870 }
2871 .into());
2872 }
2873 options.insert("type".to_string(), "pca".to_string());
2874 return Ok(ParsedTerm::Smooth {
2875 label: raw.to_string(),
2876 vars,
2877 kind: SmoothKind::S,
2878 options,
2879 });
2880 }
2881 "linkwiggle" => {
2882 if !vars.is_empty() {
2883 return Err(FormulaDslError::InvalidArgument {
2884 reason: format!(
2885 "linkwiggle() takes named options only; positional args are not supported: {raw}"
2886 ),
2887 }
2888 .into());
2889 }
2890 return Ok(ParsedTerm::LinkWiggle { options });
2891 }
2892 "timewiggle" => {
2893 if !vars.is_empty() {
2894 return Err(FormulaDslError::InvalidArgument {
2895 reason: format!(
2896 "timewiggle() takes named options only; positional args are not supported: {raw}"
2897 ),
2898 }
2899 .into());
2900 }
2901 return Ok(ParsedTerm::TimeWiggle { options });
2902 }
2903 "link" => {
2904 if !vars.is_empty() {
2905 return Err(FormulaDslError::InvalidArgument {
2906 reason: format!(
2907 "link() takes named options only; positional args are not supported: {raw}"
2908 ),
2909 }
2910 .into());
2911 }
2912 return Ok(ParsedTerm::LinkConfig { options });
2913 }
2914 "survmodel" => {
2915 if !vars.is_empty() {
2916 return Err(FormulaDslError::InvalidArgument {
2917 reason: format!(
2918 "survmodel() takes named options only; positional args are not supported: {raw}"
2919 ),
2920 }
2921 .into());
2922 }
2923 return Ok(ParsedTerm::SurvivalConfig { options });
2924 }
2925 "logslope" | "log_slope" | "log_slope_surface" => {
2926 validate_known_term_options("logslope", &options, &[], raw)?;
2927 if vars.len() < 2 {
2928 return Err(FormulaDslError::InvalidArgument {
2929 reason: format!(
2930 "logslope() expects a z column followed by one or more RHS terms; add one logslope(z, ...) declaration per vector-z coordinate: {raw}"
2931 ),
2932 }
2933 .into());
2934 }
2935 let z_column = vars[0].trim();
2936 if !is_exact_ident(z_column) {
2937 return Err(FormulaDslError::InvalidArgument {
2938 reason: format!(
2939 "logslope() z column must be a bare column name, got `{z_column}` in {raw}"
2940 ),
2941 }
2942 .into());
2943 }
2944 let rhs = vars[1..].join(" + ");
2945 let parsed = parse_formula(&format!("__logslope__ ~ {rhs}"))?;
2946 if !parsed.logslope_surfaces.is_empty() {
2947 return Err(FormulaDslError::IncompatibleTerm {
2948 reason: format!(
2949 "logslope() declarations cannot be nested inside another logslope(): {raw}"
2950 ),
2951 }
2952 .into());
2953 }
2954 validate_auxiliary_formula_controls(&parsed, "logslope()")?;
2955 return Ok(ParsedTerm::LogSlopeSurface {
2956 z_column: z_column.to_string(),
2957 terms: parsed.terms,
2958 });
2959 }
2960 "linear" => {
2961 if vars.len() != 1 {
2962 return Err(FormulaDslError::InvalidArgument {
2963 reason: format!("linear() expects exactly one variable: {raw}"),
2964 }
2965 .into());
2966 }
2967 validate_known_term_options(
2968 "linear",
2969 &options,
2970 &["min", "lower", "max", "upper"],
2971 raw,
2972 )?;
2973 let (coefficient_min, coefficient_max) =
2974 parse_linear_constraint_bounds(&options, raw)?;
2975 return Ok(ParsedTerm::Linear {
2976 name: vars[0].clone(),
2977 explicit: true,
2978 coefficient_min,
2979 coefficient_max,
2980 });
2981 }
2982 _ => {
2983 return Err(format!(
2984 "unknown term function `{name}` in '{raw}'. Supported: bounded(), linear(), constrain()/constraint()/box(), nonnegative(), nonpositive(), smooth()/s(), cyclic()/periodic()/cc()/cp(), thinplate()/thin_plate()/tps(), tensor()/interaction()/te(), t2(), ti(), fs(), sz(), group()/re()/factor(), sphere()/sos()/spherical(), s2(), matern(), duchon(), pca(), logslope()/log_slope(), linkwiggle(), timewiggle(), link(), survmodel()"
2985 ));
2986 }
2987 }
2988 }
2989
2990 let ident = raw.trim();
2991 if !is_exact_ident(ident) {
2992 return Err(FormulaDslError::UnknownIdentifier {
2993 reason: format!("unsupported top-level RHS term: {raw}"),
2994 }
2995 .into());
2996 }
2997
2998 Ok(ParsedTerm::Linear {
2999 name: ident.to_string(),
3000 explicit: false,
3001 coefficient_min: None,
3002 coefficient_max: None,
3003 })
3004}
3005
3006pub fn parse_link_choice(
3011 raw: Option<&str>,
3012 flexible_flag: bool,
3013) -> Result<Option<LinkChoice>, FormulaDslError> {
3014 if raw.is_none() && !flexible_flag {
3015 return Ok(None);
3016 }
3017 let Some(v) = raw else {
3018 return Ok(Some(LinkChoice {
3019 mode: LinkMode::Flexible,
3020 link: LinkFunction::Probit,
3021 mixture_components: None,
3022 }));
3023 };
3024 let t = v.trim().to_ascii_lowercase();
3025 if let Some(inner) = t
3026 .strip_prefix("flexible(")
3027 .and_then(|s| s.strip_suffix(')'))
3028 {
3029 if let Some(components_inner) = inner
3030 .strip_prefix("blended(")
3031 .and_then(|s| s.strip_suffix(')'))
3032 .or_else(|| {
3033 inner
3034 .strip_prefix("mixture(")
3035 .and_then(|s| s.strip_suffix(')'))
3036 })
3037 {
3038 parse_link_component_list(components_inner)?;
3039 return Err(FormulaDslError::IncompatibleTerm {
3040 reason:
3041 "flexible(...) does not support blended(...)/mixture(...) links; wiggle is only supported for jointly fit standard links"
3042 .to_string(),
3043 });
3044 }
3045 let link = parse_linkname(inner)?;
3046 if !linkname_supports_joint_wiggle(link) {
3047 return Err(FormulaDslError::IncompatibleTerm {
3048 reason:
3049 "flexible(...) does not support sas/beta-logistic links; wiggle is only supported for jointly fit standard links"
3050 .to_string(),
3051 });
3052 }
3053 return Ok(Some(LinkChoice {
3054 mode: LinkMode::Flexible,
3055 link,
3056 mixture_components: None,
3057 }));
3058 }
3059 if let Some(inner) = t
3060 .strip_prefix("blended(")
3061 .and_then(|s| s.strip_suffix(')'))
3062 .or_else(|| t.strip_prefix("mixture(").and_then(|s| s.strip_suffix(')')))
3063 {
3064 if flexible_flag {
3065 return Err(FormulaDslError::IncompatibleTerm {
3066 reason:
3067 "--flexible-link cannot be combined with --link blended(...)/mixture(...); blended inverse links are not flexible-link mode"
3068 .to_string(),
3069 });
3070 }
3071 let components = parse_link_component_list(inner)?;
3072 return Ok(Some(LinkChoice {
3073 mode: LinkMode::Strict,
3074 link: LinkFunction::Logit,
3075 mixture_components: Some(components),
3076 }));
3077 }
3078
3079 let link = parse_linkname(&t)?;
3080 if flexible_flag && !linkname_supports_joint_wiggle(link) {
3081 return Err(FormulaDslError::IncompatibleTerm {
3082 reason:
3083 "--flexible-link does not support sas/beta-logistic links; wiggle is only supported for jointly fit standard links"
3084 .to_string(),
3085 });
3086 }
3087 Ok(Some(LinkChoice {
3088 mode: if flexible_flag {
3089 LinkMode::Flexible
3090 } else {
3091 LinkMode::Strict
3092 },
3093 link,
3094 mixture_components: None,
3095 }))
3096}
3097
3098pub fn parse_linkname(v: &str) -> Result<LinkFunction, FormulaDslError> {
3099 match v.trim() {
3100 "identity" => Ok(LinkFunction::Identity),
3101 "log" => Ok(LinkFunction::Log),
3102 "logit" | "binomial-logit" => Ok(LinkFunction::Logit),
3103 "probit" | "binomial-probit" => Ok(LinkFunction::Probit),
3104 "cloglog" | "binomial-cloglog" => Ok(LinkFunction::CLogLog),
3105 "loglog" => Ok(LinkFunction::LogLog),
3106 "cauchit" => Ok(LinkFunction::Cauchit),
3107 "sas" => Ok(LinkFunction::Sas),
3108 "beta-logistic" => Ok(LinkFunction::BetaLogistic),
3109 other => Err(FormulaDslError::UnknownIdentifier {
3110 reason: format!(
3111 "unsupported link type '{other}'; \
3112 use one of identity|log|logit|probit|cloglog|loglog|cauchit|binomial-logit|binomial-probit|binomial-cloglog|sas|beta-logistic|blended(...)/mixture(...) or flexible(...). \
3113 Both `--link <type>` (CLI flag) and `link(type=<type>)` (formula term) accept the same set."
3114 ),
3115 }),
3116 }
3117}
3118
3119pub fn parse_link_component(v: &str) -> Result<LinkComponent, String> {
3120 match v.trim() {
3121 "logit" => Ok(LinkComponent::Logit),
3122 "probit" => Ok(LinkComponent::Probit),
3123 "cloglog" => Ok(LinkComponent::CLogLog),
3124 "loglog" => Ok(LinkComponent::LogLog),
3125 "cauchit" => Ok(LinkComponent::Cauchit),
3126 other => Err(FormulaDslError::UnknownIdentifier {
3127 reason: format!(
3128 "unsupported blended-link component '{other}'; use probit|logit|cloglog|loglog|cauchit"
3129 ),
3130 }
3131 .into()),
3132 }
3133}
3134
3135pub fn parse_link_component_list(v: &str) -> Result<Vec<LinkComponent>, String> {
3136 let mut out = Vec::new();
3137 for part in v.split(',') {
3138 let trimmed = part.trim();
3139 if trimmed.is_empty() {
3140 continue;
3141 }
3142 let comp = parse_link_component(trimmed)?;
3143 if out.contains(&comp) {
3144 return Err(FormulaDslError::IncompatibleTerm {
3145 reason: "blended(...) cannot contain duplicate components".to_string(),
3146 }
3147 .into());
3148 }
3149 out.push(comp);
3150 }
3151 if out.len() < 2 {
3152 return Err(FormulaDslError::InvalidArgument {
3153 reason: "blended(...) requires at least two components".to_string(),
3154 }
3155 .into());
3156 }
3157 Ok(out)
3158}