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