diff --git a/benches/common/mod.rs b/benches/common/mod.rs index 53fdf1aa..9e59e7f9 100644 --- a/benches/common/mod.rs +++ b/benches/common/mod.rs @@ -265,9 +265,9 @@ pub fn assay_error_models() -> AssayErrorModels { .expect("plasma assay error model") } -/// Residual error model. Indexes outputs by dense `usize` — assumes a single output at index 0. +/// Residual error model for the `plasma` output. pub fn residual_error_models() -> ResidualErrorModels { - ResidualErrorModels::new().add(0, ResidualErrorModel::constant(0.2)) + ResidualErrorModels::new().add("plasma", ResidualErrorModel::constant(0.2)) } // ───────────────────────────── Handwritten factories ───────────────── diff --git a/pharmsol-dsl/src/analyze.rs b/pharmsol-dsl/src/analyze.rs index 2750efd5..87428d69 100644 --- a/pharmsol-dsl/src/analyze.rs +++ b/pharmsol-dsl/src/analyze.rs @@ -11,10 +11,7 @@ use crate::name_match::{ common_prefix_len, edit_distance, is_high_confidence_match, is_single_adjacent_transposition, }; use crate::syntax; -use crate::{ - ModelKind, RouteKind, MAX_CONST_USIZE, NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, - RATE_FUNCTION_NAME, -}; +use crate::{ModelKind, RouteKind, MAX_CONST_USIZE, RATE_FUNCTION_NAME}; const RESERVED_NAMES: &[&str] = &[ "abs", @@ -628,7 +625,6 @@ impl<'a> Analyzer<'a> { let mut route_kinds = BTreeMap::>>::new(); if let Some(block) = block { for route in &block.routes { - self.validate_route_label_name(&route.input)?; let (id, prefer_for_rate) = match route_kinds.get_mut(&route.input.text) { Some(seen_kinds) => { let duplicate = route.kind.is_none() @@ -885,9 +881,6 @@ impl<'a> Analyzer<'a> { collect_bare_assignment_names(statements, &mut seen, &mut collected_idents); let mut symbols = Vec::new(); for ident in collected_idents { - if matches!(kind, SymbolKind::Output) { - self.validate_output_label_name(&ident)?; - } if matches!(kind, SymbolKind::Derived) { if let Some(parameter) = self.globals.parameters.get(&ident.text).copied() { return Err(AnalysisAssist::default() @@ -1634,18 +1627,12 @@ impl<'a> Analyzer<'a> { callee.span, )); } - if let syntax::ExprKind::Number(value) = &args[0].kind { - if let Some(suffix) = numeric_label_literal_suffix(*value) { - return Err(self.bare_numeric_route_error(args[0].span, &suffix)); - } - } let syntax::ExprKind::Name(route_name) = &args[0].kind else { return Err(AnalysisError::new( "`rate` expects a route identifier argument", args[0].span, )); }; - self.validate_route_label_name(route_name)?; let route = self .globals .routes @@ -2067,100 +2054,6 @@ impl<'a> Analyzer<'a> { Ok(id) } - fn validate_route_label_name(&self, label: &syntax::Ident) -> Result<(), AnalysisError> { - if let Some(suffix) = bare_numeric_label(&label.text) { - return Err(self.bare_numeric_route_error(label.span, suffix)); - } - if let Some(suffix) = canonical_numeric_suffix(&label.text, NUMERIC_OUTPUT_PREFIX) { - return Err(self.wrong_prefix_route_error(label, suffix)); - } - Ok(()) - } - - fn validate_output_label_name(&self, label: &syntax::Ident) -> Result<(), AnalysisError> { - if let Some(suffix) = bare_numeric_label(&label.text) { - return Err(self.bare_numeric_output_error(label.span, suffix)); - } - if let Some(suffix) = canonical_numeric_suffix(&label.text, NUMERIC_ROUTE_PREFIX) { - return Err(self.wrong_prefix_output_error(label, suffix)); - } - Ok(()) - } - - fn bare_numeric_route_error(&self, span: Span, suffix: &str) -> AnalysisError { - let replacement = format!("{NUMERIC_ROUTE_PREFIX}{suffix}"); - AnalysisAssist::default() - .help("numeric route labels must use the `input_` form in authored DSL") - .replacement_suggestion( - span, - replacement.clone(), - format!("use `{replacement}`"), - Applicability::Always, - ) - .apply(AnalysisError::new( - format!( - "bare numeric route labels are not allowed in the DSL; use `{replacement}` instead" - ), - span, - )) - } - - fn bare_numeric_output_error(&self, span: Span, suffix: &str) -> AnalysisError { - let replacement = format!("{NUMERIC_OUTPUT_PREFIX}{suffix}"); - AnalysisAssist::default() - .help("numeric output labels must use the `outeq_` form in authored DSL") - .replacement_suggestion( - span, - replacement.clone(), - format!("use `{replacement}`"), - Applicability::Always, - ) - .apply(AnalysisError::new( - format!( - "bare numeric output labels are not allowed in the DSL; use `{replacement}` instead" - ), - span, - )) - } - - fn wrong_prefix_route_error(&self, label: &syntax::Ident, suffix: &str) -> AnalysisError { - let replacement = format!("{NUMERIC_ROUTE_PREFIX}{suffix}"); - AnalysisAssist::default() - .help("numeric route labels use the `input_` prefix") - .replacement_suggestion( - label.span, - replacement.clone(), - format!("use `{replacement}`"), - Applicability::Always, - ) - .apply(AnalysisError::new( - format!( - "`{}` is an output label and cannot be used as a route; use `{replacement}` here", - label.text - ), - label.span, - )) - } - - fn wrong_prefix_output_error(&self, label: &syntax::Ident, suffix: &str) -> AnalysisError { - let replacement = format!("{NUMERIC_OUTPUT_PREFIX}{suffix}"); - AnalysisAssist::default() - .help("numeric output labels use the `outeq_` prefix") - .replacement_suggestion( - label.span, - replacement.clone(), - format!("use `{replacement}`"), - Applicability::Always, - ) - .apply(AnalysisError::new( - format!( - "`{}` is a route label and cannot be used as an output target; use `{replacement}` here", - label.text - ), - label.span, - )) - } - fn insert_local_symbol( &mut self, env: &mut BlockEnv, @@ -2726,22 +2619,6 @@ fn allows_route_output_name_overlap(existing: SymbolKind, new: SymbolKind) -> bo ) } -fn bare_numeric_label(src: &str) -> Option<&str> { - (!src.is_empty() && src.chars().all(|ch| ch.is_ascii_digit())).then_some(src) -} - -fn canonical_numeric_suffix<'a>(src: &'a str, prefix: &str) -> Option<&'a str> { - let suffix = src.strip_prefix(prefix)?; - (!suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit())).then_some(suffix) -} - -fn numeric_label_literal_suffix(value: f64) -> Option { - // `usize::MAX as f64` rounds up to 2^64; keep the bound exclusive so the - // cast below never saturates. - (value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value < usize::MAX as f64) - .then(|| (value as usize).to_string()) -} - #[derive(Default)] struct Globals { all_names: BTreeMap, diff --git a/pharmsol-dsl/src/authoring.rs b/pharmsol-dsl/src/authoring.rs index ee262761..e5a8263d 100644 --- a/pharmsol-dsl/src/authoring.rs +++ b/pharmsol-dsl/src/authoring.rs @@ -6,7 +6,7 @@ use super::syntax::*; use crate::name_match::{ common_prefix_len, edit_distance, is_high_confidence_match, is_single_adjacent_transposition, }; -use crate::{NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, RATE_FUNCTION_NAME}; +use crate::RATE_FUNCTION_NAME; const DEFAULT_MODEL_NAME: &str = "main"; @@ -1638,49 +1638,6 @@ fn parse_label_segment(src: &str, abs_start: usize, kind: LabelKind) -> Result "output label", } } - - fn noun(self) -> &'static str { - match self { - Self::Route => "route", - Self::Output => "output", - } - } - - fn noun_phrase(self) -> &'static str { - match self { - Self::Route => "a route", - Self::Output => "an output", - } - } - - fn wrong_kind_phrase(self) -> &'static str { - match self { - Self::Route => "an output", - Self::Output => "a route", - } - } - - fn canonical_label(self, suffix: &str) -> String { - match self { - Self::Route => format!("{NUMERIC_ROUTE_PREFIX}{suffix}"), - Self::Output => format!("{NUMERIC_OUTPUT_PREFIX}{suffix}"), - } - } - - fn wrong_prefix(self) -> &'static str { - match self { - Self::Route => NUMERIC_OUTPUT_PREFIX, - Self::Output => NUMERIC_ROUTE_PREFIX, - } - } - - fn prefix_pattern(self) -> &'static str { - match self { - Self::Route => "input_", - Self::Output => "outeq_", - } - } -} - -fn bare_numeric_label(src: &str) -> Option<&str> { - (!src.is_empty() && src.chars().all(|ch| ch.is_ascii_digit())).then_some(src) -} - -fn canonical_numeric_suffix<'a>(src: &'a str, prefix: &str) -> Option<&'a str> { - let suffix = src.strip_prefix(prefix)?; - (!suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit())).then_some(suffix) } fn parse_place_at(src: &str, abs_start: usize) -> Result { diff --git a/pharmsol-dsl/src/execution.rs b/pharmsol-dsl/src/execution.rs index 2af5fc55..da562bcd 100644 --- a/pharmsol-dsl/src/execution.rs +++ b/pharmsol-dsl/src/execution.rs @@ -1573,20 +1573,20 @@ out(cp) = central / v ~ continuous() } #[test] - fn canonical_numeric_channel_names_flow_into_execution_metadata_and_abi() { - let src = r#"name = canonical_numeric_channels + fn numeric_channel_names_flow_into_execution_metadata_and_abi() { + let src = r#"name = numeric_channels kind = ode params = ke, v states = depot, central -outputs = cp, outeq_2 +outputs = cp, 2 -dx(depot) = bolus(input_10) - ke * depot +dx(depot) = bolus(10) - ke * depot dx(central) = infusion(iv) - ke * central out(cp) = central / v -out(outeq_2) = depot / v +out(2) = depot / v "#; let model = crate::parse_model(src).expect("authoring model parses"); @@ -1600,7 +1600,7 @@ out(outeq_2) = depot / v .iter() .map(|route| route.name.as_str()) .collect::>(), - vec!["input_10", "iv"] + vec!["10", "iv"] ); assert_eq!( compiled @@ -1609,7 +1609,7 @@ out(outeq_2) = depot / v .iter() .map(|output| output.name.as_str()) .collect::>(), - vec!["cp", "outeq_2"] + vec!["cp", "2"] ); assert_eq!( compiled @@ -1619,7 +1619,7 @@ out(outeq_2) = depot / v .iter() .map(|slot| slot.name.as_str()) .collect::>(), - vec!["input_10", "iv"] + vec!["10", "iv"] ); assert_eq!( compiled @@ -1629,7 +1629,7 @@ out(outeq_2) = depot / v .iter() .map(|slot| slot.name.as_str()) .collect::>(), - vec!["cp", "outeq_2"] + vec!["cp", "2"] ); } diff --git a/pharmsol-dsl/src/lib.rs b/pharmsol-dsl/src/lib.rs index 633958d2..30c365a5 100644 --- a/pharmsol-dsl/src/lib.rs +++ b/pharmsol-dsl/src/lib.rs @@ -119,10 +119,6 @@ pub mod syntax; #[cfg(test)] mod test_fixtures; -/// Canonical prefix for numeric route labels such as `input_1`. -pub const NUMERIC_ROUTE_PREFIX: &str = "input_"; -/// Canonical prefix for numeric output labels such as `outeq_1`. -pub const NUMERIC_OUTPUT_PREFIX: &str = "outeq_"; /// Upper bound for compile-time-resolved sizes: state array sizes, route /// destination indices, and particle counts. Keeps compiled models within a /// comfortably allocatable state space. diff --git a/pharmsol-dsl/src/parser.rs b/pharmsol-dsl/src/parser.rs index 203d80fa..30c4b9ce 100644 --- a/pharmsol-dsl/src/parser.rs +++ b/pharmsol-dsl/src/parser.rs @@ -109,9 +109,24 @@ pub(crate) fn parse_place_fragment(src: &str) -> Result { /// `((((...))))`; the limit is far beyond any realistic model. pub const MAX_NESTING_DEPTH: usize = 256; +/// Integer literals (ASCII digits only) are accepted as route and output +/// labels; the label is the literal text. +fn is_integer_literal_text(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn takes_route_label_argument(callee: &str) -> bool { + matches!(callee, "bolus" | "infusion" | crate::RATE_FUNCTION_NAME) +} + +fn is_route_input_name(expr: &Expr) -> bool { + matches!(&expr.kind, ExprKind::Name(name) if matches!(name.text.as_str(), "bolus" | "infusion")) +} + struct Parser { tokens: Vec, index: usize, + src: String, src_len: usize, layout_boundaries: Vec, depth: usize, @@ -135,14 +150,15 @@ enum StatementContext { impl Parser { fn new(src: &str) -> Result { - Ok(Self::from_tokens(lex(src)?, src.len())) + Ok(Self::from_tokens(lex(src)?, src)) } - fn from_tokens(tokens: Vec, src_len: usize) -> Self { + fn from_tokens(tokens: Vec, src: &str) -> Self { Self { tokens, index: 0, - src_len, + src: src.to_string(), + src_len: src.len(), layout_boundaries: Vec::new(), depth: 0, fatal: false, @@ -617,7 +633,7 @@ impl Parser { TokenKind::Ident(name) if name == "bolus" || name == "infusion" => { let followed_by_label = self .next_same_line_index(self.index) - .is_some_and(|next| matches!(self.tokens[next].kind, TokenKind::Ident(_))); + .is_some_and(|next| self.is_label_token(next)); if followed_by_label { let keyword = self.bump().expect("peeked kind keyword exists"); let kind = match keyword.kind { @@ -986,16 +1002,12 @@ impl Parser { })?; match token.kind { TokenKind::Ident(name) => Ok(Ident::new(name, token.span)), - // `usize::MAX as f64` rounds up to 2^64; keep the bound exclusive - // so the cast never saturates. - TokenKind::Number(value) - if value.is_finite() - && value >= 0.0 - && value.fract() == 0.0 - && value < usize::MAX as f64 => - { - Ok(Ident::new((value as usize).to_string(), token.span)) - } + // Numeric labels are plain names: keep the literal exactly as + // written (`1`, `01`, `10`), so it matches data labels verbatim. + TokenKind::Number(_) if self.is_integer_literal(token.span) => Ok(Ident::new( + &self.src[token.span.start..token.span.end], + token.span, + )), other => Err(ParseError::new( format!( "expected {expected} identifier or non-negative integer, found {}", @@ -1006,6 +1018,25 @@ impl Parser { } } + fn is_integer_literal(&self, span: Span) -> bool { + is_integer_literal_text(&self.src[span.start..span.end]) + } + + /// Route-label arguments such as the `1` in `rate(1)` or `bolus(1)` are + /// names, not numbers: rewrite an integer literal into a name carrying + /// its source text. + fn integer_literal_as_label(&self, expr: Expr) -> Expr { + if matches!(expr.kind, ExprKind::Number(_)) && self.is_integer_literal(expr.span) { + let ident = Ident::new(&self.src[expr.span.start..expr.span.end], expr.span); + Expr { + span: expr.span, + kind: ExprKind::Name(ident), + } + } else { + expr + } + } + fn parse_ident(&mut self) -> Result { let token = self .bump() @@ -1131,6 +1162,11 @@ impl Parser { "index expression", )?; let span = expr.span.join(close.span); + let index = if is_route_input_name(&expr) { + self.integer_literal_as_label(index) + } else { + index + }; expr = Expr { span, kind: ExprKind::Index { @@ -1148,7 +1184,11 @@ impl Parser { return Err(ParseError::new("only identifiers can be called", expr.span)); } }; - let args = self.parse_expr_list(&open, TokenKindMatcher::RPAREN)?; + let mut args = self.parse_expr_list(&open, TokenKindMatcher::RPAREN)?; + if takes_route_label_argument(&callee.text) && args.len() == 1 { + let arg = args.pop().expect("one argument"); + args.push(self.integer_literal_as_label(arg)); + } let close = self.expect_closing( |kind| matches!(kind, TokenKind::RParen), "`)`", @@ -1478,22 +1518,38 @@ impl Parser { } fn line_starts_route_decl(&self, index: usize) -> bool { - let Some(TokenKind::Ident(_)) = self.tokens.get(index).map(|token| &token.kind) else { + if !self.is_label_token(index) { return false; - }; + } let Some(next) = self.next_same_line_index(index) else { return false; }; match self.tokens[next].kind { TokenKind::Arrow => true, - // Kinded form: `bolus input_1 -> central`. - TokenKind::Ident(_) => self + // Kinded form: `bolus 1 -> central`. + _ if self.is_label_token(next) => self .next_same_line_index(next) .is_some_and(|after| matches!(self.tokens[after].kind, TokenKind::Arrow)), _ => false, } } + /// A route or output label: an identifier or an integer literal. + fn is_label_token(&self, index: usize) -> bool { + match self.tokens.get(index) { + Some(Token { + kind: TokenKind::Ident(_), + .. + }) => true, + Some(Token { + kind: TokenKind::Number(_), + span, + .. + }) => self.is_integer_literal(*span), + _ => false, + } + } + fn line_starts_assignment_target(&self, index: usize) -> bool { if !matches!( self.tokens.get(index).map(|token| &token.kind), @@ -1525,10 +1581,9 @@ impl Parser { } fn line_starts_numeric_output_assignment(&self, index: usize) -> bool { - matches!( - self.tokens.get(index).map(|token| &token.kind), - Some(TokenKind::Number(_)) - ) && self + self.tokens.get(index).is_some_and(|token| { + matches!(token.kind, TokenKind::Number(_)) && self.is_integer_literal(token.span) + }) && self .next_same_line_index(index) .is_some_and(|next| matches!(self.tokens[next].kind, TokenKind::Eq)) } diff --git a/pharmsol-dsl/tests/dsl_authoring_edge_cases.rs b/pharmsol-dsl/tests/dsl_authoring_edge_cases.rs index 8b55fa96..e665ef68 100644 --- a/pharmsol-dsl/tests/dsl_authoring_edge_cases.rs +++ b/pharmsol-dsl/tests/dsl_authoring_edge_cases.rs @@ -190,17 +190,17 @@ out(cp) = central ~ continous() } #[test] -fn mixed_named_and_prefixed_numeric_output_labels_compile_and_round_trip() { +fn mixed_named_and_numeric_output_labels_compile_and_round_trip() { let src = r#" name = mixed_output_labels kind = ode params = ke, v states = central -outputs = cp, outeq_0, outeq_1 +outputs = cp, 0, 1 ddt(central) = infusion(iv) - ke * central out(cp) = central / v -out(outeq_0) = 2 * central / v -out(outeq_1) = 3 * central / v +out(0) = 2 * central / v +out(1) = 3 * central / v "#; let module = parse_module(src).expect("mixed output labels should parse in authoring DSL"); @@ -218,7 +218,7 @@ out(outeq_1) = 3 * central / v .iter() .map(|output| output.name.as_str()) .collect::>(), - vec!["cp", "outeq_0", "outeq_1"] + vec!["cp", "0", "1"] ); assert_eq!( compiled @@ -236,170 +236,134 @@ out(outeq_1) = 3 * central / v assert_eq!(rendered, reparsed.to_string()); } -#[test] -fn prefixed_numeric_route_and_output_labels_compile_and_round_trip() { - let src = r#" -name = prefixed_numeric_route_output_labels -kind = ode -params = ke, v -states = central -outputs = outeq_1 -ddt(central) = infusion(input_1) - ke * central -out(outeq_1) = central / v -"#; - - let module = parse_module(src).expect("prefixed numeric route/output labels should parse"); - let model = module - .models - .first() - .expect("authoring DSL should produce one model"); - let analyzed = - analyze_model(model).expect("prefixed numeric route/output labels should analyze"); - let compiled = compile_analyzed_model(&analyzed) - .expect("prefixed numeric route/output labels should compile"); - - assert_eq!( +fn compiled_label_names(src: &str) -> (Vec, Vec) { + let module = parse_module(src).expect("model parses"); + let model = module.models.first().expect("one model"); + let analyzed = analyze_model(model).expect("model analyzes"); + let compiled = compile_analyzed_model(&analyzed).expect("model compiles"); + let rendered = module.to_string(); + let reparsed = parse_module(&rendered).expect("rendered model reparses"); + assert_eq!(rendered, reparsed.to_string()); + ( compiled .metadata .routes .iter() - .map(|route| route.name.as_str()) - .collect::>(), - vec!["input_1"] - ); - assert_eq!( + .map(|route| route.name.clone()) + .collect(), compiled .metadata .outputs .iter() - .map(|output| output.name.as_str()) - .collect::>(), - vec!["outeq_1"] - ); - - let rendered = module.to_string(); - let reparsed = parse_module(&rendered).expect("rendered shared-label model should reparse"); - - assert_eq!(rendered, reparsed.to_string()); + .map(|output| output.name.clone()) + .collect(), + ) } #[test] -fn rejects_authoring_bare_numeric_output_declarations() { +fn numeric_route_and_output_labels_compile_and_round_trip() { let src = r#" -name = numeric_outputs +name = numeric_route_output_labels kind = ode -states = central -outputs = 1, 2 -ddt(central) = 0 -out(1) = central +params = ke, v, tlag, f +states = depot, central +outputs = 1, 10 +lag(1) = tlag +ddt(depot) = bolus(1) * f - ke * depot +ddt(central) = infusion[10] + ke * depot - ke * central +out(1) = central / v +out(10) = depot "#; - let err = parse_model(src).expect_err("bare numeric output declarations must fail"); - let rendered = err.render(src); + let (routes, outputs) = compiled_label_names(src); + assert_eq!(routes, vec!["1", "10"]); + assert_eq!(outputs, vec!["1", "10"]); +} - assert!( - rendered.contains( - "bare numeric output labels are not allowed in the DSL; use `outeq_1` instead" - ), - "{}", - rendered - ); - assert!( - rendered.contains("suggestion: use `outeq_1`"), - "{}", - rendered - ); +#[test] +fn structured_numeric_route_and_output_labels_compile() { + let src = r#" +model numeric_labels { + kind ode + parameters { ke, v } + states { central } + routes { + bolus 1 -> central, + infusion 2 -> central, + 10 -> central + } + dynamics { + ddt(central) = rate(2) + rate(10) - ke * central + } + outputs { + 2 = central / v + 10 = central + } +} +"#; + + let (routes, outputs) = compiled_label_names(src); + assert_eq!(routes, vec!["1", "2", "10"]); + assert_eq!(outputs, vec!["2", "10"]); } #[test] -fn rejects_authoring_bare_numeric_route_labels() { +fn numeric_labels_keep_their_literal_text() { + // `01` and `1` are different names; neither is normalized. let src = r#" -name = numeric_routes +name = literal_numeric_labels kind = ode +params = v states = central -outputs = cp -ddt(central) = infusion(1) + 0 -out(cp) = central +outputs = 01, 1 +ddt(central) = infusion(01) + infusion(1) +out(01) = central / v +out(1) = central "#; - let err = parse_model(src).expect_err("bare numeric route labels must fail"); - let rendered = err.render(src); - - assert!( - rendered.contains( - "bare numeric route labels are not allowed in the DSL; use `input_1` instead" - ), - "{}", - rendered - ); - assert!( - rendered.contains("suggestion: use `input_1`"), - "{}", - rendered - ); + let (routes, outputs) = compiled_label_names(src); + assert_eq!(routes, vec!["01", "1"]); + assert_eq!(outputs, vec!["01", "1"]); } #[test] -fn rejects_structured_bare_numeric_output_targets() { +fn rejects_non_integer_numeric_labels() { let src = r#" -model numeric_output_target { +model float_label { kind ode states { central } - outputs { - 1 = central - } + routes { 1.0 -> central } + dynamics { ddt(central) = 0 } + outputs { cp = central } } "#; - let model = parse_model(src).expect("structured model parses"); - let err = analyze_model(&model).expect_err("bare numeric output target must fail"); - let rendered = err.render(src); - + let err = parse_model(src).expect_err("float route labels must fail"); assert!( - rendered.contains( - "bare numeric output labels are not allowed in the DSL; use `outeq_1` instead" - ), + err.render(src).contains("expected route label"), "{}", - rendered - ); - assert!( - rendered.contains("suggestion: use `outeq_1`"), - "{}", - rendered + err.render(src) ); } #[test] -fn rejects_structured_bare_numeric_route_labels() { +fn prefixed_looking_labels_are_ordinary_names() { + // `input_1` / `outeq_1` carry no special meaning and may be used on + // either kind of label. let src = r#" -model numeric_route_label { - kind ode - states { central } - routes { - 1 -> central - } - outputs { - cp = central - } -} +name = prefixed_looking_labels +kind = ode +params = ke, v +states = central +outputs = outeq_1, input_1 +ddt(central) = infusion(input_1) + infusion(outeq_1) - ke * central +out(outeq_1) = central / v +out(input_1) = central "#; - let model = parse_model(src).expect("structured model parses"); - let err = analyze_model(&model).expect_err("bare numeric route label must fail"); - let rendered = err.render(src); - - assert!( - rendered.contains( - "bare numeric route labels are not allowed in the DSL; use `input_1` instead" - ), - "{}", - rendered - ); - assert!( - rendered.contains("suggestion: use `input_1`"), - "{}", - rendered - ); + let (routes, outputs) = compiled_label_names(src); + assert_eq!(routes, vec!["input_1", "outeq_1"]); + assert_eq!(outputs, vec!["outeq_1", "input_1"]); } fn collect_route_input_indices( @@ -460,8 +424,8 @@ kind = ode params = ke, v, tlag states = central outputs = cp -lag(input_1) = tlag -ddt(central) = bolus(input_1) + infusion(input_1) - ke * central +lag(1) = tlag +ddt(central) = bolus(1) + infusion(1) - ke * central out(cp) = central / v "#; @@ -472,7 +436,7 @@ out(cp) = central / v let routes = &compiled.metadata.routes; assert_eq!(routes.len(), 2); - assert!(routes.iter().all(|route| route.name == "input_1")); + assert!(routes.iter().all(|route| route.name == "1")); let bolus = routes .iter() .find(|route| route.kind == Some(pharmsol_dsl::RouteKind::Bolus)) @@ -486,7 +450,7 @@ out(cp) = central / v assert!(bolus.has_lag); assert!(!infusion.has_lag); - // The injected `rate(input_1)` term must read the infusion input slot. + // The injected `rate(1)` term must read the infusion input slot. let mut indices = Vec::new(); if let Some(function) = compiled.function(pharmsol_dsl::execution::ModelFunctionKind::Dynamics) { @@ -504,14 +468,13 @@ name = duplicate_bolus kind = ode states = central outputs = cp -ddt(central) = bolus(input_1) + bolus(input_1) + 0 +ddt(central) = bolus(1) + bolus(1) + 0 out(cp) = central "#; let err = parse_model(src).expect_err("repeated bolus terms must fail"); assert!( - err.render(src) - .contains("use one `bolus(input_1) * scale` term"), + err.render(src).contains("use one `bolus(1) * scale` term"), "{}", err.render(src) ); @@ -523,7 +486,7 @@ fn rejects_duplicate_route_names_in_canonical_models() { model dup_routes { kind ode states { central } - routes { input_1 -> central, input_1 -> central } + routes { 1 -> central, 1 -> central } dynamics { ddt(central) = 0 } outputs { cp = central } } @@ -532,7 +495,7 @@ model dup_routes { let model = parse_model(src).expect("canonical model parses"); let err = analyze_model(&model).expect_err("duplicate canonical route names must fail"); assert!( - err.render(src).contains("duplicate route `input_1`"), + err.render(src).contains("duplicate route `1`"), "{}", err.render(src) ); @@ -546,8 +509,8 @@ model dosing { parameters { ke, v } states { central } routes { - bolus input_1 -> central, - infusion input_1 -> central + bolus 1 -> central, + infusion 1 -> central } dynamics { ddt(central) = -ke * central @@ -566,7 +529,7 @@ model dosing { let routes = &compiled.metadata.routes; assert_eq!(routes.len(), 2); - assert!(routes.iter().all(|route| route.name == "input_1")); + assert!(routes.iter().all(|route| route.name == "1")); let bolus = routes .iter() .find(|route| route.kind == Some(pharmsol_dsl::RouteKind::Bolus)) @@ -580,11 +543,8 @@ model dosing { // Rendering preserves route kinds and reparses identically. let rendered = module.to_string(); - assert!(rendered.contains("bolus input_1 -> central"), "{rendered}"); - assert!( - rendered.contains("infusion input_1 -> central"), - "{rendered}" - ); + assert!(rendered.contains("bolus 1 -> central"), "{rendered}"); + assert!(rendered.contains("infusion 1 -> central"), "{rendered}"); let reparsed = parse_module(&rendered).expect("rendered canonical model reparses"); assert_eq!(rendered, reparsed.to_string()); } @@ -619,8 +579,8 @@ model mixed_kind { kind ode states { central } routes { - input_1 -> central, - bolus input_1 -> central + 1 -> central, + bolus 1 -> central } dynamics { ddt(central) = 0 } outputs { cp = central } @@ -630,7 +590,7 @@ model mixed_kind { let model = parse_model(src).expect("mixed-kind model parses"); let err = analyze_model(&model).expect_err("kind-less and kinded duplicates must fail"); assert!( - err.render(src).contains("duplicate route `input_1`"), + err.render(src).contains("duplicate route `1`"), "{}", err.render(src) ); @@ -646,9 +606,9 @@ model triple { kind ode states { central } routes { - bolus input_1 -> central, - infusion input_1 -> central, - bolus input_1 -> central + bolus 1 -> central, + infusion 1 -> central, + bolus 1 -> central } dynamics { ddt(central) = 0 } outputs { cp = central } @@ -658,19 +618,19 @@ model triple { let model = parse_model(src).expect("triple-route model parses"); let err = analyze_model(&model).expect_err("reappearing same-kind route must fail"); assert!( - err.render(src).contains("duplicate route `input_1`"), + err.render(src).contains("duplicate route `1`"), "{}", err.render(src) ); } #[test] -fn rejects_rate_numeric_literals_with_prefixed_guidance() { +fn rate_accepts_numeric_route_labels() { let src = r#" model numeric_rate_arg { kind ode states { central } - routes { input_5 -> central } + routes { 5 -> central } dynamics { ddt(central) = rate(5) } @@ -680,64 +640,17 @@ model numeric_rate_arg { } "#; - let model = parse_model(src).expect("structured model parses"); - let err = analyze_model(&model).expect_err("bare numeric rate argument must fail"); - let rendered = err.render(src); + let (routes, _) = compiled_label_names(src); + assert_eq!(routes, vec!["5"]); + let unknown = src.replace("rate(5)", "rate(6)"); + let model = parse_model(&unknown).expect("structured model parses"); + let err = analyze_model(&model).expect_err("undeclared numeric route must fail"); assert!( - rendered.contains( - "bare numeric route labels are not allowed in the DSL; use `input_5` instead" - ), + err.render(&unknown) + .contains("unknown route `6` in `rate(...)`"), "{}", - rendered - ); - assert!( - rendered.contains("suggestion: use `input_5`"), - "{}", - rendered - ); -} - -#[test] -fn rejects_wrong_prefix_labels_in_authored_dsl() { - let src = r#" -name = wrong_prefix_route -kind = ode -states = central -outputs = cp -ddt(central) = infusion(outeq_1) + 0 -out(cp) = central -"#; - - let err = parse_model(src).expect_err("wrong-prefix route labels must fail"); - let rendered = err.render(src); - - assert!( - rendered.contains( - "`outeq_1` is an output label and cannot be used as a route; use `input_1` here" - ), - "{}", - rendered - ); - - let src = r#" -name = wrong_prefix_output -kind = ode -states = central -outputs = cp -ddt(central) = infusion(iv) + 0 -out(input_1) = central -"#; - - let err = parse_model(src).expect_err("wrong-prefix output labels must fail"); - let rendered = err.render(src); - - assert!( - rendered.contains( - "`input_1` is a route label and cannot be used as an output; use `outeq_1` here" - ), - "{}", - rendered + err.render(&unknown) ); } diff --git a/pharmsol-dsl/tests/frontend_hardening.rs b/pharmsol-dsl/tests/frontend_hardening.rs index f807abc7..bdf15ab9 100644 --- a/pharmsol-dsl/tests/frontend_hardening.rs +++ b/pharmsol-dsl/tests/frontend_hardening.rs @@ -515,7 +515,7 @@ fn malformed_inputs_fail_with_errors_not_panics() { "kind = invalid", "model m { kind ode states { x[] } }", "model m { kind ode routes { oral -> } }", - "model m { kind ode outputs { 18446744073709551616 = central } }", + "model m { kind ode outputs { 1.5 = central } }", ]; for src in cases { assert!( diff --git a/pharmsol-dsl/tests/ode_rhs_inputs.rs b/pharmsol-dsl/tests/ode_rhs_inputs.rs index 6d0ab070..c7788cbf 100644 --- a/pharmsol-dsl/tests/ode_rhs_inputs.rs +++ b/pharmsol-dsl/tests/ode_rhs_inputs.rs @@ -34,7 +34,7 @@ fn brackets_and_derived_bolus_scales_are_supported() { #[test] fn shared_bolus_and_infusion_label_is_supported() { - let source = model("dx(gut) = bolus(input_1) * scale\ndx(central) = infusion(input_1) * v"); + let source = model("dx(gut) = bolus(1) * scale\ndx(central) = infusion(1) * v"); analyze_model(&parse_model(&source).unwrap()).unwrap(); } diff --git a/pharmsol-macros/src/analysis.rs b/pharmsol-macros/src/analysis.rs index 3d2fb1d0..0cbacf8b 100644 --- a/pharmsol-macros/src/analysis.rs +++ b/pharmsol-macros/src/analysis.rs @@ -78,27 +78,30 @@ impl<'ast> Visit<'ast> for ClosureBodyUsage { pub(crate) struct IndexRewriteTarget { container: Ident, - labels: HashMap, + labels: HashMap, } impl IndexRewriteTarget { - pub(crate) fn new(container: Ident, labels: HashMap) -> Self { + pub(crate) fn new(container: Ident, labels: HashMap) -> Self { Self { container, labels } } } -/// Maps user-visible numeric labels onto the internal dense indices used by the -/// generated closures. +/// Maps numeric labels onto the dense slots their declarations were assigned. +/// +/// A numeric label is a name: `y[2]` for a model declaring `outputs: [2]` +/// becomes `y[0]`, the slot of the declaration named `2`. Labels are matched +/// by their literal text, never by numeric value. pub(crate) struct NumericLabelRewriter { index_targets: Vec, - route_labels: Option>, + route_labels: Option>, } impl NumericLabelRewriter { pub(crate) fn rewrite( expr: &Expr, index_targets: Vec, - route_labels: Option>, + route_labels: Option>, ) -> Expr { let mut rewritten = expr.clone(); let mut rewriter = Self { @@ -109,7 +112,7 @@ impl NumericLabelRewriter { rewritten } - fn target_labels(&self, path: &syn::ExprPath) -> Option<&HashMap> { + fn target_labels(&self, path: &syn::ExprPath) -> Option<&HashMap> { if path.qself.is_some() || path.path.leading_colon.is_some() || path.path.segments.len() != 1 @@ -139,8 +142,8 @@ impl NumericLabelRewriter { }; let entries = entries.into_iter().map(|mut entry| { - if let Some(value) = entry.route.numeric_value() { - if let Some(internal_index) = route_labels.get(&value) { + if let Some(text) = entry.route.numeric_text() { + if let Some(internal_index) = route_labels.get(&text) { entry.route = SymbolicIndex::numeric(*internal_index); } } @@ -172,16 +175,19 @@ impl VisitMut for NumericLabelRewriter { let Lit::Int(lit) = &expr_lit.lit else { return; }; - let Ok(external_index) = lit.base10_parse::() else { - return; - }; - let Some(internal_index) = labels.get(&external_index) else { + let Some(internal_index) = labels.get(&lit.to_string()) else { return; }; + // The slot literal is macro output: resolve it at the macro's mixed + // site so lints do not read the label's source text (`0`) as the + // text of this different literal. *expr_index.index = Expr::Lit(syn::ExprLit { attrs: Vec::new(), - lit: Lit::Int(LitInt::new(&internal_index.to_string(), lit.span())), + lit: Lit::Int(LitInt::new( + &internal_index.to_string(), + lit.span().resolved_at(Span::mixed_site()), + )), }); } diff --git a/pharmsol-macros/src/input/analytical.rs b/pharmsol-macros/src/input/analytical.rs index a544cddb..5c140eb4 100644 --- a/pharmsol-macros/src/input/analytical.rs +++ b/pharmsol-macros/src/input/analytical.rs @@ -275,7 +275,7 @@ mod tests { #[test] fn analytical_allows_shared_label_across_bolus_and_infusion_routes() { let input = syn::parse_str::( - "name: \"demo\", params: [ka, ke, v], states: [gut, central], outputs: [cp], routes: [bolus(input_1) -> gut, infusion(input_1) -> central], structure: one_compartment_with_absorption, out: |x, p, t, cov, y| {}", + "name: \"demo\", params: [ka, ke, v], states: [gut, central], outputs: [cp], routes: [bolus(1) -> gut, infusion(1) -> central], structure: one_compartment_with_absorption, out: |x, p, t, cov, y| {}", ) .expect("bolus and infusion sharing a label must parse"); @@ -285,14 +285,14 @@ mod tests { #[test] fn analytical_rejects_shared_label_within_same_kind() { let error = syn::parse_str::( - "name: \"demo\", params: [ka, ke, v], states: [gut, central], outputs: [cp], routes: [bolus(input_1) -> gut, bolus(input_1) -> central], structure: one_compartment_with_absorption, out: |x, p, t, cov, y| {}", + "name: \"demo\", params: [ka, ke, v], states: [gut, central], outputs: [cp], routes: [bolus(1) -> gut, bolus(1) -> central], structure: one_compartment_with_absorption, out: |x, p, t, cov, y| {}", ) .err() .expect("duplicate bolus routes must fail"); assert!(error .to_string() - .contains("duplicate route `input_1` in declaration-first `analytical!`")); + .contains("duplicate route `1` in declaration-first `analytical!`")); } #[test] diff --git a/pharmsol-macros/src/input/ode.rs b/pharmsol-macros/src/input/ode.rs index b7998f0e..a2f33a5a 100644 --- a/pharmsol-macros/src/input/ode.rs +++ b/pharmsol-macros/src/input/ode.rs @@ -369,10 +369,8 @@ mod tests { #[test] fn supports_shared_labels_and_call_syntax() { - let input = syn::parse_str::(&source( - "dx[central] = bolus(input_1) + infusion(input_1);", - )) - .unwrap(); + let input = + syn::parse_str::(&source("dx[central] = bolus(1) + infusion(1);")).unwrap(); assert_eq!(input.routes.len(), 2); assert_eq!(dense_index_len(&ode_route_input_bindings(&input.routes)), 1); } diff --git a/pharmsol-macros/src/input/sde.rs b/pharmsol-macros/src/input/sde.rs index af3e3f18..c8aec592 100644 --- a/pharmsol-macros/src/input/sde.rs +++ b/pharmsol-macros/src/input/sde.rs @@ -236,7 +236,7 @@ mod tests { #[test] fn sde_allows_shared_label_across_bolus_and_infusion_routes() { let input = syn::parse_str::( - "name: \"demo\", params: [ke, v], states: [central], outputs: [cp], particles: 16, routes: [bolus(input_1) -> central, infusion(input_1) -> central], drift: |x, p, t, dx, cov| {}, diffusion: |p, sigma| {}, out: |x, p, t, cov, y| {}", + "name: \"demo\", params: [ke, v], states: [central], outputs: [cp], particles: 16, routes: [bolus(1) -> central, infusion(1) -> central], drift: |x, p, t, dx, cov| {}, diffusion: |p, sigma| {}, out: |x, p, t, cov, y| {}", ) .expect("bolus and infusion sharing a label must parse"); @@ -246,13 +246,13 @@ mod tests { #[test] fn sde_rejects_shared_label_within_same_kind() { let error = syn::parse_str::( - "name: \"demo\", params: [ke, v], states: [central], outputs: [cp], particles: 16, routes: [bolus(input_1) -> central, bolus(input_1) -> central], drift: |x, p, t, dx, cov| {}, diffusion: |p, sigma| {}, out: |x, p, t, cov, y| {}", + "name: \"demo\", params: [ke, v], states: [central], outputs: [cp], particles: 16, routes: [bolus(1) -> central, bolus(1) -> central], drift: |x, p, t, dx, cov| {}, diffusion: |p, sigma| {}, out: |x, p, t, cov, y| {}", ) .err() .expect("duplicate bolus routes must fail"); assert!(error .to_string() - .contains("duplicate route `input_1` in declaration-first `sde!`")); + .contains("duplicate route `1` in declaration-first `sde!`")); } } diff --git a/pharmsol-macros/src/symbols.rs b/pharmsol-macros/src/symbols.rs index 19b47828..3e40d9b6 100644 --- a/pharmsol-macros/src/symbols.rs +++ b/pharmsol-macros/src/symbols.rs @@ -9,7 +9,10 @@ use syn::{ }; /// A `states` / `outputs` / route label, written either as a name or as a -/// numeric index. +/// numeric literal. +/// +/// A numeric label is a plain name whose text is the literal (`1`, `10`): +/// it has no relationship to declaration order or dense slots. #[derive(Clone)] pub(crate) enum SymbolicIndex { Ident(Ident), @@ -20,7 +23,7 @@ impl SymbolicIndex { pub(crate) fn name(&self) -> String { match self { Self::Ident(ident) => ident.to_string(), - Self::Int(lit) => lit.base10_digits().to_string(), + Self::Int(lit) => lit.to_string(), } } @@ -31,13 +34,11 @@ impl SymbolicIndex { } } - pub(crate) fn numeric_value(&self) -> Option { + /// The literal text of a numeric label (`"1"` for `1`, `"01"` for `01`). + pub(crate) fn numeric_text(&self) -> Option { match self { Self::Ident(_) => None, - Self::Int(lit) => Some( - lit.base10_parse::() - .expect("validated numeric label should fit usize"), - ), + Self::Int(lit) => Some(lit.to_string()), } } @@ -50,12 +51,12 @@ impl Parse for SymbolicIndex { fn parse(input: ParseStream) -> syn::Result { if input.peek(LitInt) { let lit: LitInt = input.parse()?; - lit.base10_parse::().map_err(|_| { - syn::Error::new_spanned( + if !is_plain_decimal(&lit.to_string()) { + return Err(syn::Error::new_spanned( &lit, - "numeric declaration-first labels must be non-negative base-10 integers that fit in usize", - ) - })?; + "numeric declaration-first labels must be plain decimal digits such as `1` or `10`", + )); + } Ok(Self::Int(lit)) } else { Ok(Self::Ident(input.parse()?)) @@ -63,6 +64,12 @@ impl Parse for SymbolicIndex { } } +/// Whether a literal's source text is only ASCII digits (no suffix, sign, +/// radix prefix or underscores). +pub(crate) fn is_plain_decimal(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) +} + impl ToTokens for SymbolicIndex { fn to_tokens(&self, tokens: &mut TokenStream2) { match self { @@ -164,12 +171,14 @@ pub(crate) fn symbolic_index_bindings(labels: &[SymbolicIndex]) -> Vec<(Symbolic .collect() } +/// Maps each numeric label's literal text to the dense slot its declaration +/// was assigned. pub(crate) fn symbolic_numeric_binding_map( bindings: &[(SymbolicIndex, usize)], -) -> HashMap { +) -> HashMap { bindings .iter() - .filter_map(|(label, index)| label.numeric_value().map(|value| (value, *index))) + .filter_map(|(label, index)| label.numeric_text().map(|text| (text, *index))) .collect() } @@ -237,3 +246,30 @@ pub(crate) fn dense_index_len(bindings: &[(SymbolicIndex, usize)]) -> usize { .max() .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_labels_keep_their_literal_text() { + let labels = ["1", "01", "10"] + .map(|src| syn::parse_str::(src).expect("numeric label parses")); + assert_eq!( + labels.each_ref().map(SymbolicIndex::name), + ["1", "01", "10"] + ); + + let map = symbolic_numeric_binding_map(&symbolic_index_bindings(&labels)); + assert_eq!(map.get("1"), Some(&0)); + assert_eq!(map.get("01"), Some(&1)); + assert_eq!(map.get("10"), Some(&2)); + } + + #[test] + fn rejects_non_decimal_numeric_labels() { + for src in ["1usize", "0x1", "1_0"] { + assert!(syn::parse_str::(src).is_err(), "{src}"); + } + } +} diff --git a/schemas/model-v2.json b/schemas/model-v2.json deleted file mode 100644 index 943fb860..00000000 --- a/schemas/model-v2.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pharmsol.rs/schemas/model-v2.json", - "title": "pharmsol Model Definition v2", - "description": "Canonical JSON Schema for pharmacometric model definitions in pharmsol. Supports analytical, ODE, and SDE models with optional editor metadata.", - "type": "object", - "additionalProperties": false, - "$defs": { - "identifier": { - "type": "string", - "pattern": "^[a-z][a-z0-9]*(?:[\\/_-][a-z0-9]+)*$" - }, - "symbol": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" - }, - "expression": { - "type": "string", - "minLength": 1 - }, - "outputDefinition": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { "$ref": "#/$defs/symbol" }, - "equation": { "$ref": "#/$defs/expression" }, - "name": { "type": "string" }, - "units": { "type": "string" } - }, - "required": ["id", "equation"] - }, - "namedEquation": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { "$ref": "#/$defs/symbol" }, - "equation": { "$ref": "#/$defs/expression" } - }, - "required": ["id", "equation"] - }, - "covariateDefinition": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { "$ref": "#/$defs/symbol" }, - "column": { "type": "string" }, - "required": { "type": "boolean" }, - "reference": { "type": "number" } - }, - "required": ["id"] - }, - "expressionMap": { - "type": "object", - "additionalProperties": { "$ref": "#/$defs/expression" } - }, - "expressionOrNumberMap": { - "type": "object", - "additionalProperties": { - "oneOf": [{ "$ref": "#/$defs/expression" }, { "type": "number" }] - } - }, - "position": { - "type": "object", - "additionalProperties": false, - "properties": { - "x": { "type": "number" }, - "y": { "type": "number" } - }, - "required": ["x", "y"] - }, - "editor": { - "type": "object", - "additionalProperties": false, - "properties": { - "display": { "type": "object" }, - "layout": { - "type": "object", - "additionalProperties": { "$ref": "#/$defs/position" } - }, - "documentation": { "type": "object" } - } - } - }, - "properties": { - "schema": { "const": "2.0" }, - "id": { "$ref": "#/$defs/identifier" }, - "type": { - "type": "string", - "enum": ["analytical", "ode", "sde"] - }, - "version": { "type": "string" }, - "aliases": { - "type": "array", - "items": { "type": "string" } - }, - "extends": { "$ref": "#/$defs/identifier" }, - "parameters": { - "type": "array", - "items": { "$ref": "#/$defs/symbol" } - }, - "compartments": { - "type": "array", - "items": { "$ref": "#/$defs/symbol" } - }, - "states": { - "type": "array", - "items": { "$ref": "#/$defs/symbol" } - }, - "analytical": { - "type": "string", - "enum": [ - "one_compartment", - "one_compartment_with_absorption", - "two_compartments", - "two_compartments_with_absorption", - "three_compartments", - "three_compartments_with_absorption" - ] - }, - "diffeq": { "$ref": "#/$defs/expressionMap" }, - "drift": { "$ref": "#/$defs/expressionMap" }, - "diffusion": { "$ref": "#/$defs/expressionOrNumberMap" }, - "secondary": { - "type": "array", - "items": { "$ref": "#/$defs/namedEquation" } - }, - "outputs": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/outputDefinition" } - }, - "init": { "$ref": "#/$defs/expressionOrNumberMap" }, - "lag": { "$ref": "#/$defs/expressionOrNumberMap" }, - "fa": { "$ref": "#/$defs/expressionOrNumberMap" }, - "particles": { - "type": "integer", - "minimum": 1 - }, - "covariates": { - "type": "array", - "items": { "$ref": "#/$defs/covariateDefinition" } - }, - "editor": { "$ref": "#/$defs/editor" } - }, - "required": ["schema", "id", "type"], - "allOf": [ - { - "if": { "properties": { "type": { "const": "analytical" } } }, - "then": { - "required": ["analytical", "parameters", "compartments", "outputs"] - } - }, - { - "if": { "properties": { "type": { "const": "ode" } } }, - "then": { - "required": ["parameters", "compartments", "diffeq", "outputs"] - } - }, - { - "if": { "properties": { "type": { "const": "sde" } } }, - "then": { - "required": ["parameters", "states", "drift", "diffusion", "outputs"] - } - } - ] -} diff --git a/src/data/error_model.rs b/src/data/error_model.rs index 1fffca20..f6a22bc9 100644 --- a/src/data/error_model.rs +++ b/src/data/error_model.rs @@ -262,6 +262,21 @@ impl AssayErrorModels { return Ok(BoundAssayErrorModels::Borrowed(self)); } + if outputs.is_empty() { + // The equation declares no output labels (a handwritten model + // without metadata), so its outputs are addressed by dense slot, + // exactly like the observation labels resolved against it. + let mut bound = Self::empty(); + bound.models = self.models.clone(); + for (label, model) in &self.named_models { + let outeq = label + .index() + .ok_or_else(|| ErrorModelError::UnknownOutputLabel(label.to_string()))?; + bound.insert_model_at(outeq, model.clone())?; + } + return Ok(BoundAssayErrorModels::Owned(bound)); + } + let mut bound = Self::with_output_names(outputs.iter().map(String::as_str)); bound.models = self.models.clone(); @@ -307,7 +322,7 @@ impl AssayErrorModels { } } - fn bound_output_names(&self) -> Vec { + pub(crate) fn bound_output_names(&self) -> Vec { let mut names = self .output_lookup .iter() @@ -322,7 +337,6 @@ impl AssayErrorModels { self.output_lookup .get(&label) .copied() - .or_else(|| label.index()) .ok_or_else(|| ErrorModelError::UnknownOutputLabel(label.to_string())) } @@ -356,14 +370,20 @@ impl AssayErrorModels { Ok(&self.models[outeq]) } - /// Add a new error model for a specific output equation or declared label. + /// Add a new error model for a public output label. + /// + /// The label is matched exactly against the model's declared output + /// names when the error models are bound to an equation: `"1"` binds to + /// an output declared as `1`, and `"cp"` to an output declared as `cp`. + /// A numeric label is never treated as a position. + /// /// # Arguments - /// * `outeq` - The output slot index or public output label. - /// * `model` - The [AssayErrorModel] to add for the specified output equation. + /// * `outeq` - The public output label. + /// * `model` - The [AssayErrorModel] to add for the specified output. /// # Returns /// A new instance of AssayErrorModels with the added model. /// # Errors - /// If the output label is unknown or if a model already exists for that output equation, an error is returned. + /// If the output label is unknown or if a model already exists for that output, an error is returned. pub fn add( mut self, outeq: impl ToString, @@ -377,17 +397,26 @@ impl AssayErrorModels { return Ok(self); } - if let Some(outeq) = label.index() { - self.insert_model_at(outeq, model)?; - return Ok(self); - } - if self.named_models.contains_key(&label) { return Err(ErrorModelError::ExistingOutputLabel(label.to_string())); } self.named_models.insert(label, model); Ok(self) } + + /// Add an error model directly at a dense output slot, for tests that + /// work with resolved output indices. This never goes through label + /// binding. + #[cfg(test)] + pub(crate) fn add_at( + mut self, + outeq: usize, + model: AssayErrorModel, + ) -> Result { + self.insert_model_at(outeq, model)?; + Ok(self) + } + /// Returns an iterator over the error models in the collection. /// /// # Returns @@ -1253,7 +1282,7 @@ mod tests { #[test] fn test_error_models_add_single() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); assert_eq!(models.len(), 1); } @@ -1263,9 +1292,9 @@ mod tests { let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); let models = AssayErrorModels::empty() - .add(0, model1) + .add_at(0, model1) .unwrap() - .add(1, model2) + .add_at(1, model2) .unwrap(); assert_eq!(models.len(), 2); @@ -1365,9 +1394,9 @@ mod tests { let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); let result = AssayErrorModels::empty() - .add(0, model1) + .add_at(0, model1) .unwrap() - .add(0, model2); // Same outeq should fail + .add_at(0, model2); // Same outeq should fail assert!(result.is_err()); match result { @@ -1379,7 +1408,7 @@ mod tests { #[test] fn test_error_models_factor() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); assert_eq!(models.factor(0).unwrap(), 5.0); } @@ -1387,7 +1416,7 @@ mod tests { #[test] fn test_error_models_factor_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let result = models.factor(1); assert!(result.is_err()); @@ -1400,7 +1429,7 @@ mod tests { #[test] fn test_error_models_set_factor() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); + let mut models = AssayErrorModels::empty().add_at(0, model).unwrap(); assert_eq!(models.factor(0).unwrap(), 5.0); models.set_factor(0, 10.0).unwrap(); @@ -1410,7 +1439,7 @@ mod tests { #[test] fn test_error_models_set_factor_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); + let mut models = AssayErrorModels::empty().add_at(0, model).unwrap(); let result = models.set_factor(1, 10.0); assert!(result.is_err()); @@ -1424,7 +1453,7 @@ mod tests { fn test_error_models_errorpoly() { let poly = ErrorPoly::new(1.0, 2.0, 3.0, 4.0); let model = AssayErrorModel::additive(poly, 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let retrieved_poly = models.errorpoly(0).unwrap(); assert_eq!(retrieved_poly.coefficients(), (1.0, 2.0, 3.0, 4.0)); @@ -1433,7 +1462,7 @@ mod tests { #[test] fn test_error_models_errorpoly_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let result = models.errorpoly(1); assert!(result.is_err()); @@ -1448,7 +1477,7 @@ mod tests { let poly1 = ErrorPoly::new(1.0, 2.0, 3.0, 4.0); let poly2 = ErrorPoly::new(5.0, 6.0, 7.0, 8.0); let model = AssayErrorModel::additive(poly1, 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); + let mut models = AssayErrorModels::empty().add_at(0, model).unwrap(); assert_eq!( models.errorpoly(0).unwrap().coefficients(), @@ -1464,7 +1493,7 @@ mod tests { #[test] fn test_error_models_set_errorpoly_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let mut models = AssayErrorModels::empty().add(0, model).unwrap(); + let mut models = AssayErrorModels::empty().add_at(0, model).unwrap(); let result = models.set_errorpoly(1, ErrorPoly::new(5.0, 6.0, 7.0, 8.0)); assert!(result.is_err()); @@ -1477,7 +1506,7 @@ mod tests { #[test] fn test_error_models_sigma() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); let prediction = observation.to_prediction(10.0, vec![]); @@ -1490,7 +1519,7 @@ mod tests { #[test] fn test_error_models_sigma_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let observation = Observation::new(0.0, Some(20.0), 1, None, 0, Censor::None); // outeq=1 not in models let prediction = observation.to_prediction(10.0, vec![]); @@ -1506,7 +1535,7 @@ mod tests { #[test] fn test_error_models_variance() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let observation = Observation::new(0.0, Some(20.0), 0, None, 0, Censor::None); let prediction = observation.to_prediction(10.0, vec![]); @@ -1519,7 +1548,7 @@ mod tests { #[test] fn test_error_models_variance_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let observation = Observation::new(0.0, Some(20.0), 1, None, 0, Censor::None); // outeq=1 not in models let prediction = observation.to_prediction(10.0, vec![]); @@ -1535,7 +1564,7 @@ mod tests { #[test] fn test_error_models_sigma_from_value() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let sigma = models.sigma_from_value(0, 20.0).unwrap(); assert_eq!(sigma, (26.0_f64).sqrt()); @@ -1544,7 +1573,7 @@ mod tests { #[test] fn test_error_models_sigma_from_value_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let result = models.sigma_from_value(1, 20.0); assert!(result.is_err()); @@ -1557,7 +1586,7 @@ mod tests { #[test] fn test_error_models_variance_from_value() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let variance = models.variance_from_value(0, 20.0).unwrap(); let expected_sigma = (26.0_f64).sqrt(); @@ -1567,7 +1596,7 @@ mod tests { #[test] fn test_error_models_variance_from_value_invalid_outeq() { let model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models = AssayErrorModels::empty().add(0, model).unwrap(); + let models = AssayErrorModels::empty().add_at(0, model).unwrap(); let result = models.variance_from_value(1, 20.0); assert!(result.is_err()); @@ -1583,15 +1612,15 @@ mod tests { let model2 = AssayErrorModel::proportional(ErrorPoly::new(2.0, 0.0, 0.0, 0.0), 3.0); let models1 = AssayErrorModels::empty() - .add(0, model1.clone()) + .add_at(0, model1.clone()) .unwrap() - .add(1, model2.clone()) + .add_at(1, model2.clone()) .unwrap(); let models2 = AssayErrorModels::empty() - .add(0, model1) + .add_at(0, model1) .unwrap() - .add(1, model2) + .add_at(1, model2) .unwrap(); // Same models should produce same hash @@ -1605,15 +1634,15 @@ mod tests { // Add in different orders let models1 = AssayErrorModels::empty() - .add(0, model1.clone()) + .add_at(0, model1.clone()) .unwrap() - .add(1, model2.clone()) + .add_at(1, model2.clone()) .unwrap(); let models2 = AssayErrorModels::empty() - .add(1, model2) + .add_at(1, model2) .unwrap() - .add(0, model1) + .add_at(0, model1) .unwrap(); // Hash should be the same regardless of insertion order @@ -1627,9 +1656,9 @@ mod tests { AssayErrorModel::proportional(ErrorPoly::new(0.0, 0.05, 0.0, 0.0), 0.1); let models = AssayErrorModels::empty() - .add(0, additive_model) + .add_at(0, additive_model) .unwrap() - .add(1, proportional_model) + .add_at(1, proportional_model) .unwrap(); assert_eq!(models.len(), 2); @@ -1656,9 +1685,9 @@ mod tests { AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); let models = AssayErrorModels::empty() - .add(0, additive_model) + .add_at(0, additive_model) .unwrap() - .add(1, proportional_model) + .add_at(1, proportional_model) .unwrap(); // Test with outeq=0 (additive model) @@ -1776,9 +1805,9 @@ mod tests { AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); let mut models = AssayErrorModels::empty() - .add(0, additive_model) + .add_at(0, additive_model) .unwrap() - .add(1, proportional_model) + .add_at(1, proportional_model) .unwrap(); // Test factor param retrieval @@ -1835,8 +1864,10 @@ mod tests { let model1_variable = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); let model1_fixed = AssayErrorModel::additive_fixed(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0); - let models1 = AssayErrorModels::empty().add(0, model1_variable).unwrap(); - let models2 = AssayErrorModels::empty().add(0, model1_fixed).unwrap(); + let models1 = AssayErrorModels::empty() + .add_at(0, model1_variable) + .unwrap(); + let models2 = AssayErrorModels::empty().add_at(0, model1_fixed).unwrap(); // Different fixed/variable states should produce different hashes assert_ne!(models1.hash(), models2.hash()); @@ -1849,9 +1880,9 @@ mod tests { AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 2.0); let mut models = AssayErrorModels::empty() - .add(0, additive_model) + .add_at(0, additive_model) .unwrap() - .add(1, proportional_model) + .add_at(1, proportional_model) .unwrap(); // Verify initial state - both should be variable @@ -1923,7 +1954,7 @@ mod tests { #[test] fn error_model_hash_deterministic() { let models = AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), ) @@ -1934,13 +1965,13 @@ mod tests { #[test] fn error_model_hash_differs_on_value() { let a = AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), ) .unwrap(); let b = AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 10.0), ) @@ -1951,13 +1982,13 @@ mod tests { #[test] fn error_model_hash_differs_on_type() { let a = AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), ) .unwrap(); let b = AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::proportional(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 5.0), ) diff --git a/src/data/event.rs b/src/data/event.rs index 2b14c9f4..b7e46eb8 100644 --- a/src/data/event.rs +++ b/src/data/event.rs @@ -173,8 +173,9 @@ impl fmt::Display for InputLabel { } impl PartialEq for InputLabel { + /// Labels compare as text: `"1" == 1`, but `"01" != 1`. fn eq(&self, other: &usize) -> bool { - self.index() == Some(*other) + self.0 == other.to_string() } } @@ -255,8 +256,9 @@ impl fmt::Display for OutputLabel { } impl PartialEq for OutputLabel { + /// Labels compare as text: `"1" == 1`, but `"01" != 1`. fn eq(&self, other: &usize) -> bool { - self.index() == Some(*other) + self.0 == other.to_string() } } @@ -775,6 +777,14 @@ impl fmt::Display for Event { mod tests { use super::*; + #[test] + fn labels_compare_to_numbers_as_text() { + assert_eq!(InputLabel::new("1"), 1); + assert_eq!(OutputLabel::new("10"), 10); + assert_ne!(InputLabel::new("01"), 1); + assert_ne!(OutputLabel::new("outeq_1"), 1); + } + #[test] fn test_bolus_creation() { let bolus = Bolus::new(2.5, 100.0, 1, 0); diff --git a/src/data/parser/pmetrics/mod.rs b/src/data/parser/pmetrics/mod.rs index e5acf058..73a29650 100644 --- a/src/data/parser/pmetrics/mod.rs +++ b/src/data/parser/pmetrics/mod.rs @@ -5,7 +5,12 @@ //! //! `INPUT` and `OUTEQ` values are parsed as labels, not rewritten to dense //! indices. Named values such as `iv` and `cp` are preserved exactly, and -//! numeric values such as `1` are preserved as numeric-looking labels. +//! numeric values such as `1` stay the label `"1"`, which matches a model +//! route or output declared as `1`. +//! +//! Because Pmetrics columns are numeric, an integer value written with a +//! zero fractional part (`1.0`, `2.00`) is normalized to its integer text +//! (`"1"`, `"2"`). Every other value, including `01`, is kept verbatim. use crate::data::*; use csv::{ReaderBuilder, StringRecord}; @@ -378,14 +383,31 @@ fn deserialize_option_route_label<'de, D>(deserializer: D) -> Result, { - deserialize_option::(deserializer).map(|value| value.map(InputLabel::from)) + deserialize_option::(deserializer) + .map(|value| value.map(|label| InputLabel::from(normalize_numeric_label(label)))) } fn deserialize_option_output_label<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { - deserialize_option::(deserializer).map(|value| value.map(OutputLabel::from)) + deserialize_option::(deserializer) + .map(|value| value.map(|label| OutputLabel::from(normalize_numeric_label(label)))) +} + +/// Rewrite an integer written with a zero fraction (`1.0`, `2.00`) to its +/// integer text. Any other label is returned unchanged. +fn normalize_numeric_label(label: String) -> String { + match label.split_once('.') { + Some((integer, fraction)) + if !integer.is_empty() + && integer.bytes().all(|byte| byte.is_ascii_digit()) + && fraction.bytes().all(|byte| byte == b'0') => + { + integer.to_string() + } + _ => label, + } } fn deserialize_option_i64<'de, D>(deserializer: D) -> Result, D::Error> @@ -538,4 +560,31 @@ mod tests { _ => panic!("expected observation event"), } } + + #[test] + fn read_pmetrics_normalizes_float_formatted_integer_labels() { + let file = NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + "ID,EVID,TIME,DUR,DOSE,ADDL,II,INPUT,OUT,OUTEQ,CENS,C0,C1,C2,C3\npt1,1,0,.,100,.,.,1.0,.,.,.,.,.,.,.\npt1,0,1,.,.,.,.,.,42,2.00,0,.,.,.,.\npt1,0,2,.,.,.,.,.,42,01,0,.,.,.,.\n", + ) + .unwrap(); + + let data = read_pmetrics(file.path().display().to_string()).unwrap(); + let events = data.subjects()[0].occasions()[0].events(); + + match &events[0] { + Event::Bolus(bolus) => assert_eq!(bolus.input().as_str(), "1"), + _ => panic!("expected bolus event"), + } + match &events[1] { + Event::Observation(observation) => assert_eq!(observation.outeq().as_str(), "2"), + _ => panic!("expected observation event"), + } + // Leading zeros are not a float format: `01` stays a distinct label. + match &events[2] { + Event::Observation(observation) => assert_eq!(observation.outeq().as_str(), "01"), + _ => panic!("expected observation event"), + } + } } diff --git a/src/data/residual_error.rs b/src/data/residual_error.rs index 51dd9763..c0aa2972 100644 --- a/src/data/residual_error.rs +++ b/src/data/residual_error.rs @@ -36,6 +36,10 @@ //! - **Exponential**: σ for log-transformed data use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::data::error_model::ErrorModelError; +use crate::data::event::OutputLabel; /// Residual error model for parametric estimation algorithms. /// @@ -336,27 +340,71 @@ impl ResidualErrorModel { /// Collection of residual error models for multiple output equations /// -/// This mirrors [`crate::ErrorModels`] but for parametric algorithms. +/// This mirrors [`crate::AssayErrorModels`] but for parametric algorithms. +/// +/// Models are added by public output label and bound to an equation's dense +/// output slots once, when used through [`crate::simulator::likelihood`] +/// entrypoints. Labels match the equation's declared output names exactly: +/// `"1"` binds to an output declared as `1`, never to a position. Equations +/// without metadata declare no output names; their outputs are addressed by +/// dense slot (`"0"`, `"1"`, ...), like the observation labels resolved +/// against them. +/// +/// The slot accessors ([`Self::get`], [`Self::sigma`], +/// [`Self::log_likelihood`], ...) operate on the bound, dense form. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ResidualErrorModels { models: Vec, + #[serde(default)] + named_models: BTreeMap, } impl ResidualErrorModels { /// Create an empty collection pub fn new() -> Self { - Self { models: vec![] } + Self::default() } - /// Add an error model for a specific output equation - pub fn add(mut self, outeq: usize, model: ResidualErrorModel) -> Self { - if outeq >= self.models.len() { - self.models.resize(outeq + 1, ResidualErrorModel::default()); - } - self.models[outeq] = model; + /// Add an error model for a public output label. + /// + /// Adding a second model for the same label replaces the first. + pub fn add(mut self, outeq: impl ToString, model: ResidualErrorModel) -> Self { + self.named_models.insert(OutputLabel::new(outeq), model); self } + /// Bind the label-keyed models to dense output slots. + /// + /// `outputs` are the equation's declared output names in slot order. When + /// it is empty (an equation without metadata), labels are dense slot + /// numbers. + pub(crate) fn bind_output_names(&self, outputs: &[String]) -> Result { + let mut bound = Self { + models: self.models.clone(), + named_models: BTreeMap::new(), + }; + for (label, model) in &self.named_models { + let outeq = if outputs.is_empty() { + label.index() + } else { + outputs.iter().position(|output| output == label.as_str()) + } + .ok_or_else(|| ErrorModelError::UnknownOutputLabel(label.to_string()))?; + if outeq >= bound.models.len() { + bound + .models + .resize(outeq + 1, ResidualErrorModel::default()); + } + bound.models[outeq] = *model; + } + Ok(bound) + } + + /// Whether every model is already bound to a dense slot. + pub(crate) fn is_bound(&self) -> bool { + self.named_models.is_empty() + } + /// Get the error model for a specific output equation pub fn get(&self, outeq: usize) -> Option<&ResidualErrorModel> { self.models.get(outeq) @@ -372,14 +420,18 @@ impl ResidualErrorModels { self.models.get(outeq).map(|m| m.sigma(prediction)) } - /// Number of error models + /// Number of error models: dense slots once bound, otherwise the number + /// of labels added. pub fn len(&self) -> usize { + if self.models.is_empty() { + return self.named_models.len(); + } self.models.len() } /// Check if collection is empty pub fn is_empty(&self) -> bool { - self.models.is_empty() + self.models.is_empty() && self.named_models.is_empty() } /// Iterate over (outeq, model) pairs @@ -426,7 +478,7 @@ impl ResidualErrorModels { /// Update all models with a new sigma estimate pub fn update_sigma(&mut self, new_sigma: f64) { - for model in &mut self.models { + for model in self.models.iter_mut().chain(self.named_models.values_mut()) { *model = model.with_updated_sigma(new_sigma); } } @@ -507,8 +559,13 @@ mod tests { #[test] fn test_residual_error_models_collection() { let models = ResidualErrorModels::new() - .add(0, ResidualErrorModel::constant(0.5)) - .add(1, ResidualErrorModel::proportional(0.1)); + .add("cp", ResidualErrorModel::constant(0.5)) + .add("effect", ResidualErrorModel::proportional(0.1)); + assert_eq!(models.len(), 2); + + let models = models + .bind_output_names(&["cp".to_string(), "effect".to_string()]) + .unwrap(); assert_eq!(models.len(), 2); assert!(models.get(0).unwrap().is_constant()); @@ -516,4 +573,34 @@ mod tests { assert!((models.sigma(0, 100.0).unwrap() - 0.5).abs() < 1e-10); assert!((models.sigma(1, 100.0).unwrap() - 10.0).abs() < 1e-10); } + + #[test] + fn test_residual_error_models_bind_by_exact_label() { + let models = ResidualErrorModels::new().add("1", ResidualErrorModel::constant(0.5)); + + // Output `1` is the only declared output, so it lives in slot 0. + let bound = models.bind_output_names(&["1".to_string()]).unwrap(); + assert_eq!(bound.len(), 1); + assert!(bound.get(0).unwrap().is_constant()); + + // `"1"` is a name, not a position: it does not bind to `outeq_1` + // or to the second of two outputs named otherwise. + for outputs in [vec!["outeq_1"], vec!["0", "2"]] { + let outputs = outputs.into_iter().map(String::from).collect::>(); + match models.bind_output_names(&outputs) { + Err(ErrorModelError::UnknownOutputLabel(label)) => assert_eq!(label, "1"), + other => panic!("expected UnknownOutputLabel, got {other:?}"), + } + } + } + + #[test] + fn test_residual_error_models_bind_dense_slots_without_declared_outputs() { + let models = ResidualErrorModels::new() + .add(1, ResidualErrorModel::proportional(0.1)) + .bind_output_names(&[]) + .unwrap(); + assert_eq!(models.len(), 2); + assert!(models.get(1).unwrap().is_proportional()); + } } diff --git a/src/dsl/backend.rs b/src/dsl/backend.rs index 94abe0a2..8a1c0a18 100644 --- a/src/dsl/backend.rs +++ b/src/dsl/backend.rs @@ -15,7 +15,7 @@ use pharmsol_dsl::execution::ModelFunctionKind; use pharmsol_dsl::{ AnalyticalKernel, AnalyticalStructureInputKind, AnalyticalStructureInputPlan, ModelKind, - RouteKind, NUMERIC_ROUTE_PREFIX, + RouteKind, }; use super::model_info::{RuntimeModelInfo, RuntimeRouteInfo, RuntimeStateInfo}; @@ -657,9 +657,7 @@ impl SharedRuntimeModel { /// genuinely kind-less route from an explicitly bolus one; the info /// representation can. /// - /// Bare numeric labels resolve through the `input_` alias, matching - /// Pmetrics `INPUT` numbering, and never fall back to a declaration - /// position. + /// Labels match declared route names exactly. fn route_for_label_kind( &self, label: &str, @@ -669,20 +667,6 @@ impl SharedRuntimeModel { .routes .iter() .find(|route| route.kind == kind && route.name == label) - .or_else(|| { - if !crate::simulator::equation::metadata::is_bare_numeric_label(label) { - return None; - } - let aliased = format!("{NUMERIC_ROUTE_PREFIX}{label}"); - self.info - .routes - .iter() - .find(|route| route.kind == kind && route.name == aliased) - }) - } - - fn metadata_output_index_for_label(&self, label: &str) -> Option { - self.metadata().output_for_label(label) } fn validate_support_point(&self, support_point: &[f64]) -> Result<(), PharmsolError> { @@ -741,7 +725,7 @@ impl SharedRuntimeModel { // An explicitly bolus or infusion route is never an any-kind // fallback: bolus and infusion indices are separate ordinal spaces, // so falling through to the other kind's route by a colliding - // numeric index would silently execute the wrong route. + // input slot would silently execute the wrong route. let route = match self.route_for_label_kind(name, Some(kind)) { Some(route) => Some(route), None => self.route_for_label_kind(name, None), @@ -770,7 +754,8 @@ impl SharedRuntimeModel { } fn resolve_output_label(&self, label: &OutputLabel) -> Result { - self.metadata_output_index_for_label(label.as_str()) + self.metadata() + .output_for_label(label.as_str()) .ok_or_else(|| { PharmsolError::unknown_output_label( label.as_str(), diff --git a/src/dsl/jit.rs b/src/dsl/jit.rs index 8e1f1dbb..a50716ef 100644 --- a/src/dsl/jit.rs +++ b/src/dsl/jit.rs @@ -1607,9 +1607,9 @@ params = ke, v, tlag states = central outputs = cp -lag(input_1) = tlag +lag(1) = tlag -dx(central) = bolus(input_1) + infusion(input_1) - ke * central +dx(central) = bolus(1) + infusion(1) - ke * central out(cp) = central / v ~ continuous() "#; @@ -1625,16 +1625,14 @@ out(cp) = central / v ~ continuous() .info() .routes .iter() - .find(|route| { - route.name == "input_1" && route.kind == Some(pharmsol_dsl::RouteKind::Bolus) - }) + .find(|route| route.name == "1" && route.kind == Some(pharmsol_dsl::RouteKind::Bolus)) .expect("bolus route"); let infusion_route = jit .info() .routes .iter() .find(|route| { - route.name == "input_1" && route.kind == Some(pharmsol_dsl::RouteKind::Infusion) + route.name == "1" && route.kind == Some(pharmsol_dsl::RouteKind::Infusion) }) .expect("infusion route"); assert_eq!(bolus_route.index, 0); @@ -1642,8 +1640,8 @@ out(cp) = central / v ~ continuous() assert!(bolus_route.has_lag); let jit_subject = Subject::builder("ode") - .bolus(0.0, 500.0, "input_1") - .infusion(1.0, 100.0, "input_1", 2.0) + .bolus(0.0, 500.0, "1") + .infusion(1.0, 100.0, "1", 2.0) .observation(0.5, 0.0, "cp") .observation(1.0, 0.0, "cp") .observation(2.0, 0.0, "cp") @@ -1713,9 +1711,9 @@ params = ke, v, tlag states = central outputs = cp -lag(input_1) = tlag +lag(1) = tlag -dx(central) = bolus(input_1) + infusion(input_1) - ke * central +dx(central) = bolus(1) + infusion(1) - ke * central out(cp) = central / v ~ continuous() "#; @@ -1727,11 +1725,11 @@ model shared_label_canonical { parameters { ke, v, tlag } states { central } routes { - bolus input_1 -> central { lag = tlag }, - infusion input_1 -> central + bolus 1 -> central { lag = tlag }, + infusion 1 -> central } dynamics { - ddt(central) = -ke * central + rate(input_1) + ddt(central) = -ke * central + rate(1) } outputs { cp = central / v @@ -1762,8 +1760,8 @@ model shared_label_canonical { .with_solver(OdeSolver::ExplicitRk(ExplicitRkTableau::Tsit45)); let subject = Subject::builder("ode") - .bolus(0.0, 500.0, "input_1") - .infusion(1.0, 100.0, "input_1", 2.0) + .bolus(0.0, 500.0, "1") + .infusion(1.0, 100.0, "1", 2.0) .observation(0.5, 0.0, "cp") .observation(1.0, 0.0, "cp") .observation(2.0, 0.0, "cp") @@ -1807,9 +1805,9 @@ states = gut, peripheral, central outputs = cp -ddt(gut) = bolus(input_1) - ka * gut -ddt(peripheral) = infusion(input_2) - kp * peripheral -ddt(central) = infusion(input_1) - ke * central +ddt(gut) = bolus(1) - ka * gut +ddt(peripheral) = infusion(2) - kp * peripheral +ddt(central) = infusion(1) - ke * central out(cp) = central / v ~ continuous() "#; @@ -1825,25 +1823,23 @@ out(cp) = central / v ~ continuous() let bolus = info .routes .iter() - .find(|route| { - route.name == "input_1" && route.kind == Some(pharmsol_dsl::RouteKind::Bolus) - }) - .expect("input_1 bolus route"); + .find(|route| route.name == "1" && route.kind == Some(pharmsol_dsl::RouteKind::Bolus)) + .expect("1 bolus route"); let infusion = info .routes .iter() .find(|route| { - route.name == "input_1" && route.kind == Some(pharmsol_dsl::RouteKind::Infusion) + route.name == "1" && route.kind == Some(pharmsol_dsl::RouteKind::Infusion) }) - .expect("input_1 infusion route"); + .expect("1 infusion route"); assert_eq!(bolus.index, 0); assert_eq!(bolus.destination_name, "gut"); assert_eq!(infusion.index, 1); assert_eq!(infusion.destination_name, "central"); let jit_subject = Subject::builder("ode") - .bolus(0.0, 500.0, "input_1") - .infusion(0.0, 100.0, "input_1", 2.0) + .bolus(0.0, 500.0, "1") + .infusion(0.0, 100.0, "1", 2.0) .observation(0.5, 0.0, "cp") .observation(1.0, 0.0, "cp") .observation(2.0, 0.0, "cp") @@ -1851,10 +1847,10 @@ out(cp) = central / v ~ continuous() .observation(4.0, 0.0, "cp") .build(); - // The reference uses infusion ordinal 1 for `input_1` (its declared - // slot) and infusion ordinal 0 for `input_2`; if the runtime resolved - // the `input_1` infusion by a colliding numeric index it would land - // in `peripheral` (input_2's destination) and the predictions would + // The reference uses infusion ordinal 1 for `1` (its declared + // slot) and infusion ordinal 0 for `2`; if the runtime resolved + // the `1` infusion by a colliding numeric index it would land + // in `peripheral` (route `2`'s destination) and the predictions would // diverge. let reference_subject = Subject::builder("ode") .bolus(0.0, 500.0, 0) @@ -1983,9 +1979,9 @@ out(cp) = central / v ~ continuous() } #[test] - fn route_resolution_matches_bare_numeric_aliases() { + fn route_resolution_matches_numeric_labels_exactly() { let source = r#" -name = numeric_alias +name = numeric_labels kind = ode params = ke, v @@ -1993,7 +1989,7 @@ states = central outputs = cp -ddt(central) = infusion(input_1) - ke * central +ddt(central) = infusion(1) - ke * central out(cp) = central / v ~ continuous() "#; @@ -2010,26 +2006,29 @@ out(cp) = central / v ~ continuous() ) .expect("valid named parameters"); - // A bare numeric label resolves through the `input_` alias. + // The numeric label `1` resolves to the route declared as `1`. let numeric_subject = Subject::builder("ode") .infusion(0.0, 100.0, 1, 2.0) .observation(1.0, 0.0, "cp") .build(); jit.estimate_predictions(&numeric_subject, &support) - .expect("bare numeric alias must resolve"); - - // A bare numeric label without a declared alias never resolves. - let missing_subject = Subject::builder("ode") - .infusion(0.0, 100.0, 9, 2.0) - .observation(1.0, 0.0, "cp") - .build(); - let err = jit - .estimate_predictions(&missing_subject, &support) - .expect_err("undeclared numeric alias must fail"); - assert!( - matches!(err, crate::PharmsolError::UnknownInputLabel { .. }), - "unexpected error: {err:?}" - ); + .expect("declared numeric label must resolve"); + + // Undeclared labels never resolve: not by position, and `input_1` + // is not an alias of `1`. + for label in ["0", "9", "01", "input_1"] { + let missing_subject = Subject::builder("ode") + .infusion(0.0, 100.0, label, 2.0) + .observation(1.0, 0.0, "cp") + .build(); + let err = jit + .estimate_predictions(&missing_subject, &support) + .expect_err("undeclared label must fail"); + assert!( + matches!(err, crate::PharmsolError::UnknownInputLabel { .. }), + "unexpected error for `{label}`: {err:?}" + ); + } } #[test] @@ -2046,7 +2045,7 @@ states = central outputs = cp -ddt(central) = bolus(input_0) - ke * central +ddt(central) = bolus(0) - ke * central out(cp) = central / v ~ continuous() "#; @@ -2060,8 +2059,8 @@ out(cp) = central / v ~ continuous() let ulp = 12.0f64.next_up() - 12.0; let subject = Subject::builder("dense_grid_near_event") - .bolus(0.0, 200.0, "input_0") - .bolus(12.0, 100.0, "input_0") + .bolus(0.0, 200.0, "0") + .bolus(12.0, 100.0, "0") .missing_observation(0.0, "cp") .missing_observation(12.0 - 16.0 * ulp, "cp") .missing_observation(12.0 + 16.0 * ulp, "cp") diff --git a/src/dsl/model_info.rs b/src/dsl/model_info.rs index 1eee8c9d..32b99f02 100644 --- a/src/dsl/model_info.rs +++ b/src/dsl/model_info.rs @@ -380,22 +380,22 @@ out(cp) = central / v } #[test] - fn runtime_model_info_preserves_canonical_numeric_channel_names() { + fn runtime_model_info_preserves_numeric_channel_names() { let info = load_model_info( r#" -name = canonical_numeric_channels +name = numeric_channels kind = ode params = ke, v states = depot, central -outputs = cp, outeq_2 +outputs = cp, 2 -dx(depot) = bolus(input_10) - ke * depot +dx(depot) = bolus(10) - ke * depot dx(central) = infusion(iv) - ke * central out(cp) = central / v -out(outeq_2) = depot / v +out(2) = depot / v "#, ); @@ -404,14 +404,14 @@ out(outeq_2) = depot / v .iter() .map(|route| route.name.as_str()) .collect::>(), - vec!["input_10", "iv"] + vec!["10", "iv"] ); assert_eq!( info.outputs .iter() .map(|output| output.name.as_str()) .collect::>(), - vec!["cp", "outeq_2"] + vec!["cp", "2"] ); } diff --git a/src/dsl/runtime.rs b/src/dsl/runtime.rs index 38c2635e..4ab978f8 100644 --- a/src/dsl/runtime.rs +++ b/src/dsl/runtime.rs @@ -295,18 +295,18 @@ kind = ode params = ke, v states = central -outputs = outeq_2, outeq_10, outeq_11 +outputs = 2, 10, 11 dx(central) = infusion(iv) - ke * central -out(outeq_10) = central / v ~ continuous() -out(outeq_2) = central / v ~ continuous() -out(outeq_11) = central / v ~ continuous() +out(10) = central / v ~ continuous() +out(2) = central / v ~ continuous() +out(11) = central / v ~ continuous() "#; const NUMERIC_ROUTE_LABELS_RUNTIME_DSL: &str = r#" -name = prefixed_numeric_route_runtime +name = numeric_route_runtime kind = ode params = ke, v @@ -314,23 +314,23 @@ states = central outputs = cp -dx(central) = bolus(input_10) + bolus(input_11) - ke * central +dx(central) = bolus(10) + bolus(11) - ke * central out(cp) = central / v ~ continuous() "#; const SHARED_NUMERIC_ROUTE_OUTPUT_LABEL_RUNTIME_DSL: &str = r#" -name = prefixed_numeric_route_output_runtime +name = numeric_route_output_runtime kind = ode params = ke, v states = central -outputs = outeq_1 +outputs = 1 -dx(central) = infusion(input_1) - ke * central +dx(central) = infusion(1) - ke * central -out(outeq_1) = central / v ~ continuous() +out(1) = central / v ~ continuous() "#; const UNDECLARED_NUMERIC_OUTPUT_LABEL_RUNTIME_DSL: &str = r#" @@ -435,16 +435,16 @@ out(cp) = central / v ~ continuous() fn numeric_route_subject() -> Subject { Subject::builder("numeric-route-runtime") - .bolus(0.0, 120.0, "input_10") - .bolus(1.0, 80.0, "input_11") + .bolus(0.0, 120.0, "10") + .bolus(1.0, 80.0, "11") .missing_observation(0.5, "cp") .missing_observation(1.5, "cp") .build() } - fn numeric_route_alias_subject() -> Subject { - Subject::builder("numeric-route-runtime-alias") - .bolus(0.0, 120.0, "10") + fn prefixed_route_subject() -> Subject { + Subject::builder("prefixed-route-runtime") + .bolus(0.0, 120.0, "input_10") .bolus(1.0, 80.0, "11") .missing_observation(0.5, "cp") .missing_observation(1.5, "cp") @@ -452,18 +452,17 @@ out(cp) = central / v ~ continuous() } fn shared_numeric_route_output_subject() -> Subject { - Subject::builder("prefixed-numeric-route-output-runtime") - .infusion(0.0, 120.0, "input_1", 1.0) - .missing_observation(0.5, "outeq_1") - .missing_observation(1.5, "outeq_1") + Subject::builder("numeric-route-output-runtime") + .infusion(0.0, 120.0, "1", 1.0) + .missing_observation(0.5, "1") + .missing_observation(1.5, "1") .build() } - fn shared_numeric_route_output_alias_subject() -> Subject { - Subject::builder("raw-numeric-route-output-runtime") + fn prefixed_output_subject() -> Subject { + Subject::builder("prefixed-output-runtime") .infusion(0.0, 120.0, "1", 1.0) - .missing_observation(0.5, "1") - .missing_observation(1.5, "1") + .missing_observation(0.5, "outeq_1") .build() } @@ -650,14 +649,10 @@ out(cp) = central / v ~ continuous() fn runtime_jit_reports_route_kind_mismatch() { let subject = mismatched_route_kind_subject(); - let jit = compile_runtime_model( - NUMERIC_ROUTE_LABELS_RUNTIME_DSL, - "prefixed_numeric_route_runtime", - ); + let jit = compile_runtime_model(NUMERIC_ROUTE_LABELS_RUNTIME_DSL, "numeric_route_runtime"); let support = Parameters::with_model(&jit, [("ke", 0.2), ("v", 10.0)]) .expect("valid named parameters"); - let expected_input = - compiled_route_input_index(&jit, "input_10").expect("input_10 route index"); + let expected_input = compiled_route_input_index(&jit, "10").expect("10 route index"); assert_unsupported_input_route_kind( &jit, @@ -675,22 +670,19 @@ out(cp) = central / v ~ continuous() "multi_digit_output_runtime", ); - assert_eq!(compiled_output_slot_index(&jit, "outeq_2"), Some(0)); - assert_eq!(compiled_output_slot_index(&jit, "outeq_10"), Some(1)); - assert_eq!(compiled_output_slot_index(&jit, "outeq_11"), Some(2)); + assert_eq!(compiled_output_slot_index(&jit, "2"), Some(0)); + assert_eq!(compiled_output_slot_index(&jit, "10"), Some(1)); + assert_eq!(compiled_output_slot_index(&jit, "11"), Some(2)); } #[test] - fn runtime_jit_supports_prefixed_multi_digit_numeric_route_labels() { - let jit = compile_runtime_model( - NUMERIC_ROUTE_LABELS_RUNTIME_DSL, - "prefixed_numeric_route_runtime", - ); + fn runtime_jit_supports_multi_digit_numeric_route_labels() { + let jit = compile_runtime_model(NUMERIC_ROUTE_LABELS_RUNTIME_DSL, "numeric_route_runtime"); let support = Parameters::with_model(&jit, [("ke", 0.2), ("v", 10.0)]) .expect("valid named parameters"); - assert_eq!(compiled_route_input_index(&jit, "input_10"), Some(0)); - assert_eq!(compiled_route_input_index(&jit, "input_11"), Some(1)); + assert_eq!(compiled_route_input_index(&jit, "10"), Some(0)); + assert_eq!(compiled_route_input_index(&jit, "11"), Some(1)); let subject = numeric_route_subject(); @@ -702,34 +694,25 @@ out(cp) = central / v ~ continuous() } #[test] - fn runtime_jit_resolves_raw_numeric_route_labels_against_prefixed_metadata() { - let jit = compile_runtime_model( - NUMERIC_ROUTE_LABELS_RUNTIME_DSL, - "prefixed_numeric_route_runtime", - ); + fn runtime_jit_does_not_alias_prefixed_route_labels() { + let jit = compile_runtime_model(NUMERIC_ROUTE_LABELS_RUNTIME_DSL, "numeric_route_runtime"); let support = Parameters::with_model(&jit, [("ke", 0.2), ("v", 10.0)]) .expect("valid named parameters"); - let subject = numeric_route_alias_subject(); - - let values = subject_values( - &jit.estimate_predictions(&subject, &support) - .expect("jit predictions"), - ); - assert!(values.iter().all(|value| value.is_finite())); + assert_unknown_input_label(&jit, &prefixed_route_subject(), &support, "input_10"); } #[test] - fn runtime_jit_supports_prefixed_numeric_route_and_output_labels() { + fn runtime_jit_supports_numeric_route_and_output_labels() { let jit = compile_runtime_model( SHARED_NUMERIC_ROUTE_OUTPUT_LABEL_RUNTIME_DSL, - "prefixed_numeric_route_output_runtime", + "numeric_route_output_runtime", ); let support = Parameters::with_model(&jit, [("ke", 0.2), ("v", 10.0)]) .expect("valid named parameters"); - assert_eq!(compiled_route_input_index(&jit, "input_1"), Some(0)); - assert_eq!(compiled_output_slot_index(&jit, "outeq_1"), Some(0)); + assert_eq!(compiled_route_input_index(&jit, "1"), Some(0)); + assert_eq!(compiled_output_slot_index(&jit, "1"), Some(0)); let subject = shared_numeric_route_output_subject(); @@ -741,21 +724,15 @@ out(cp) = central / v ~ continuous() } #[test] - fn runtime_jit_resolves_shared_raw_numeric_route_and_output_aliases() { + fn runtime_jit_does_not_alias_prefixed_output_labels() { let jit = compile_runtime_model( SHARED_NUMERIC_ROUTE_OUTPUT_LABEL_RUNTIME_DSL, - "prefixed_numeric_route_output_runtime", + "numeric_route_output_runtime", ); let support = Parameters::with_model(&jit, [("ke", 0.2), ("v", 10.0)]) .expect("valid named parameters"); - let subject = shared_numeric_route_output_alias_subject(); - - let values = subject_values( - &jit.estimate_predictions(&subject, &support) - .expect("jit predictions"), - ); - assert!(values.iter().all(|value| value.is_finite())); + assert_unknown_output_label(&jit, &prefixed_output_subject(), &support, "outeq_1"); } #[test] diff --git a/src/simulator/equation/analytical/mod.rs b/src/simulator/equation/analytical/mod.rs index b473ae06..ba486b21 100644 --- a/src/simulator/equation/analytical/mod.rs +++ b/src/simulator/equation/analytical/mod.rs @@ -594,7 +594,7 @@ pub(crate) mod tests { } #[test] - fn handwritten_analytical_metadata_resolves_raw_numeric_aliases_against_canonical_labels() { + fn handwritten_analytical_metadata_resolves_numeric_labels_by_exact_name() { let eq = |x: &V, _p: &V, dt: f64, rateiv: &V, _cov: &Covariates| { let mut next = x.clone(); next[0] += rateiv[0] * dt; @@ -617,32 +617,37 @@ pub(crate) mod tests { .with_metadata( super::super::metadata::new("numeric_alias_analytical") .states(["central"]) - .outputs(["outeq_1"]) - .route(super::super::Route::infusion("input_1").to_state("central")), + .outputs(["1"]) + .route(super::super::Route::infusion("1").to_state("central")), ) .expect("metadata attachment should validate"); - let canonical = Subject::builder("canonical") - .infusion(0.0, 100.0, "input_1", 1.0) - .observation(1.0, 0.0, "outeq_1") - .build(); - let aliased = Subject::builder("aliased") + let numeric = Subject::builder("numeric") .infusion(0.0, 100.0, "1", 1.0) .observation(1.0, 0.0, "1") .build(); - - let canonical_predictions = analytical - .estimate_predictions(&canonical, &crate::parameters::dense([])) - .expect("canonical labels should simulate"); - let aliased_predictions = analytical - .estimate_predictions(&aliased, &crate::parameters::dense([])) - .expect("raw numeric aliases should simulate"); - - assert_relative_eq!( - canonical_predictions.predictions()[0].prediction(), - aliased_predictions.predictions()[0].prediction(), - epsilon = 1e-10 - ); + let prediction = analytical + .estimate_predictions(&numeric, &crate::parameters::dense([])) + .expect("numeric labels should resolve by exact name") + .predictions()[0] + .prediction(); + assert!(prediction > 0.0); + + // `input_1` / `outeq_1` are different names, not aliases of `1`. + for prefixed in [ + Subject::builder("prefixed_input") + .infusion(0.0, 100.0, "input_1", 1.0) + .observation(1.0, 0.0, "1") + .build(), + Subject::builder("prefixed_output") + .infusion(0.0, 100.0, "1", 1.0) + .observation(1.0, 0.0, "outeq_1") + .build(), + ] { + assert!(analytical + .estimate_predictions(&prefixed, &crate::parameters::dense([])) + .is_err()); + } } #[test] diff --git a/src/simulator/equation/metadata.rs b/src/simulator/equation/metadata.rs index 31f0634b..94523b46 100644 --- a/src/simulator/equation/metadata.rs +++ b/src/simulator/equation/metadata.rs @@ -8,8 +8,10 @@ //! After validation, the execution layer can resolve public labels such as //! `"iv"` and `"cp"` against those declarations before simulation. //! -//! Without metadata, handwritten models fall back to numeric labels. With -//! metadata, labels are matched by name. +//! With metadata, labels are matched exactly by name. A numeric name such as +//! `"1"` is an ordinary name: it never resolves by position. Without metadata, +//! a handwritten model declares no names, so its routes and outputs are +//! addressed by dense index (`"0"`, `"1"`, ...). //! //! # Example //! @@ -30,10 +32,7 @@ //! assert!(metadata.output("cp").is_some()); //! ``` -use pharmsol_dsl::{ - AnalyticalKernel, CovariateInterpolation, ModelKind, NUMERIC_OUTPUT_PREFIX, - NUMERIC_ROUTE_PREFIX, -}; +use pharmsol_dsl::{AnalyticalKernel, CovariateInterpolation, ModelKind}; use std::fmt; use thiserror::Error; @@ -223,11 +222,6 @@ impl ValidatedModelMetadata { .position(|route| route.kind() == kind && route.name() == name) } - /// Look up an output by public name and return its dense output index. - pub(crate) fn output_index(&self, name: &str) -> Option { - self.outputs.iter().position(|output| output.name() == name) - } - /// Resolve a public route label from data to a declared route of the /// given kind. /// @@ -235,13 +229,9 @@ impl ValidatedModelMetadata { /// route), so the kind is part of the match: bolus events resolve bolus /// routes and infusion events resolve infusion routes. /// - /// Resolution order: - /// 1. exact route name of the given kind, - /// 2. canonical numeric alias (`input_`) for a bare numeric label, - /// matching Pmetrics `INPUT` numbering. - /// - /// A bare numeric label never falls back to a declaration position, so - /// `"10"` does not resolve unless an `input_10` route is declared. + /// Labels are opaque strings matched exactly against declared route + /// names. A numeric label such as `"1"` is just a name: it never falls + /// back to a declaration position and is not aliased to anything else. /// /// Use [`ValidatedRoute::input_index`] on the result for the dense /// execution input slot. @@ -249,29 +239,16 @@ impl ValidatedModelMetadata { self.routes .iter() .find(|route| route.kind() == kind && route.name() == label) - .or_else(|| { - if !is_bare_numeric_label(label) { - return None; - } - let aliased = format!("{NUMERIC_ROUTE_PREFIX}{label}"); - self.routes - .iter() - .find(|route| route.kind() == kind && route.name() == aliased) - }) } /// Resolve a public output label from data to its dense output index. /// - /// Uses the same resolution order as [`Self::route_for_label`]: exact - /// output name, then the `outeq_` alias matching Pmetrics `OUTEQ` - /// numbering, with no positional fallback. + /// Like [`Self::route_for_label`], this is an exact name match with no + /// aliasing and no positional fallback. pub fn output_for_label(&self, label: &str) -> Option { - self.output_index(label).or_else(|| { - if !is_bare_numeric_label(label) { - return None; - } - self.output_index(&format!("{NUMERIC_OUTPUT_PREFIX}{label}")) - }) + self.outputs + .iter() + .position(|output| output.name() == label) } pub fn parameter(&self, name: &str) -> Option<&Parameter> { @@ -291,21 +268,16 @@ impl ValidatedModelMetadata { /// Look up a route by public name. /// /// Legacy name-only lookup: when a bolus and an infusion share a label, - /// this returns the first matching declaration. Prefer [`Self::route_by_kind`] + /// this returns the first matching declaration. Prefer [`Self::route_for_label`] /// when the route kind is known. pub fn route(&self, name: &str) -> Option<&ValidatedRoute> { self.route_declaration_index(name) .map(|index| &self.routes[index]) } - /// Look up a route by public name and kind. - pub fn route_by_kind(&self, name: &str, kind: RouteKind) -> Option<&ValidatedRoute> { - self.route_declaration_index_by_kind(name, kind) - .map(|index| &self.routes[index]) - } - pub fn output(&self, name: &str) -> Option<&Output> { - self.output_index(name).map(|index| &self.outputs[index]) + self.output_for_label(name) + .map(|index| &self.outputs[index]) } } @@ -812,11 +784,6 @@ impl Route { } } -/// Returns `true` for labels consisting only of ASCII digits (`"0"`, `"12"`). -pub(crate) fn is_bare_numeric_label(label: &str) -> bool { - !label.is_empty() && label.chars().all(|ch| ch.is_ascii_digit()) -} - fn resolve_kind( declared_kind: Option, requested_kind: Option, @@ -1100,7 +1067,7 @@ mod tests { assert!(metadata.route("iv").is_some()); assert_eq!(metadata.route_declaration_index("iv"), Some(0)); assert_eq!(metadata.route_input_count(), 1); - assert_eq!(metadata.output_index("cp"), Some(0)); + assert_eq!(metadata.output_for_label("cp"), Some(0)); assert_eq!( metadata.route("iv").expect("route exists").destination(), "central" @@ -1123,47 +1090,51 @@ mod tests { } #[test] - fn numeric_labels_resolve_via_canonical_alias_only() { + fn numeric_labels_resolve_by_exact_name_only() { let metadata = new("mixed_labels") .kind(ModelKind::Ode) .parameters(["ke", "v"]) .states(["gut", "central"]) - .outputs(["cp", "outeq_0", "outeq_1"]) + .outputs(["cp", "2", "outeq_1"]) .route(Route::infusion("iv").to_state("central")) - .route(Route::bolus("input_0").to_state("gut")) + .route(Route::bolus("10").to_state("gut")) + .route(Route::bolus("input_1").to_state("central")) .validate() .expect("metadata should validate"); - // Exact names resolve first. assert_eq!(metadata.output_for_label("cp"), Some(0)); + assert_eq!(metadata.output_for_label("2"), Some(1)); + assert_eq!(metadata.output_for_label("outeq_1"), Some(2)); assert_eq!( metadata .route_for_label("iv", RouteKind::Infusion) .map(|route| route.declaration_index()), Some(0) ); - - // Bare numeric labels resolve through the `outeq_`/`input_` - // aliases, including `0`. - assert_eq!(metadata.output_for_label("0"), Some(1)); - assert_eq!(metadata.output_for_label("1"), Some(2)); assert_eq!( metadata - .route_for_label("0", RouteKind::Bolus) + .route_for_label("10", RouteKind::Bolus) .map(|route| route.declaration_index()), Some(1) ); + assert_eq!( + metadata + .route_for_label("input_1", RouteKind::Bolus) + .map(|route| route.declaration_index()), + Some(2) + ); - // Without a declared alias, a bare numeric label never falls back to - // a declaration position. - assert_eq!(metadata.output_for_label("2"), None); - assert_eq!(metadata.output_for_label("3"), None); - assert!(metadata.route_for_label("1", RouteKind::Bolus).is_none()); - assert!(metadata.route_for_label("2", RouteKind::Bolus).is_none()); - assert_eq!(metadata.output_for_label("missing"), None); - assert!(metadata - .route_for_label("missing", RouteKind::Bolus) - .is_none()); + // Numeric labels never fall back to a position, and prefixed-looking + // names are not aliases of bare numbers (or vice versa). + for label in ["0", "1", "02", "outeq_2", "missing"] { + assert_eq!(metadata.output_for_label(label), None, "{label}"); + } + for label in ["0", "1", "2", "010", "input_10", "missing"] { + assert!( + metadata.route_for_label(label, RouteKind::Bolus).is_none(), + "{label}" + ); + } } #[test] @@ -1266,8 +1237,8 @@ mod tests { .states(["central"]) .outputs(["cp"]) .routes([ - Route::bolus("input_1").to_state("central"), - Route::infusion("input_1").to_state("central"), + Route::bolus("1").to_state("central"), + Route::infusion("1").to_state("central"), ]) .validate() .expect("bolus and infusion sharing a label must validate"); @@ -1276,28 +1247,28 @@ mod tests { assert_eq!(metadata.route_input_count(), 1); assert_eq!( metadata - .route_for_label("input_1", RouteKind::Bolus) + .route_for_label("1", RouteKind::Bolus) .expect("bolus route resolves by label") .input_index(), 0 ); assert_eq!( metadata - .route_for_label("input_1", RouteKind::Infusion) + .route_for_label("1", RouteKind::Infusion) .expect("infusion route resolves by label") .input_index(), 0 ); assert_eq!( metadata - .route_for_label("input_1", RouteKind::Bolus) + .route_for_label("1", RouteKind::Bolus) .expect("bolus route resolves by label") .destination(), "central" ); assert_eq!( metadata - .route_for_label("input_1", RouteKind::Infusion) + .route_for_label("1", RouteKind::Infusion) .expect("infusion route resolves by label") .destination(), "central" @@ -1312,8 +1283,8 @@ mod tests { .states(["central"]) .outputs(["cp"]) .routes([ - Route::bolus("input_1").to_state("central"), - Route::bolus("input_1").to_state("central"), + Route::bolus("1").to_state("central"), + Route::bolus("1").to_state("central"), ]) .validate() .expect_err("duplicate bolus routes must fail"); @@ -1322,7 +1293,7 @@ mod tests { error, ModelMetadataError::DuplicateName { domain: NameDomain::Route, - name: "input_1".to_string(), + name: "1".to_string(), } ); } @@ -1335,47 +1306,47 @@ mod tests { .states(["gut", "peripheral", "central"]) .outputs(["cp"]) .routes([ - Route::bolus("input_1").to_state("gut"), - Route::infusion("input_2").to_state("peripheral"), - Route::infusion("input_1").to_state("central"), + Route::bolus("1").to_state("gut"), + Route::infusion("2").to_state("peripheral"), + Route::infusion("1").to_state("central"), ]) .validate() .expect("mixed-order shared-label metadata should validate"); - // The `input_1` bolus is the first bolus: bolus ordinal 0, gut. + // The `1` bolus is the first bolus: bolus ordinal 0, gut. let bolus = metadata - .route_for_label("input_1", RouteKind::Bolus) + .route_for_label("1", RouteKind::Bolus) .expect("bolus route"); assert_eq!(bolus.input_index(), 0); assert_eq!(bolus.destination(), "gut"); assert_eq!( - metadata.route_declaration_index_by_kind("input_1", RouteKind::Bolus), + metadata.route_declaration_index_by_kind("1", RouteKind::Bolus), Some(0) ); - // The `input_1` infusion is the second infusion: infusion ordinal 1, - // central. It must never resolve to the `input_2` infusion slot 0. + // The `1` infusion is the second infusion: infusion ordinal 1, + // central. It must never resolve to the `2` infusion slot 0. let infusion = metadata - .route_for_label("input_1", RouteKind::Infusion) + .route_for_label("1", RouteKind::Infusion) .expect("infusion route"); assert_eq!(infusion.input_index(), 1); assert_eq!(infusion.destination(), "central"); assert_eq!( - metadata.route_declaration_index_by_kind("input_1", RouteKind::Infusion), + metadata.route_declaration_index_by_kind("1", RouteKind::Infusion), Some(2) ); assert_eq!( metadata - .route_by_kind("input_1", RouteKind::Infusion) + .route_for_label("1", RouteKind::Infusion) .expect("route") .destination(), "central" ); - // The `input_2` infusion keeps its own infusion ordinal 0. + // The `2` infusion keeps its own infusion ordinal 0. let other = metadata - .route_for_label("input_2", RouteKind::Infusion) - .expect("input_2 route"); + .route_for_label("2", RouteKind::Infusion) + .expect("route `2` route"); assert_eq!(other.input_index(), 0); assert_eq!(other.destination(), "peripheral"); @@ -1384,15 +1355,15 @@ mod tests { // unique labels. assert_eq!( metadata - .route("input_1") + .route("1") .expect("first-match route") .declaration_index(), 0 ); - assert_eq!(metadata.route_declaration_index("input_2"), Some(1)); + assert_eq!(metadata.route_declaration_index("2"), Some(1)); // The shared label is listed once in the error-message label list. - assert_eq!(metadata.route_labels(), vec!["input_1", "input_2"]); + assert_eq!(metadata.route_labels(), vec!["1", "2"]); } #[test] diff --git a/src/simulator/equation/mod.rs b/src/simulator/equation/mod.rs index 31c1e1fc..db0db9d7 100644 --- a/src/simulator/equation/mod.rs +++ b/src/simulator/equation/mod.rs @@ -20,11 +20,12 @@ //! //! Input and output labels arrive from public data APIs as strings. //! -//! - Without metadata, handwritten models fall back to numeric labels such as -//! `0` or `1`. //! - With [`metadata::ModelMetadata`] attached, route and output labels are -//! resolved by name against the declared routes and outputs before -//! simulation. +//! resolved by exact name against the declared routes and outputs before +//! simulation. Numeric names such as `"1"` are ordinary names and never +//! resolve by position. +//! - Without metadata, handwritten models declare no names, so labels are +//! dense indices such as `0` or `1`. //! //! That label-first path is the preferred public workflow for current authoring. //! @@ -360,8 +361,8 @@ pub(crate) trait EquationPriv: EquationTypes { /// /// Subject data enters this layer through public labels on dose and observation /// events. If metadata is attached to the equation, those labels are resolved by -/// name before simulation. Otherwise, the execution layer expects numeric labels -/// that can be interpreted as dense indices. +/// exact name before simulation. Otherwise, the equation declares no names and +/// the execution layer expects labels that are dense indices. /// /// # Likelihood Calculation /// diff --git a/src/simulator/equation/ode/mod.rs b/src/simulator/equation/ode/mod.rs index daed0e90..c7f6816d 100644 --- a/src/simulator/equation/ode/mod.rs +++ b/src/simulator/equation/ode/mod.rs @@ -2954,7 +2954,7 @@ mod tests { } #[test] - fn handwritten_ode_metadata_resolves_raw_numeric_aliases_against_canonical_labels() { + fn handwritten_ode_metadata_resolves_numeric_labels_by_exact_name() { let ode = ODE::new( explicit_route_function, zero_lag, @@ -2968,34 +2968,38 @@ mod tests { .with_metadata( super::super::metadata::new("numeric_alias_ode") .states(["central"]) - .outputs(["outeq_1"]) - .route(super::super::Route::infusion("input_1").to_state("central")), + .outputs(["1"]) + .route(super::super::Route::infusion("1").to_state("central")), ) .expect("metadata attachment should validate"); - let canonical = Subject::builder("canonical") - .infusion(0.0, 100.0, "input_1", 1.0) - .observation(1.0, 0.0, "outeq_1") - .build(); - let aliased = Subject::builder("aliased") + let numeric = Subject::builder("numeric") .infusion(0.0, 100.0, "1", 1.0) .observation(1.0, 0.0, "1") .build(); - - let canonical_predictions = ode - .simulate_subject(&canonical, &crate::parameters::dense([]), None) - .expect("canonical labels should simulate") - .0; - let aliased_predictions = ode - .simulate_subject(&aliased, &crate::parameters::dense([]), None) - .expect("raw numeric aliases should simulate") - .0; - - assert_relative_eq!( - canonical_predictions.predictions()[0].prediction(), - aliased_predictions.predictions()[0].prediction(), - epsilon = 1e-6 - ); + let prediction = ode + .simulate_subject(&numeric, &crate::parameters::dense([]), None) + .expect("numeric labels should resolve by exact name") + .0 + .predictions()[0] + .prediction(); + assert!(prediction > 0.0); + + // `input_1` / `outeq_1` are different names, not aliases of `1`. + for prefixed in [ + Subject::builder("prefixed_input") + .infusion(0.0, 100.0, "input_1", 1.0) + .observation(1.0, 0.0, "1") + .build(), + Subject::builder("prefixed_output") + .infusion(0.0, 100.0, "1", 1.0) + .observation(1.0, 0.0, "outeq_1") + .build(), + ] { + assert!(ode + .simulate_subject(&prefixed, &crate::parameters::dense([]), None) + .is_err()); + } } #[test] @@ -3245,7 +3249,7 @@ mod tests { super::super::metadata::new("dense_grid_near_event_ode") .states(["central"]) .outputs(["cp"]) - .route(super::super::Route::bolus("input_0").to_state("central")), + .route(super::super::Route::bolus("0").to_state("central")), ) .expect("metadata should validate"); diff --git a/src/simulator/equation/sde/mod.rs b/src/simulator/equation/sde/mod.rs index cb1952b9..5059e440 100644 --- a/src/simulator/equation/sde/mod.rs +++ b/src/simulator/equation/sde/mod.rs @@ -834,7 +834,7 @@ mod tests { } #[test] - fn handwritten_sde_metadata_resolves_raw_numeric_aliases_against_canonical_labels() { + fn handwritten_sde_metadata_resolves_numeric_labels_by_exact_name() { let drift = |_x: &V, _p: &V, _t: f64, dx: &mut V, rateiv: &V, _cov: &Covariates| { dx.fill(0.0); dx[1] = rateiv[0]; @@ -858,33 +858,37 @@ mod tests { .with_metadata( equation::metadata::new("numeric_alias_sde") .states(["depot", "central"]) - .outputs(["outeq_1"]) - .route(Route::infusion("input_1").to_state("central")) + .outputs(["1"]) + .route(Route::infusion("1").to_state("central")) .particles(16), ) .expect("SDE metadata attachment should validate"); - let canonical = Subject::builder("canonical") - .infusion(0.0, 100.0, "input_1", 1.0) - .observation(1.0, 0.0, "outeq_1") - .build(); - let aliased = Subject::builder("aliased") + let numeric = Subject::builder("numeric") .infusion(0.0, 100.0, "1", 1.0) .observation(1.0, 0.0, "1") .build(); - - let canonical_predictions = sde - .estimate_predictions(&canonical, &crate::parameters::dense([])) - .expect("canonical labels should simulate"); - let aliased_predictions = sde - .estimate_predictions(&aliased, &crate::parameters::dense([])) - .expect("raw numeric aliases should simulate"); - - assert!( - (canonical_predictions[[0, 0]].prediction() - aliased_predictions[[0, 0]].prediction()) - .abs() - < 1e-10 - ); + let prediction = sde + .estimate_predictions(&numeric, &crate::parameters::dense([])) + .expect("numeric labels should resolve by exact name")[[0, 0]] + .prediction(); + assert!(prediction > 0.0); + + // `input_1` / `outeq_1` are different names, not aliases of `1`. + for prefixed in [ + Subject::builder("prefixed_input") + .infusion(0.0, 100.0, "input_1", 1.0) + .observation(1.0, 0.0, "1") + .build(), + Subject::builder("prefixed_output") + .infusion(0.0, 100.0, "1", 1.0) + .observation(1.0, 0.0, "outeq_1") + .build(), + ] { + assert!(sde + .estimate_predictions(&prefixed, &crate::parameters::dense([])) + .is_err()); + } } #[test] diff --git a/src/simulator/likelihood/matrix.rs b/src/simulator/likelihood/matrix.rs index 2ff73273..3492baef 100644 --- a/src/simulator/likelihood/matrix.rs +++ b/src/simulator/likelihood/matrix.rs @@ -184,7 +184,7 @@ mod tests { fn likelihood_error_models() -> AssayErrorModels { AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), ) diff --git a/src/simulator/likelihood/mod.rs b/src/simulator/likelihood/mod.rs index 62ee58b0..2608d812 100644 --- a/src/simulator/likelihood/mod.rs +++ b/src/simulator/likelihood/mod.rs @@ -79,6 +79,8 @@ pub use matrix::psi; pub use prediction::Prediction; pub use subject::{PopulationPredictions, SubjectPredictions}; +use std::borrow::Cow; + use ndarray::{Array2, Axis}; use rayon::prelude::*; @@ -106,8 +108,8 @@ use crate::{Data, Equation, PharmsolError, Predictions, Subject}; /// use pharmsol::prelude::simulator::log_likelihood_batch; /// use pharmsol::{ResidualErrorModel, ResidualErrorModels}; /// -/// let residual_error = ResidualAssayErrorModels::new() -/// .add(0, ResidualErrorModel::constant(0.5)); +/// let residual_error = ResidualErrorModels::new() +/// .add("cp", ResidualErrorModel::constant(0.5)); /// /// let log_liks = log_likelihood_batch( /// &equation, @@ -133,6 +135,9 @@ pub fn log_likelihood_batch( ))); } + let residual_error_models = bind_residual_error_models(equation, residual_error_models)?; + let residual_error_models = residual_error_models.as_ref(); + let score_subject = |subject: &Subject, parameter_row: &[f64]| { let predictions = match equation.estimate_predictions_dense(subject, parameter_row) { Ok(preds) => preds, @@ -189,7 +194,9 @@ pub fn log_likelihood_batch( /// - `residual_error_models`: The residual error models (prediction-based sigma) /// /// # Returns -/// The log-likelihood for this subject. Returns `f64::NEG_INFINITY` on simulation error. +/// The log-likelihood for this subject. Returns `f64::NEG_INFINITY` on simulation error +/// or when a residual error model names an output the equation does not declare +/// (use [`log_likelihood_batch`] to get that binding error back). /// /// # Example /// ```ignore @@ -208,6 +215,11 @@ pub fn log_likelihood_subject( params: &crate::Parameters, residual_error_models: &crate::ResidualErrorModels, ) -> f64 { + let Ok(residual_error_models) = bind_residual_error_models(equation, residual_error_models) + else { + return f64::NEG_INFINITY; + }; + // Simulate to get predictions let predictions = match equation.estimate_predictions_dense(subject, params.as_slice()) { Ok(preds) => preds, @@ -226,6 +238,21 @@ pub fn log_likelihood_subject( residual_error_models.total_log_likelihood(obs_pred_pairs) } +/// Bind label-keyed residual error models to the equation's output slots by +/// exact output name. +fn bind_residual_error_models<'a>( + equation: &impl Equation, + residual_error_models: &'a crate::ResidualErrorModels, +) -> Result, PharmsolError> { + if residual_error_models.is_bound() { + return Ok(Cow::Borrowed(residual_error_models)); + } + let outputs = equation.assay_error_models().bound_output_names(); + Ok(Cow::Owned( + residual_error_models.bind_output_names(&outputs)?, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -249,7 +276,7 @@ mod tests { // Create error model with additive error let error_models = crate::AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), ) @@ -296,7 +323,7 @@ mod tests { let subject_predictions = SubjectPredictions::from(predictions); let error_models = crate::AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), ) @@ -331,7 +358,7 @@ mod tests { let error_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 0.0); let errors = crate::AssayErrorModels::empty() - .add(0, error_model) + .add_at(0, error_model) .unwrap(); let log_lik = preds.log_likelihood(&errors).unwrap(); diff --git a/src/simulator/likelihood/prediction.rs b/src/simulator/likelihood/prediction.rs index b1610f56..4e6d17c0 100644 --- a/src/simulator/likelihood/prediction.rs +++ b/src/simulator/likelihood/prediction.rs @@ -234,7 +234,7 @@ mod tests { fn create_error_models() -> AssayErrorModels { AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 0.0), ) diff --git a/src/simulator/likelihood/subject.rs b/src/simulator/likelihood/subject.rs index 05c1417f..a9696610 100644 --- a/src/simulator/likelihood/subject.rs +++ b/src/simulator/likelihood/subject.rs @@ -172,7 +172,7 @@ mod tests { fn create_error_models() -> AssayErrorModels { AssayErrorModels::empty() - .add( + .add_at( 0, AssayErrorModel::additive(ErrorPoly::new(0.0, 1.0, 0.0, 0.0), 0.0), ) @@ -201,7 +201,7 @@ mod tests { preds.add_prediction(obs.to_prediction(1.0, vec![])); let error_model = AssayErrorModel::additive(ErrorPoly::new(1.0, 0.0, 0.0, 0.0), 0.0); - let errors = AssayErrorModels::empty().add(0, error_model).unwrap(); + let errors = AssayErrorModels::empty().add_at(0, error_model).unwrap(); let log_lik = preds.log_likelihood(&errors).unwrap(); assert!(log_lik.is_finite()); diff --git a/tests/authoring_parity_corpus.rs b/tests/authoring_parity_corpus.rs index 396952b7..85a3c841 100644 --- a/tests/authoring_parity_corpus.rs +++ b/tests/authoring_parity_corpus.rs @@ -66,14 +66,14 @@ kind = ode params = ke, v states = central -outputs = outeq_2, outeq_10, outeq_11 +outputs = 2, 10, 11 dx(central) = infusion(iv) - ke * central -out(outeq_10) = central / v ~ continuous() -out(outeq_2) = central / v ~ continuous() -out(outeq_11) = central / v ~ continuous() +out(10) = central / v ~ continuous() +out(2) = central / v ~ continuous() +out(11) = central / v ~ continuous() "#; const ODE_NUMERIC_ROUTE_LABELS_AUTHORING_DSL: &str = r#" @@ -84,8 +84,8 @@ states = first, second outputs = cp -dx(first) = bolus(input_10) + 0 -dx(second) = bolus(input_11) + 0 +dx(first) = bolus(10) + 0 +dx(second) = bolus(11) + 0 out(cp) = first + second ~ continuous() "#; @@ -97,8 +97,8 @@ const ODE_NUMERIC_ROUTE_LABELS_STRUCTURED_DSL: &str = r#"model structured_numeri second, } routes { - input_10 -> first - input_11 -> second + 10 -> first + 11 -> second } dynamics { ddt(first) = 0 @@ -149,14 +149,14 @@ kind = ode params = ke, v states = central -outputs = cp, outeq_0, outeq_1 +outputs = cp, 0, 1 dx(central) = infusion(iv) - ke * central out(cp) = central / v ~ continuous() -out(outeq_0) = 2 * central / v ~ continuous() -out(outeq_1) = 3 * central / v ~ continuous() +out(0) = 2 * central / v ~ continuous() +out(1) = 3 * central / v ~ continuous() "#; #[cfg(feature = "dsl")] @@ -1162,15 +1162,15 @@ fn ode_dsl_declared_output_order_controls_dense_indices_for_multi_digit_labels() dsl_view.outputs, vec![ NamedIndex { - name: "outeq_2".to_string(), + name: "2".to_string(), index: 0, }, NamedIndex { - name: "outeq_10".to_string(), + name: "10".to_string(), index: 1, }, NamedIndex { - name: "outeq_11".to_string(), + name: "11".to_string(), index: 2, }, ] @@ -1186,7 +1186,7 @@ fn ode_authoring_dsl_supports_prefixed_multi_digit_numeric_route_labels() { dsl_view.routes, vec![ RouteParity { - name: "input_10".to_string(), + name: "10".to_string(), kind: Some(RouteKindParity::Bolus), declaration_index: 0, input_index: 0, @@ -1196,7 +1196,7 @@ fn ode_authoring_dsl_supports_prefixed_multi_digit_numeric_route_labels() { has_bioavailability: false, }, RouteParity { - name: "input_11".to_string(), + name: "11".to_string(), kind: Some(RouteKindParity::Bolus), declaration_index: 1, input_index: 1, @@ -1218,7 +1218,7 @@ fn ode_structured_dsl_supports_prefixed_multi_digit_numeric_route_labels() { dsl_view.routes, vec![ RouteParity { - name: "input_10".to_string(), + name: "10".to_string(), kind: None, declaration_index: 0, input_index: 0, @@ -1228,7 +1228,7 @@ fn ode_structured_dsl_supports_prefixed_multi_digit_numeric_route_labels() { has_bioavailability: false, }, RouteParity { - name: "input_11".to_string(), + name: "11".to_string(), kind: None, declaration_index: 1, input_index: 1, @@ -1718,22 +1718,16 @@ fn ode_runtime_jit_preserves_mixed_output_labels() { let subject = Subject::builder("runtime-mixed-output-labels") .infusion(0.0, 100.0, "iv", 1.0) .missing_observation(0.5, "cp") - .missing_observation(0.5, "outeq_0") - .missing_observation(0.5, "outeq_1") + .missing_observation(0.5, "0") + .missing_observation(0.5, "1") .build(); let support_point = pharmsol::Parameters::with_model(&runtime_model, [("ke", 0.2), ("v", 10.0)]) .expect("valid named parameters"); assert_eq!(compiled_output_slot_index(&runtime_model, "cp"), Some(0)); - assert_eq!( - compiled_output_slot_index(&runtime_model, "outeq_0"), - Some(1) - ); - assert_eq!( - compiled_output_slot_index(&runtime_model, "outeq_1"), - Some(2) - ); + assert_eq!(compiled_output_slot_index(&runtime_model, "0"), Some(1)); + assert_eq!(compiled_output_slot_index(&runtime_model, "1"), Some(2)); let predictions = match runtime_model .estimate_predictions(&subject, &support_point) diff --git a/tests/bolus_reinit_stop_time.rs b/tests/bolus_reinit_stop_time.rs index 710985de..35155e73 100644 --- a/tests/bolus_reinit_stop_time.rs +++ b/tests/bolus_reinit_stop_time.rs @@ -98,10 +98,10 @@ const LAGGED_STOP_OBSERVATION_TIMES: [f64; 16] = [ fn subject_with_bolus_history() -> Subject { let mut builder = Subject::builder("g34"); for dose_index in -16..=0 { - builder = builder.bolus(f64::from(dose_index) * 12.0, 750.0, "input_1"); + builder = builder.bolus(f64::from(dose_index) * 12.0, 750.0, "1"); } for time in OBSERVATION_TIMES { - builder = builder.missing_observation(time, "outeq_1"); + builder = builder.missing_observation(time, "1"); } builder.build() } @@ -109,10 +109,10 @@ fn subject_with_bolus_history() -> Subject { fn subject_with_crushed_endpoint_step() -> Subject { let mut builder = Subject::builder("g10"); for dose_index in -16..=0 { - builder = builder.bolus(f64::from(dose_index) * 12.0, 1000.0, "input_1"); + builder = builder.bolus(f64::from(dose_index) * 12.0, 1000.0, "1"); } for time in CRUSHED_STOP_OBSERVATION_TIMES { - builder = builder.missing_observation(time, "outeq_1"); + builder = builder.missing_observation(time, "1"); } builder.build() } @@ -120,10 +120,10 @@ fn subject_with_crushed_endpoint_step() -> Subject { fn subject_with_lagged_absolute_stop_roundoff() -> Subject { let mut builder = Subject::builder("g28"); for dose_index in -16..=0 { - builder = builder.bolus(f64::from(dose_index) * 12.0, 750.0, "input_1"); + builder = builder.bolus(f64::from(dose_index) * 12.0, 750.0, "1"); } for time in LAGGED_STOP_OBSERVATION_TIMES { - builder = builder.missing_observation(time, "outeq_1"); + builder = builder.missing_observation(time, "1"); } builder.build() } @@ -156,8 +156,8 @@ fn closure_model(solver: OdeSolver) -> equation::ODE { equation::metadata::new("bolus_reinit_stop_time") .parameters(["ka", "cl0", "vc0", "q0", "vp0"]) .states(["x1", "x2", "x3"]) - .outputs(["outeq_1"]) - .routes([equation::Route::bolus("input_1") + .outputs(["1"]) + .routes([equation::Route::bolus("1") .to_state("x1") .expect_explicit_input()]), ) @@ -198,8 +198,8 @@ fn lagged_closure_model(solver: OdeSolver) -> equation::ODE { equation::metadata::new("lagged_absolute_stop_roundoff") .parameters(["lag1", "ka", "cl0", "vc0", "q0", "vp0", "fa1"]) .states(["x1", "x2", "x3"]) - .outputs(["outeq_1"]) - .routes([equation::Route::bolus("input_1") + .outputs(["1"]) + .routes([equation::Route::bolus("1") .to_state("x1") .expect_explicit_input()]), ) @@ -297,7 +297,7 @@ name = bolus_reinit_stop_time kind = ode params = ka, cl0, vc0, q0, vp0 states = x1, x2, x3 -outputs = outeq_1 +outputs = 1 cl = cl0 @@ -308,24 +308,23 @@ ke = cl / vc k23 = q / vc k32 = q / vp -dx(x1) = bolus(input_1) - (x1 * ka) -dx(x2) = infusion(input_1) + x1 * ka + (x3 * k32) - (x2 * (ke + k23)) +dx(x1) = bolus(1) - (x1 * ka) +dx(x2) = infusion(1) + x1 * ka + (x3 * k32) - (x2 * (ke + k23)) dx(x3) = x2 * k23 - (x3 * k32) -out(outeq_1) = x2 / vc +out(1) = x2 / vc "#; fn infusion_boundary_subject() -> Subject { Subject::builder("accepted_infusion_boundary") - .infusion(5.0, 100.0, "input_1", 10.0_f64.next_up() - 5.0) + .infusion(5.0, 100.0, "1", 10.0_f64.next_up() - 5.0) .missing_observation(10.0, "cp") .missing_observation(20.0, "cp") .build() } fn stepped_infusion_boundary_subject() -> Subject { - let mut builder = - Subject::builder("stepped_infusion_boundary").infusion(0.0, 100.0, "input_1", 12.0); + let mut builder = Subject::builder("stepped_infusion_boundary").infusion(0.0, 100.0, "1", 12.0); for time in OBSERVATION_TIMES.into_iter().filter(|time| *time < 12.0) { builder = builder.missing_observation(time, "cp"); } @@ -350,7 +349,7 @@ fn closure_infusion_model(solver: OdeSolver) -> equation::ODE { equation::metadata::new("accepted_infusion_boundary") .states(["central"]) .outputs(["cp"]) - .routes([equation::Route::infusion("input_1").to_state("central")]), + .routes([equation::Route::infusion("1").to_state("central")]), ) .expect("infusion metadata should validate") } @@ -417,7 +416,7 @@ name = accepted_infusion_boundary kind = ode states = central outputs = cp -dx(central) = infusion(input_1) - (0.5 * central) +dx(central) = infusion(1) - (0.5 * central) out(cp) = central "#; @@ -470,7 +469,7 @@ fn jit_solvers_restart_with_post_infusion_rhs() -> Result<(), Box Subject { Subject::builder("material_short_infusion") - .infusion(1.0, 100.0, "input_1", 1.0_f64.next_up() - 1.0) + .infusion(1.0, 100.0, "1", 1.0_f64.next_up() - 1.0) .missing_observation(2.0, "cp") .build() } @@ -623,7 +622,7 @@ kind = ode states = amount outputs = cp init(amount) = 10 -dx(amount) = bolus(input_1) + 0 +dx(amount) = bolus(1) + 0 out(cp) = amount "#; @@ -635,7 +634,7 @@ params = scale states = amount outputs = cp dose_scale = scale -dx(amount) = bolus(input_1) * (dose_scale) + 0 +dx(amount) = bolus(1) * (dose_scale) + 0 out(cp) = amount "#; @@ -645,8 +644,8 @@ name = rebase_absolute_time_close_gap kind = ode states = amount, time_weighted_amount outputs = cp, callback -dx(amount) = infusion(input_1) + 0 -dx(time_weighted_amount) = infusion(input_1) * time +dx(amount) = infusion(1) + 0 +dx(time_weighted_amount) = infusion(1) * time out(cp) = amount out(callback) = time_weighted_amount "#; @@ -788,7 +787,7 @@ fn assert_runtime_rebase_after_large_event(backend: &str, compiled: &CompiledRun let delivered = 0.3; let expected_time_weighted_amount = delivered * 0.5 * (center + stop); let subject = Subject::builder(format!("{backend}-rebase-after-large-event")) - .infusion(center, delivered, "input_1", gap) + .infusion(center, delivered, "1", gap) .missing_observation(0.0, "cp") .missing_observation(center, "cp") .missing_observation(stop, "cp") @@ -913,10 +912,10 @@ fn jit_runs_user_init_before_each_occasion() -> Result<(), Box Result<(), Box` / `outeq_` or resolved by position. + +use approx::assert_relative_eq; +use ndarray::Array2; +use pharmsol::prelude::simulator::log_likelihood_batch; +use pharmsol::prelude::*; +use pharmsol::{PharmsolError, Predictions, ResidualErrorModel, ResidualErrorModels}; + +const PMETRICS_HEADER: &str = "ID,EVID,TIME,DUR,DOSE,ADDL,II,INPUT,OUT,OUTEQ,CENS,C0,C1,C2,C3"; + +/// One subject: an infusion on `input`, three observations on `outeq`. +fn pmetrics_data(input: &str, outeq: &str) -> Data { + let csv = format!( + "{PMETRICS_HEADER}\n\ + pt1,1,0,1,100,.,.,{input},.,.,.,.,.,.,.\n\ + pt1,0,1,.,.,.,.,.,8.1,{outeq},0,.,.,.,.\n\ + pt1,0,2,.,.,.,.,.,6.9,{outeq},0,.,.,.,.\n\ + pt1,0,4,.,.,.,.,.,5.2,{outeq},0,.,.,.,.\n" + ); + Data::from_pmetrics_csv_bytes(csv.as_bytes()).expect("Pmetrics data parses") +} + +fn pmetrics_subject(input: &str, outeq: &str) -> Subject { + pmetrics_data(input, outeq).subjects()[0].clone() +} + +fn numeric_one_cpt() -> equation::ODE { + ode! { + name: "numeric_one_cpt", + params: [ke, v], + states: [central], + outputs: [1], + diffeq: |x, _t, dx| { + dx[central] = infusion[1] - ke * x[central]; + }, + out: |x, _t, y| { + y[1] = x[central] / v; + }, + } +} + +fn named_one_cpt() -> equation::ODE { + ode! { + name: "named_one_cpt", + params: [ke, v], + states: [central], + outputs: [cp], + diffeq: |x, _t, dx| { + dx[central] = infusion[iv] - ke * x[central]; + }, + out: |x, _t, y| { + y[cp] = x[central] / v; + }, + } +} + +fn assay_error_models(label: &str) -> AssayErrorModels { + AssayErrorModels::new() + .add( + label, + AssayErrorModel::additive(ErrorPoly::new(0.1, 0.1, 0.0, 0.0), 0.0), + ) + .expect("label-first assay error model") +} + +fn residual_error_models(label: &str) -> ResidualErrorModels { + ResidualErrorModels::new().add(label, ResidualErrorModel::combined(0.2, 0.1)) +} + +fn one_cpt_parameters(model: &equation::ODE) -> Parameters { + Parameters::with_model(model, [("ke", 0.2), ("v", 10.0)]).expect("valid parameters") +} + +fn prediction_values(predictions: &impl Predictions) -> Vec { + predictions + .get_predictions() + .iter() + .map(|prediction| prediction.prediction()) + .collect() +} + +#[test] +fn numeric_output_binds_error_models_from_pmetrics_data_like_a_named_output() { + let numeric = numeric_one_cpt(); + let named = named_one_cpt(); + // `1.0` is how many tools export the numeric Pmetrics column; it is + // normalized to the label `1` at parse time. + let numeric_data = pmetrics_data("1", "1.0"); + let named_data = pmetrics_data("iv", "cp"); + + let numeric_subject = &numeric_data.subjects()[0]; + let named_subject = &named_data.subjects()[0]; + + let numeric_ll = numeric + .estimate_log_likelihood( + numeric_subject, + &one_cpt_parameters(&numeric), + &assay_error_models("1"), + ) + .expect("assay error model for output `1` binds"); + let named_ll = named + .estimate_log_likelihood( + named_subject, + &one_cpt_parameters(&named), + &assay_error_models("cp"), + ) + .expect("assay error model for output `cp` binds"); + assert!(numeric_ll.is_finite()); + assert_relative_eq!(numeric_ll, named_ll, max_relative = 1e-12); + + let parameters = Array2::from_shape_vec((1, 2), vec![0.2, 10.0]).unwrap(); + let numeric_residual = log_likelihood_batch( + &numeric, + &numeric_data, + ¶meters, + &residual_error_models("1"), + ) + .expect("residual error model for output `1` binds"); + let named_residual = log_likelihood_batch( + &named, + &named_data, + ¶meters, + &residual_error_models("cp"), + ) + .expect("residual error model for output `cp` binds"); + assert!(numeric_residual[0].is_finite()); + assert_relative_eq!(numeric_residual[0], named_residual[0], max_relative = 1e-12); +} + +#[test] +fn error_models_for_undeclared_labels_fail_to_bind() { + let model = numeric_one_cpt(); + let data = pmetrics_data("1", "1"); + let subject = data.subjects()[0]; + let parameters = one_cpt_parameters(&model); + + // `0` is not a position, and `outeq_1` / `01` are not aliases of `1`. + for label in ["0", "2", "01", "outeq_1", "cp"] { + let error = model + .estimate_log_likelihood(subject, ¶meters, &assay_error_models(label)) + .expect_err("undeclared assay error-model label must not bind"); + assert!( + matches!( + &error, + PharmsolError::ErrorModelError( + pharmsol::data::error_model::ErrorModelError::UnknownOutputLabel(found) + ) if found == label + ), + "unexpected error for `{label}`: {error:?}" + ); + + let parameters = Array2::from_shape_vec((1, 2), vec![0.2, 10.0]).unwrap(); + assert!( + log_likelihood_batch(&model, &data, ¶meters, &residual_error_models(label)) + .is_err(), + "undeclared residual error-model label `{label}` must not bind" + ); + } +} + +#[test] +fn prefixed_looking_data_labels_are_not_aliases() { + let model = numeric_one_cpt(); + let parameters = one_cpt_parameters(&model); + + for (input, outeq) in [("input_1", "1"), ("1", "outeq_1"), ("1", "0"), ("0", "1")] { + let data = pmetrics_data(input, outeq); + assert!( + model + .estimate_predictions(data.subjects()[0], ¶meters) + .is_err(), + "INPUT={input}, OUTEQ={outeq} must not resolve" + ); + } +} + +fn output_two_only() -> equation::ODE { + ode! { + name: "output_two_only", + params: [ke, v], + states: [central], + outputs: [2], + diffeq: |x, _t, dx| { + dx[central] = infusion[iv] - ke * x[central]; + }, + out: |x, _t, y| { + y[2] = x[central] / v; + }, + } +} + +#[test] +fn non_contiguous_numeric_output_is_a_single_named_slot() { + let model = output_two_only(); + let metadata = model.metadata().expect("macro attaches metadata"); + assert_eq!(model.nouteqs(), 1); + assert_eq!(metadata.output_labels(), vec!["2".to_string()]); + assert_eq!(metadata.output_for_label("2"), Some(0)); + + let parameters = one_cpt_parameters(&model); + let predictions = model + .estimate_predictions(&pmetrics_subject("iv", "2"), ¶meters) + .expect("OUTEQ=2 resolves to the output declared as `2`"); + assert!(prediction_values(&predictions) + .iter() + .all(|value| *value > 0.0)); + + for outeq in ["1", "0"] { + let error = model + .estimate_predictions(&pmetrics_subject("iv", outeq), ¶meters) + .expect_err("undeclared numeric output must fail"); + assert!( + matches!(&error, PharmsolError::UnknownOutputLabel { label, .. } if label == outeq), + "unexpected error for OUTEQ={outeq}: {error:?}" + ); + } + + let error_models = assay_error_models("2"); + model + .estimate_log_likelihood(&pmetrics_subject("iv", "2"), ¶meters, &error_models) + .expect("assay error model for output `2` binds to the only slot"); +} + +/// Numeric (`1`, `10`, `2`) and named labels produce identical simulations. +fn numeric_subject() -> Subject { + Subject::builder("numeric") + .bolus(0.0, 100.0, "1") + .infusion(1.0, 50.0, "10", 1.0) + .missing_observation(0.5, "2") + .missing_observation(2.0, "2") + .missing_observation(2.0, "10") + .missing_observation(4.0, "10") + .build() +} + +fn named_subject() -> Subject { + Subject::builder("named") + .bolus(0.0, 100.0, "oral") + .infusion(1.0, 50.0, "iv", 1.0) + .missing_observation(0.5, "cp") + .missing_observation(2.0, "cp") + .missing_observation(2.0, "amount") + .missing_observation(4.0, "amount") + .build() +} + +#[test] +fn ode_macro_numeric_labels_match_named_labels() { + let numeric = ode! { + name: "numeric_two_route", + params: [ka, ke, v], + states: [gut, central], + outputs: [2, 10], + diffeq: |x, _t, dx| { + dx[gut] = bolus[1] - ka * x[gut]; + dx[central] = ka * x[gut] + infusion[10] - ke * x[central]; + }, + out: |x, _t, y| { + y[2] = x[central] / v; + y[10] = x[central]; + }, + }; + let named = ode! { + name: "named_two_route", + params: [ka, ke, v], + states: [gut, central], + outputs: [cp, amount], + diffeq: |x, _t, dx| { + dx[gut] = bolus[oral] - ka * x[gut]; + dx[central] = ka * x[gut] + infusion[iv] - ke * x[central]; + }, + out: |x, _t, y| { + y[cp] = x[central] / v; + y[amount] = x[central]; + }, + }; + + let metadata = numeric.metadata().expect("macro attaches metadata"); + assert_eq!(metadata.route_labels(), vec!["1", "10"]); + assert_eq!(metadata.output_labels(), vec!["2", "10"]); + + let values = [("ka", 1.0), ("ke", 0.2), ("v", 10.0)]; + let numeric_predictions = numeric + .estimate_predictions( + &numeric_subject(), + &Parameters::with_model(&numeric, values).unwrap(), + ) + .expect("numeric labels simulate"); + let named_predictions = named + .estimate_predictions( + &named_subject(), + &Parameters::with_model(&named, values).unwrap(), + ) + .expect("named labels simulate"); + + let numeric_values = prediction_values(&numeric_predictions); + assert!(numeric_values.iter().all(|value| *value > 0.0)); + assert_eq!(numeric_values, prediction_values(&named_predictions)); +} + +#[test] +fn analytical_macro_numeric_labels_match_named_labels() { + let numeric = analytical! { + name: "numeric_analytical", + params: [ka, ke, v], + states: [gut, central], + outputs: [2, 10], + routes: [ + bolus(1) -> gut, + infusion(10) -> central, + ], + structure: one_compartment_with_absorption, + out: |x, _t, y| { + y[2] = x[central] / v; + y[10] = x[central]; + }, + }; + let named = analytical! { + name: "named_analytical", + params: [ka, ke, v], + states: [gut, central], + outputs: [cp, amount], + routes: [ + bolus(oral) -> gut, + infusion(iv) -> central, + ], + structure: one_compartment_with_absorption, + out: |x, _t, y| { + y[cp] = x[central] / v; + y[amount] = x[central]; + }, + }; + + let metadata = numeric.metadata().expect("macro attaches metadata"); + assert_eq!(metadata.route_labels(), vec!["1", "10"]); + assert_eq!(metadata.output_labels(), vec!["2", "10"]); + + let values = [("ka", 1.0), ("ke", 0.2), ("v", 10.0)]; + let numeric_predictions = numeric + .estimate_predictions( + &numeric_subject(), + &Parameters::with_model(&numeric, values).unwrap(), + ) + .expect("numeric labels simulate"); + let named_predictions = named + .estimate_predictions( + &named_subject(), + &Parameters::with_model(&named, values).unwrap(), + ) + .expect("named labels simulate"); + + let numeric_values = prediction_values(&numeric_predictions); + assert!(numeric_values.iter().all(|value| *value > 0.0)); + assert_eq!(numeric_values, prediction_values(&named_predictions)); +} + +#[test] +fn sde_macro_numeric_labels_match_named_labels() { + // Zero diffusion keeps the particles deterministic. + let numeric = sde! { + name: "numeric_sde", + params: [ka, ke, v], + states: [gut, central], + outputs: [2, 10], + particles: 4, + routes: [ + bolus(1) -> gut, + infusion(10) -> central, + ], + drift: |x, _t, dx| { + dx[gut] = -ka * x[gut]; + dx[central] = ka * x[gut] - ke * x[central]; + }, + diffusion: |sigma| { + sigma[gut] = 0.0; + sigma[central] = 0.0; + }, + out: |x, _t, y| { + y[2] = x[central] / v; + y[10] = x[central]; + }, + }; + let named = sde! { + name: "named_sde", + params: [ka, ke, v], + states: [gut, central], + outputs: [cp, amount], + particles: 4, + routes: [ + bolus(oral) -> gut, + infusion(iv) -> central, + ], + drift: |x, _t, dx| { + dx[gut] = -ka * x[gut]; + dx[central] = ka * x[gut] - ke * x[central]; + }, + diffusion: |sigma| { + sigma[gut] = 0.0; + sigma[central] = 0.0; + }, + out: |x, _t, y| { + y[cp] = x[central] / v; + y[amount] = x[central]; + }, + }; + + let metadata = numeric.metadata().expect("macro attaches metadata"); + assert_eq!(metadata.route_labels(), vec!["1", "10"]); + assert_eq!(metadata.output_labels(), vec!["2", "10"]); + + let values = [("ka", 1.0), ("ke", 0.2), ("v", 10.0)]; + let numeric_predictions = numeric + .estimate_predictions( + &numeric_subject(), + &Parameters::with_model(&numeric, values).unwrap(), + ) + .expect("numeric labels simulate"); + let named_predictions = named + .estimate_predictions( + &named_subject(), + &Parameters::with_model(&named, values).unwrap(), + ) + .expect("named labels simulate"); + + let numeric_values = prediction_values(&numeric_predictions); + assert!(numeric_values.iter().all(|value| *value > 0.0)); + for (numeric, named) in numeric_values + .iter() + .zip(prediction_values(&named_predictions)) + { + assert_relative_eq!(*numeric, named, max_relative = 1e-10); + } +} + +#[test] +fn prefixed_looking_names_are_ordinary_labels() { + // `input_1` and `outeq_1` are just names; the output may even be called + // `input_1`. They do not answer to `1`. + let model = ode! { + name: "prefixed_looking", + params: [ke, v], + states: [central], + outputs: [input_1], + diffeq: |x, _t, dx| { + dx[central] = infusion[outeq_1] - ke * x[central]; + }, + out: |x, _t, y| { + y[input_1] = x[central] / v; + }, + }; + let parameters = Parameters::with_model(&model, [("ke", 0.2), ("v", 10.0)]).unwrap(); + + model + .estimate_predictions(&pmetrics_subject("outeq_1", "input_1"), ¶meters) + .expect("prefixed-looking names resolve exactly"); + model + .estimate_log_likelihood( + &pmetrics_subject("outeq_1", "input_1"), + ¶meters, + &assay_error_models("input_1"), + ) + .expect("error model binds to the output named `input_1`"); + + for (input, outeq) in [("1", "input_1"), ("outeq_1", "1")] { + assert!(model + .estimate_predictions(&pmetrics_subject(input, outeq), ¶meters) + .is_err()); + } +} + +#[cfg(feature = "dsl")] +mod dsl { + use super::*; + use pharmsol::dsl::{compile_module_source_to_runtime, CompiledRuntimeModel}; + + fn runtime_ode(source: &str, name: &str) -> pharmsol::dsl::RuntimeOdeModel { + match compile_module_source_to_runtime(source, Some(name), |_, _| {}) + .expect("DSL model compiles") + { + CompiledRuntimeModel::Ode(model) => model, + _ => panic!("expected an ODE model"), + } + } + + const NUMERIC_DSL: &str = r#" +name = numeric_dsl +kind = ode +params = ke, v +states = central +outputs = 1 +ddt(central) = infusion(1) - ke * central +out(1) = central / v +"#; + + const NAMED_DSL: &str = r#" +name = named_dsl +kind = ode +params = ke, v +states = central +outputs = cp +ddt(central) = infusion(iv) - ke * central +out(cp) = central / v +"#; + + #[test] + fn dsl_numeric_output_binds_error_models_from_pmetrics_data() { + let numeric = runtime_ode(NUMERIC_DSL, "numeric_dsl"); + let named = runtime_ode(NAMED_DSL, "named_dsl"); + let values = [("ke", 0.2), ("v", 10.0)]; + + let numeric_ll = numeric + .estimate_log_likelihood( + &pmetrics_subject("1", "1"), + &Parameters::with_model(&numeric, values).unwrap(), + &assay_error_models("1"), + ) + .expect("assay error model for DSL output `1` binds"); + let named_ll = named + .estimate_log_likelihood( + &pmetrics_subject("iv", "cp"), + &Parameters::with_model(&named, values).unwrap(), + &assay_error_models("cp"), + ) + .expect("assay error model for DSL output `cp` binds"); + assert!(numeric_ll.is_finite()); + assert_relative_eq!(numeric_ll, named_ll, max_relative = 1e-12); + + assert!(numeric + .estimate_log_likelihood( + &pmetrics_subject("1", "1"), + &Parameters::with_model(&numeric, values).unwrap(), + &assay_error_models("outeq_1"), + ) + .is_err()); + assert!(numeric + .estimate_predictions( + &pmetrics_subject("input_1", "1"), + &Parameters::with_model(&numeric, values).unwrap(), + ) + .is_err()); + } +} diff --git a/tests/ode_macro_lowering.rs b/tests/ode_macro_lowering.rs index c47909ce..9fd4eb78 100644 --- a/tests/ode_macro_lowering.rs +++ b/tests/ode_macro_lowering.rs @@ -33,8 +33,8 @@ fn subject_for_shared_input() -> Subject { fn subject_for_shared_label() -> Subject { Subject::builder("macro-shared-label") - .bolus(0.0, 500.0, "input_1") - .infusion(1.0, 100.0, "input_1", 2.0) + .bolus(0.0, 500.0, "1") + .infusion(1.0, 100.0, "1", 2.0) .missing_observation(0.5, "cp") .missing_observation(1.0, "cp") .missing_observation(2.0, "cp") @@ -114,13 +114,13 @@ fn numeric_label_macro_ode() -> equation::ODE { name: "numeric_label_one_cpt", params: [ke, v], states: [central], - outputs: [outeq_1], + outputs: [1], diffeq: |x, _t, dx| { - dx[central] = infusion[input_1] - ke * x[central]; + dx[central] = infusion[1] - ke * x[central]; }, out: |x, _t, y| { - y[outeq_1] = x[central] / v; + y[1] = x[central] / v; }, } } @@ -146,9 +146,9 @@ fn numeric_label_handwritten_ode() -> equation::ODE { equation::metadata::new("numeric_label_one_cpt") .parameters(["ke", "v"]) .states(["central"]) - .outputs(["outeq_1"]) + .outputs(["1"]) .route( - equation::Route::infusion("input_1") + equation::Route::infusion("1") .to_state("central") .inject_input_to_destination(), ), @@ -228,10 +228,10 @@ fn shared_label_macro_ode() -> equation::ODE { outputs: [cp], diffeq: |x, _t, dx| { - dx[central] = bolus[input_1] + infusion[input_1] - ke * x[central]; + dx[central] = bolus[1] + infusion[1] - ke * x[central]; }, lag: |_t| { - lag! { input_1 => tlag } + lag! { 1 => tlag } }, out: |x, _t, y| { y[cp] = x[central] / v; @@ -265,11 +265,11 @@ fn shared_label_handwritten_ode() -> equation::ODE { .states(["central"]) .outputs(["cp"]) .routes([ - equation::Route::bolus("input_1") + equation::Route::bolus("1") .to_state("central") .with_lag() .inject_input_to_destination(), - equation::Route::infusion("input_1") + equation::Route::infusion("1") .to_state("central") .inject_input_to_destination(), ]), @@ -282,18 +282,18 @@ fn numeric_route_property_macro_ode() -> equation::ODE { name: "numeric_route_property_one_cpt", params: [ka, ke, v, tlag, f_oral], states: [depot, central], - outputs: [outeq_1], + outputs: [1], diffeq: |x, _t, dx| { - dx[depot] = bolus[input_1] * (f_oral) - ka * x[depot]; + dx[depot] = bolus[1] * (f_oral) - ka * x[depot]; dx[central] = ka * x[depot] - ke * x[central]; }, lag: |_t| { - lag! { input_1 => tlag } + lag! { 1 => tlag } }, out: |x, _t, y| { - y[outeq_1] = x[central] / v; + y[1] = x[central] / v; }, } } @@ -326,9 +326,9 @@ fn numeric_route_property_handwritten_ode() -> equation::ODE { equation::metadata::new("numeric_route_property_one_cpt") .parameters(["ka", "ke", "v", "tlag", "f_oral"]) .states(["depot", "central"]) - .outputs(["outeq_1"]) + .outputs(["1"]) .route( - equation::Route::bolus("input_1") + equation::Route::bolus("1") .to_state("depot") .with_lag() .with_bioavailability() @@ -343,15 +343,15 @@ fn mixed_output_labels_macro_ode() -> equation::ODE { name: "mixed_output_labels_one_cpt", params: [ke, v], states: [central], - outputs: [cp, outeq_0, outeq_1], + outputs: [cp, 0, 1], diffeq: |x, _t, dx| { dx[central] = infusion[iv] - ke * x[central]; }, out: |x, _t, y| { y[cp] = x[central] / v; - y[outeq_0] = 2.0 * x[central] / v; - y[outeq_1] = 3.0 * x[central] / v; + y[0] = 2.0 * x[central] / v; + y[1] = 3.0 * x[central] / v; }, } } @@ -379,7 +379,7 @@ fn mixed_output_labels_handwritten_ode() -> equation::ODE { equation::metadata::new("mixed_output_labels_one_cpt") .parameters(["ke", "v"]) .states(["central"]) - .outputs(["cp", "outeq_0", "outeq_1"]) + .outputs(["cp", "0", "1"]) .route( equation::Route::infusion("iv") .to_state("central") @@ -517,8 +517,8 @@ fn macro_numeric_labels_lower_to_dense_slots() { .expect("macro numeric-label model should carry metadata"); assert_metadata_shape(¯o_ode, &handwritten_ode); - assert!(macro_metadata.route("input_1").is_some()); - assert!(macro_metadata.output("outeq_1").is_some()); + assert!(macro_metadata.route("1").is_some()); + assert!(macro_metadata.output("1").is_some()); assert_eq!(macro_ode.state_index("central"), Some(0)); let macro_predictions = macro_ode @@ -589,7 +589,7 @@ fn macro_shared_label_lowering_matches_handwritten_metadata_and_predictions() { .expect("macro shared-label model should carry metadata"); assert_metadata_shape(¯o_ode, &handwritten_ode); - assert!(macro_metadata.route("input_1").is_some()); + assert!(macro_metadata.route("1").is_some()); assert!(macro_metadata.output("cp").is_some()); assert_eq!(macro_ode.state_index("central"), Some(0)); @@ -625,8 +625,8 @@ fn macro_mixed_output_labels_lower_to_dense_slots() { assert_metadata_shape(¯o_ode, &handwritten_ode); assert!(macro_metadata.output("cp").is_some()); - assert!(macro_metadata.output("outeq_0").is_some()); - assert!(macro_metadata.output("outeq_1").is_some()); + assert!(macro_metadata.output("0").is_some()); + assert!(macro_metadata.output("1").is_some()); let macro_predictions = macro_ode .estimate_predictions(&subject, &support_point) @@ -663,8 +663,8 @@ fn macro_numeric_route_properties_lower_to_dense_slots() { .expect("macro numeric route-property model should carry metadata"); assert_metadata_shape(¯o_ode, &handwritten_ode); - assert!(macro_metadata.route("input_1").is_some()); - assert!(macro_metadata.output("outeq_1").is_some()); + assert!(macro_metadata.route("1").is_some()); + assert!(macro_metadata.output("1").is_some()); assert_eq!(macro_ode.state_index("depot"), Some(0)); assert_eq!(macro_ode.state_index("central"), Some(1)); diff --git a/tests/ode_optimizations.rs b/tests/ode_optimizations.rs index 12bda743..f15233cd 100644 --- a/tests/ode_optimizations.rs +++ b/tests/ode_optimizations.rs @@ -1148,7 +1148,7 @@ fn likelihood_calculation_matches_analytical() { let error_models = AssayErrorModels::default() .add( - 0, + "cp", AssayErrorModel::additive(ErrorPoly::new(0.0, 0.1, 0.0, 0.0), 0.0), ) .unwrap(); diff --git a/tests/support/bimodal_ke.rs b/tests/support/bimodal_ke.rs index 38b11e7c..fb194b22 100644 --- a/tests/support/bimodal_ke.rs +++ b/tests/support/bimodal_ke.rs @@ -47,13 +47,8 @@ pub fn subject() -> Subject { pub fn subject_for_runtime_model(model: &pharmsol::dsl::CompiledRuntimeModel) -> Subject { let route_label = if model.info().routes.iter().any(|route| route.name == "iv") { "iv" - } else if model - .info() - .routes - .iter() - .any(|route| route.name == "input_0") - { - "input_0" + } else if model.info().routes.iter().any(|route| route.name == "0") { + "0" } else { panic!("bimodal_ke route is available"); }; diff --git a/tests/test_pf.rs b/tests/test_pf.rs index 719aafc3..7ee46fc8 100644 --- a/tests/test_pf.rs +++ b/tests/test_pf.rs @@ -53,7 +53,7 @@ fn test_particle_filter_likelihood() { let ems = AssayErrorModels::default() .add( - 0, + "cp", AssayErrorModel::additive(ErrorPoly::new(0.5, 0.0, 0.0, 0.0), 0.0), ) .unwrap();