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