From 71769ca3a1daf17ed4866fdd2a3b29cf00c78468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Thu, 14 May 2026 18:10:39 +0100 Subject: [PATCH 1/4] feature: Metadata contract --- pharmsol-dsl/src/ir.rs | 2 +- src/dsl/aot.rs | 10 +- src/dsl/compiled_backend_abi.rs | 23 +- src/dsl/jit.rs | 10 +- src/dsl/mod.rs | 2 +- src/dsl/model_info.rs | 75 ++++- src/dsl/native.rs | 472 ++++++++++++++++++++++++++++++-- src/dsl/runtime.rs | 18 +- src/dsl/wasm.rs | 57 ++-- src/parameter_order.rs | 1 + 10 files changed, 598 insertions(+), 72 deletions(-) diff --git a/pharmsol-dsl/src/ir.rs b/pharmsol-dsl/src/ir.rs index 407a9307..5aab057e 100644 --- a/pharmsol-dsl/src/ir.rs +++ b/pharmsol-dsl/src/ir.rs @@ -128,7 +128,7 @@ pub struct TypedConstant { pub span: Span, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CovariateInterpolation { Linear, Locf, diff --git a/src/dsl/aot.rs b/src/dsl/aot.rs index 34e43e18..0e76c4cc 100644 --- a/src/dsl/aot.rs +++ b/src/dsl/aot.rs @@ -335,12 +335,18 @@ pub fn load_aot_model(path: impl AsRef) -> Result CompiledNativeModel::Ode(super::NativeOdeModel::new(info, artifact)), + ModelKind::Ode => CompiledNativeModel::Ode( + super::NativeOdeModel::new(info, artifact) + .map_err(|error| AotError::Load(error.to_string()))?, + ), ModelKind::Analytical => CompiledNativeModel::Analytical( super::NativeAnalyticalModel::new(info, artifact) .map_err(|error| AotError::Load(error.to_string()))?, ), - ModelKind::Sde => CompiledNativeModel::Sde(super::NativeSdeModel::new(info, artifact)), + ModelKind::Sde => CompiledNativeModel::Sde( + super::NativeSdeModel::new(info, artifact) + .map_err(|error| AotError::Load(error.to_string()))?, + ), }) } diff --git a/src/dsl/compiled_backend_abi.rs b/src/dsl/compiled_backend_abi.rs index bfb32c6f..a3c28121 100644 --- a/src/dsl/compiled_backend_abi.rs +++ b/src/dsl/compiled_backend_abi.rs @@ -275,9 +275,11 @@ fn kernel_output_len(info: &NativeModelInfo, role: KernelRole) -> usize { #[cfg(test)] mod tests { - use super::super::model_info::{NativeCovariateInfo, NativeOutputInfo, NativeRouteInfo}; + use super::super::model_info::{ + NativeCovariateInfo, NativeOutputInfo, NativeRouteInfo, NativeStateInfo, + }; use super::*; - use pharmsol_dsl::ModelKind; + use pharmsol_dsl::{ModelKind, RouteKind}; #[test] fn compiled_backend_symbol_names_are_frozen() { @@ -322,13 +324,27 @@ mod tests { covariates: vec![NativeCovariateInfo { name: "wt".to_string(), index: 0, + interpolation: None, }], + states: vec![ + NativeStateInfo { + name: "depot".to_string(), + offset: 0, + }, + NativeStateInfo { + name: "central".to_string(), + offset: 1, + }, + ], routes: vec![NativeRouteInfo { name: "iv".to_string(), declaration_index: 0, index: 0, - kind: None, + kind: Some(RouteKind::Infusion), destination_offset: 1, + destination_name: "central".to_string(), + has_lag: false, + has_bioavailability: false, inject_input_to_destination: true, }], outputs: vec![NativeOutputInfo { @@ -367,6 +383,7 @@ mod tests { parameters: vec![], derived: vec!["ke_i".to_string(), "v_i".to_string(), "cl_i".to_string()], covariates: vec![], + states: vec![], routes: vec![], outputs: vec![], state_len: 2, diff --git a/src/dsl/jit.rs b/src/dsl/jit.rs index 302dc7ed..d9691b57 100644 --- a/src/dsl/jit.rs +++ b/src/dsl/jit.rs @@ -1284,10 +1284,11 @@ pub fn compile_ode_model_to_jit(model: &ExecutionModel) -> Result Result, /// Declared covariates and their dense runtime indices. pub covariates: Vec, + /// Declared states together with their dense runtime offsets. + pub states: Vec, /// Declared routes together with declaration-order and dense runtime indices. pub routes: Vec, /// Declared outputs and their dense runtime indices. @@ -51,6 +53,17 @@ pub struct NativeCovariateInfo { pub name: String, /// Dense runtime covariate index. pub index: usize, + /// Optional interpolation policy declared for this covariate. + pub interpolation: Option, +} + +/// Metadata for one compiled state. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeStateInfo { + /// Public state name. + pub name: String, + /// Dense runtime state offset. + pub offset: usize, } /// Metadata for one compiled route. @@ -59,15 +72,19 @@ pub struct NativeRouteInfo { /// Public route label. pub name: String, /// Route position in declaration order. - #[serde(default)] pub declaration_index: usize, /// Dense runtime route-input index. pub index: usize, /// Coarse route kind when declared in metadata. - #[serde(default)] pub kind: Option, /// Dense destination state offset used by compiled kernels. pub destination_offset: usize, + /// Public destination state name. + pub destination_name: String, + /// Whether this route declares lag handling. + pub has_lag: bool, + /// Whether this route declares bioavailability handling. + pub has_bioavailability: bool, /// Whether the compiled backend injects the route input into the destination /// state automatically when the model does not read the route input /// explicitly. @@ -109,6 +126,16 @@ impl NativeModelInfo { .map(|covariate| NativeCovariateInfo { name: covariate.name.clone(), index: covariate.index, + interpolation: covariate.interpolation, + }) + .collect(), + states: model + .metadata + .states + .iter() + .map(|state| NativeStateInfo { + name: state.name.clone(), + offset: state.offset, }) .collect(), routes: model @@ -121,6 +148,9 @@ impl NativeModelInfo { index: route.index, kind: route.kind, destination_offset: route.destination.state_offset, + destination_name: route.destination.state_name.clone(), + has_lag: route.has_lag, + has_bioavailability: route.has_bioavailability, inject_input_to_destination: !explicit_route_input_usage .get(route.declaration_index) .copied() @@ -315,6 +345,45 @@ out(cp) = central / v ~ continuous() assert!(!info.routes[1].inject_input_to_destination); } + #[test] + fn native_model_info_preserves_state_covariate_and_route_metadata() { + let info = load_model_info( + r#" +name = metadata_surface +kind = ode + +params = ke, v +covariates = wt@linear +states = depot, central +outputs = cp + +bolus(oral) -> depot +infusion(iv) -> central +lag(oral) = 1.0 +fa(oral) = 0.8 + +dx(depot) = -ke * depot +dx(central) = ke * depot - rate(iv) + +out(cp) = central / v +"#, + ); + + assert_eq!(info.states.len(), 2); + assert_eq!(info.states[0].name, "depot"); + assert_eq!(info.states[1].name, "central"); + assert_eq!( + info.covariates[0].interpolation, + Some(CovariateInterpolation::Linear) + ); + assert_eq!(info.routes[0].destination_name, "depot"); + assert!(info.routes[0].has_lag); + assert!(info.routes[0].has_bioavailability); + assert_eq!(info.routes[1].destination_name, "central"); + assert!(!info.routes[1].has_lag); + assert!(!info.routes[1].has_bioavailability); + } + #[test] fn native_model_info_preserves_canonical_numeric_channel_names() { let info = load_model_info( diff --git a/src/dsl/native.rs b/src/dsl/native.rs index 75b995fa..c080202d 100644 --- a/src/dsl/native.rs +++ b/src/dsl/native.rs @@ -16,12 +16,12 @@ use cranelift_jit::JITModule; use libloading::Library; use pharmsol_dsl::execution::KernelRole; use pharmsol_dsl::{ - AnalyticalKernel, AnalyticalStructureInputKind, AnalyticalStructureInputPlan, RouteKind, - NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, + AnalyticalKernel, AnalyticalStructureInputKind, AnalyticalStructureInputPlan, ModelKind, + RouteKind, NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, }; pub use super::model_info::{ - NativeCovariateInfo, NativeModelInfo, NativeOutputInfo, NativeRouteInfo, + NativeCovariateInfo, NativeModelInfo, NativeOutputInfo, NativeRouteInfo, NativeStateInfo, }; use crate::{ data::error_model::AssayErrorModels, @@ -39,7 +39,7 @@ use crate::{ likelihood::{Prediction, SubjectPredictions}, Fa, Lag, M, T, V, }, - Event, Observation, Occasion, Parameters, PharmsolError, Subject, + Event, Observation, Occasion, Parameters, PharmsolError, Subject, ValidatedModelMetadata, }; pub type DenseKernelFn = unsafe extern "C" fn( @@ -274,10 +274,209 @@ impl RuntimeArtifact for NativeExecutionArtifact { #[derive(Clone, Debug)] struct SharedNativeModel { info: Arc, + metadata: Arc, route_semantics: Arc, artifact: Arc, } +fn compiled_metadata_error(info: &NativeModelInfo, detail: impl Into) -> PharmsolError { + PharmsolError::OtherError(format!( + "compiled model `{}` has invalid runtime metadata: {}", + info.name, + detail.into() + )) +} + +fn sorted_dense_metadata<'a, T>( + info: &NativeModelInfo, + domain: &str, + expected_len: usize, + entries: &'a [T], + index_of: impl Fn(&T) -> usize, +) -> Result, PharmsolError> { + if entries.len() != expected_len { + return Err(compiled_metadata_error( + info, + format!( + "expected {expected_len} {domain} entr{} but found {}", + if expected_len == 1 { "y" } else { "ies" }, + entries.len() + ), + )); + } + + let mut sorted = entries.iter().collect::>(); + sorted.sort_by_key(|entry| index_of(entry)); + for (expected, entry) in sorted.iter().enumerate() { + let found = index_of(entry); + if found != expected { + return Err(compiled_metadata_error( + info, + format!( + "{domain} metadata must use dense 0-based indices; expected {expected}, found {found}" + ), + )); + } + } + + Ok(sorted) +} + +fn runtime_model_metadata(info: &NativeModelInfo) -> Result { + let states = sorted_dense_metadata(info, "state", info.state_len, &info.states, |state| { + state.offset + })?; + let state_names = states + .iter() + .map(|state| state.name.clone()) + .collect::>(); + + let covariates = sorted_dense_metadata( + info, + "covariate", + info.covariates.len(), + &info.covariates, + |covariate| covariate.index, + )?; + let routes = sorted_dense_metadata( + info, + "route declaration", + info.routes.len(), + &info.routes, + |route| route.declaration_index, + )?; + let outputs = + sorted_dense_metadata(info, "output", info.output_len, &info.outputs, |output| { + output.index + })?; + + let mut metadata = crate::simulator::equation::metadata::new(info.name.clone()) + .kind(info.kind) + .parameters(info.parameters.iter().cloned()) + .covariates(covariates.into_iter().map(|covariate| { + let mut declaration = + crate::simulator::equation::metadata::Covariate::new(covariate.name.clone()); + if let Some(interpolation) = covariate.interpolation { + declaration = declaration.with_interpolation(interpolation); + } + declaration + })) + .states(state_names.iter().cloned()) + .outputs(outputs.into_iter().map(|output| output.name.clone())); + + if let Some(kernel) = info.analytical { + metadata = metadata.analytical_kernel(kernel); + } + + if let Some(particles) = info.particles { + metadata = metadata.particles(particles); + } + + for route in &routes { + let destination = state_names + .get(route.destination_offset) + .ok_or_else(|| { + compiled_metadata_error( + info, + format!( + "route `{}` targets out-of-range state offset {}", + route.name, route.destination_offset + ), + ) + })? + .clone(); + if route.destination_name != destination { + return Err(compiled_metadata_error( + info, + format!( + "route `{}` names destination `{}` but offset {} resolves to `{}`", + route.name, route.destination_name, route.destination_offset, destination + ), + )); + } + // Structured-block DSL routes still lower without an explicit kind. + // Treat them as declaration-ordered bolus routes for the shared + // metadata surface while preserving the original runtime semantics + // from `info.routes` below. + let kind = route.kind.unwrap_or(RouteKind::Bolus); + + let mut declaration = match kind { + RouteKind::Bolus => { + crate::simulator::equation::metadata::Route::bolus(route.name.clone()) + } + RouteKind::Infusion => { + crate::simulator::equation::metadata::Route::infusion(route.name.clone()) + } + } + .to_state(destination); + + if route.has_lag { + declaration = declaration.with_lag(); + } + if route.has_bioavailability { + declaration = declaration.with_bioavailability(); + } + + declaration = if route.inject_input_to_destination { + declaration.inject_input_to_destination() + } else { + declaration.expect_explicit_input() + }; + + metadata = metadata.route(declaration); + } + + let validated = match info.kind { + ModelKind::Sde => { + let particles = info.particles.ok_or_else(|| { + compiled_metadata_error(info, "SDE models must declare a particle count") + })?; + metadata.validate_for_with_particles(ModelKind::Sde, particles) + } + kind => metadata.validate_for(kind), + } + .map_err(|error| compiled_metadata_error(info, error.to_string()))?; + + if validated.route_input_count() != info.route_len { + return Err(compiled_metadata_error( + info, + format!( + "route input count {} does not match declared route buffer length {}", + validated.route_input_count(), + info.route_len + ), + )); + } + + for route in routes { + let validated_route = &validated.routes()[route.declaration_index]; + if validated_route.input_index() != route.index { + return Err(compiled_metadata_error( + info, + format!( + "route `{}` uses input index {} but validated metadata resolves to {}", + route.name, + route.index, + validated_route.input_index() + ), + )); + } + if validated_route.destination_index() != route.destination_offset { + return Err(compiled_metadata_error( + info, + format!( + "route `{}` targets state offset {} but validated metadata resolves to {}", + route.name, + route.destination_offset, + validated_route.destination_index() + ), + )); + } + } + + Ok(validated) +} + #[derive(Clone, Debug)] struct RouteInputSemantics { bolus_destinations: Vec>, @@ -339,12 +538,21 @@ impl RouteInputSemantics { } impl SharedNativeModel { - fn new(info: NativeModelInfo, artifact: impl RuntimeArtifact + 'static) -> Self { - Self { + fn new( + info: NativeModelInfo, + artifact: impl RuntimeArtifact + 'static, + ) -> Result { + let metadata = Arc::new(runtime_model_metadata(&info)?); + Ok(Self { + metadata, route_semantics: Arc::new(RouteInputSemantics::from_model_info(&info)), info: Arc::new(info), artifact: Arc::new(artifact), - } + }) + } + + fn metadata(&self) -> &ValidatedModelMetadata { + self.metadata.as_ref() } fn route_index(&self, name: &str) -> Option { @@ -770,10 +978,23 @@ pub enum CompiledNativeModel { Sde(NativeSdeModel), } +impl CompiledNativeModel { + pub fn metadata(&self) -> &ValidatedModelMetadata { + match self { + Self::Ode(model) => model.metadata(), + Self::Analytical(model) => model.metadata(), + Self::Sde(model) => model.metadata(), + } + } +} + impl NativeOdeModel { - pub(crate) fn new(info: NativeModelInfo, artifact: impl RuntimeArtifact + 'static) -> Self { - Self { - shared: Arc::new(SharedNativeModel::new(info, artifact)), + pub(crate) fn new( + info: NativeModelInfo, + artifact: impl RuntimeArtifact + 'static, + ) -> Result { + Ok(Self { + shared: Arc::new(SharedNativeModel::new(info, artifact)?), solver: OdeSolver::default(), rtol: DEFAULT_ODE_RTOL, atol: DEFAULT_ODE_ATOL, @@ -781,7 +1002,7 @@ impl NativeOdeModel { error_model_cache: Some(BoundErrorModelCache::new( DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, )), - } + }) } pub fn with_solver(mut self, solver: OdeSolver) -> Self { @@ -799,6 +1020,11 @@ impl NativeOdeModel { self.shared.info.as_ref() } + /// Access the validated metadata attached to this compiled ODE model. + pub fn metadata(&self) -> &ValidatedModelMetadata { + self.shared.metadata() + } + pub fn backend(&self) -> RuntimeBackend { self.shared.artifact.backend() } @@ -1148,7 +1374,7 @@ impl EquationPriv for NativeOdeModel { } fn metadata(&self) -> Option<&crate::ValidatedModelMetadata> { - None + Some(self.shared.metadata()) } fn solve( @@ -1301,7 +1527,7 @@ impl NativeAnalyticalModel { ) -> Result { let parameter_projection = build_analytical_parameter_projection(&info)?; Ok(Self { - shared: Arc::new(SharedNativeModel::new(info, artifact)), + shared: Arc::new(SharedNativeModel::new(info, artifact)?), cache: Some(PredictionCache::new(DEFAULT_CACHE_SIZE)), parameter_projection, }) @@ -1311,6 +1537,11 @@ impl NativeAnalyticalModel { self.shared.info.as_ref() } + /// Access the validated metadata attached to this compiled analytical model. + pub fn metadata(&self) -> &ValidatedModelMetadata { + self.shared.metadata() + } + pub fn backend(&self) -> RuntimeBackend { self.shared.artifact.backend() } @@ -1538,7 +1769,7 @@ impl EquationPriv for NativeAnalyticalModel { } fn metadata(&self) -> Option<&crate::ValidatedModelMetadata> { - None + Some(self.shared.metadata()) } fn solve( @@ -1659,13 +1890,18 @@ impl Equation for NativeAnalyticalModel { } impl NativeSdeModel { - pub(crate) fn new(info: NativeModelInfo, artifact: impl RuntimeArtifact + 'static) -> Self { - let nparticles = info.particles.unwrap_or(1); - Self { - shared: Arc::new(SharedNativeModel::new(info, artifact)), + pub(crate) fn new( + info: NativeModelInfo, + artifact: impl RuntimeArtifact + 'static, + ) -> Result { + let nparticles = info.particles.ok_or_else(|| { + compiled_metadata_error(&info, "SDE models must declare a particle count") + })?; + Ok(Self { + shared: Arc::new(SharedNativeModel::new(info, artifact)?), nparticles, cache: Some(SdeLikelihoodCache::new(DEFAULT_CACHE_SIZE)), - } + }) } pub fn with_particles(mut self, nparticles: usize) -> Self { @@ -1677,6 +1913,11 @@ impl NativeSdeModel { self.shared.info.as_ref() } + /// Access the validated metadata attached to this compiled SDE model. + pub fn metadata(&self) -> &ValidatedModelMetadata { + self.shared.metadata() + } + pub fn backend(&self) -> RuntimeBackend { self.shared.artifact.backend() } @@ -2008,7 +2249,7 @@ impl EquationPriv for NativeSdeModel { } fn metadata(&self) -> Option<&crate::ValidatedModelMetadata> { - None + Some(self.shared.metadata()) } fn solve( @@ -2391,19 +2632,52 @@ fn apply_analytical_kernel( mod tests { use super::{ build_analytical_parameter_projection, canonical_numeric_alias, - project_analytical_parameters, runtime_ode_predictions, BoundErrorModelCache, - KernelSession, NativeAnalyticalModel, NativeModelInfo, NativeOdeModel, NativeOutputInfo, - NativeRouteInfo, PredictionCache, RuntimeArtifact, RuntimeBackend, SharedNativeModel, + project_analytical_parameters, KernelSession, NativeAnalyticalModel, NativeCovariateInfo, + NativeModelInfo, NativeOdeModel, NativeOutputInfo, NativeRouteInfo, NativeStateInfo, + RuntimeArtifact, RuntimeBackend, SharedNativeModel, NUMERIC_OUTPUT_PREFIX, + NUMERIC_ROUTE_PREFIX, + }; + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all( + feature = "dsl-wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ) + ))] + use super::{ + runtime_ode_predictions, BoundErrorModelCache, PredictionCache, DEFAULT_BOUND_ERROR_MODEL_CACHE_SIZE, DEFAULT_ODE_ATOL, DEFAULT_ODE_RTOL, - NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, }; + use crate::PharmsolError; + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all( + feature = "dsl-wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ) + ))] use crate::{ - data::builder::SubjectBuilderExt, dsl::CompiledRuntimeModel, dsl::RuntimePredictions, - prelude::SubjectPredictions, Parameters, PharmsolError, Subject, + data::builder::SubjectBuilderExt, + dsl::{CompiledRuntimeModel, RuntimePredictions}, + prelude::SubjectPredictions, + Parameters, Subject, }; use diffsol::VectorHost; use pharmsol_dsl::execution::KernelRole; - use pharmsol_dsl::{AnalyticalKernel, AnalyticalStructureInputKind, ModelKind, RouteKind}; + use pharmsol_dsl::{ + AnalyticalKernel, AnalyticalStructureInputKind, CovariateInterpolation, ModelKind, + RouteKind, + }; + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all( + feature = "dsl-wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ) + ))] use std::sync::Arc; #[derive(Debug)] @@ -2431,12 +2705,19 @@ mod tests { parameters: Vec::new(), derived: Vec::new(), covariates: Vec::new(), + states: vec![NativeStateInfo { + name: "gut".to_string(), + offset: 0, + }], routes: vec![NativeRouteInfo { name: "oral".to_string(), declaration_index: 0, index: 0, kind: Some(RouteKind::Bolus), destination_offset: 0, + destination_name: "gut".to_string(), + has_lag: false, + has_bioavailability: false, inject_input_to_destination: false, }], outputs: vec![NativeOutputInfo { @@ -2452,6 +2733,7 @@ mod tests { }, DummyArtifact, ) + .expect("bolus-only metadata should build") } fn analytical_model_info( @@ -2465,6 +2747,12 @@ mod tests { parameters: parameters.iter().map(|name| (*name).to_string()).collect(), derived: derived.iter().map(|name| (*name).to_string()).collect(), covariates: Vec::new(), + states: (0..kernel.state_count()) + .map(|offset| NativeStateInfo { + name: format!("state_{offset}"), + offset, + }) + .collect(), routes: Vec::new(), outputs: Vec::new(), state_len: kernel.state_count(), @@ -2476,6 +2764,112 @@ mod tests { } } + #[test] + fn runtime_ode_models_expose_validated_metadata_for_declared_routes() { + let model = NativeOdeModel::new( + NativeModelInfo { + name: "runtime_metadata".to_string(), + kind: ModelKind::Ode, + parameters: vec!["ke".to_string(), "v".to_string()], + derived: Vec::new(), + covariates: vec![NativeCovariateInfo { + name: "wt".to_string(), + index: 0, + interpolation: Some(CovariateInterpolation::Linear), + }], + states: vec![NativeStateInfo { + name: "central".to_string(), + offset: 0, + }], + routes: vec![NativeRouteInfo { + name: "iv".to_string(), + declaration_index: 0, + index: 0, + kind: Some(RouteKind::Infusion), + destination_offset: 0, + destination_name: "central".to_string(), + has_lag: false, + has_bioavailability: false, + inject_input_to_destination: false, + }], + outputs: vec![NativeOutputInfo { + name: "cp".to_string(), + index: 0, + }], + state_len: 1, + derived_len: 0, + output_len: 1, + route_len: 1, + analytical: None, + particles: None, + }, + DummyArtifact, + ) + .expect("runtime ODE metadata should build"); + + let metadata = model.metadata(); + assert_eq!(metadata.parameter_index("ke"), Some(0)); + assert_eq!( + metadata.covariate("wt").unwrap().interpolation(), + Some(CovariateInterpolation::Linear) + ); + assert_eq!(metadata.route("iv").unwrap().destination(), "central"); + assert_eq!(metadata.output("cp").unwrap().name(), "cp"); + + let compiled = super::CompiledNativeModel::Ode(model.clone()); + assert_eq!( + compiled.metadata().route("iv").unwrap().destination(), + "central" + ); + } + + #[test] + fn runtime_ode_model_setup_rejects_invalid_route_destination_metadata() { + let error = NativeOdeModel::new( + NativeModelInfo { + name: "runtime_metadata_invalid_destination".to_string(), + kind: ModelKind::Ode, + parameters: vec!["ke".to_string()], + derived: Vec::new(), + covariates: Vec::new(), + states: vec![NativeStateInfo { + name: "central".to_string(), + offset: 0, + }], + routes: vec![NativeRouteInfo { + name: "iv".to_string(), + declaration_index: 0, + index: 0, + kind: Some(RouteKind::Infusion), + destination_offset: 1, + destination_name: "central".to_string(), + has_lag: false, + has_bioavailability: false, + inject_input_to_destination: false, + }], + outputs: vec![NativeOutputInfo { + name: "cp".to_string(), + index: 0, + }], + state_len: 1, + derived_len: 0, + output_len: 1, + route_len: 1, + analytical: None, + particles: None, + }, + DummyArtifact, + ) + .expect_err("invalid route destination metadata must fail at setup"); + + assert!(error.to_string().contains( + "compiled model `runtime_metadata_invalid_destination` has invalid runtime metadata" + )); + assert!(error + .to_string() + .contains("route `iv` targets out-of-range state offset 1")); + } + fn analytical_projection_values( model: &NativeAnalyticalModel, support_point: &[f64], @@ -2486,6 +2880,14 @@ mod tests { .to_vec() } + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all( + feature = "dsl-wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ) + ))] fn cached_runtime_ode_model() -> NativeOdeModel { NativeOdeModel { shared: Arc::new(bolus_only_shared_model()), @@ -2499,6 +2901,14 @@ mod tests { } } + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all( + feature = "dsl-wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ) + ))] fn cached_runtime_subject() -> Subject { Subject::builder("runtime_cached_prediction") .bolus(0.0, 100.0, "oral") @@ -2672,6 +3082,14 @@ mod tests { )); } + #[cfg(any( + feature = "dsl-jit", + all(feature = "dsl-aot", feature = "dsl-aot-load"), + all( + feature = "dsl-wasm", + not(all(target_arch = "wasm32", target_os = "unknown")) + ) + ))] #[test] fn compiled_runtime_ode_predictions_use_prefilled_cache() { let model = cached_runtime_ode_model(); diff --git a/src/dsl/runtime.rs b/src/dsl/runtime.rs index cfd2c1eb..c056afd3 100644 --- a/src/dsl/runtime.rs +++ b/src/dsl/runtime.rs @@ -95,7 +95,8 @@ use super::jit::{compile_execution_model_to_jit, JitCompileError}; use super::native::RuntimeArtifact; use super::native::{ CompiledNativeModel, NativeAnalyticalModel, NativeCovariateInfo, NativeModelInfo, - NativeOdeModel, NativeOutputInfo, NativeRouteInfo, NativeSdeModel, RuntimeBackend, + NativeOdeModel, NativeOutputInfo, NativeRouteInfo, NativeSdeModel, NativeStateInfo, + RuntimeBackend, }; #[cfg(feature = "dsl-wasm")] use super::wasm::{load_wasm_artifact, load_wasm_artifact_bytes}; @@ -105,7 +106,7 @@ use super::wasm_compile::{ }; use crate::{ simulator::likelihood::{Prediction, SubjectPredictions}, - Parameters, PharmsolError, Subject, + Parameters, PharmsolError, Subject, ValidatedModelMetadata, }; use pharmsol_dsl::{ analyze_module, lower_typed_model, parse_module, Diagnostic, DiagnosticReport, ExecutionModel, @@ -114,6 +115,7 @@ use pharmsol_dsl::{ pub type RuntimeModelInfo = NativeModelInfo; pub type RuntimeCovariateInfo = NativeCovariateInfo; +pub type RuntimeStateInfo = NativeStateInfo; pub type RuntimeRouteInfo = NativeRouteInfo; pub type RuntimeOutputInfo = NativeOutputInfo; pub type RuntimeOdeModel = NativeOdeModel; @@ -231,6 +233,14 @@ impl CompiledRuntimeModel { self.info().kind } + pub fn metadata(&self) -> &ValidatedModelMetadata { + match self { + Self::Ode(model) => model.metadata(), + Self::Analytical(model) => model.metadata(), + Self::Sde(model) => model.metadata(), + } + } + pub fn estimate_predictions( &self, subject: &Subject, @@ -457,11 +467,11 @@ fn runtime_model_from_parts( artifact: impl RuntimeArtifact + 'static, ) -> Result { Ok(match info.kind { - ModelKind::Ode => CompiledRuntimeModel::Ode(NativeOdeModel::new(info, artifact)), + ModelKind::Ode => CompiledRuntimeModel::Ode(NativeOdeModel::new(info, artifact)?), ModelKind::Analytical => { CompiledRuntimeModel::Analytical(NativeAnalyticalModel::new(info, artifact)?) } - ModelKind::Sde => CompiledRuntimeModel::Sde(NativeSdeModel::new(info, artifact)), + ModelKind::Sde => CompiledRuntimeModel::Sde(NativeSdeModel::new(info, artifact)?), }) } diff --git a/src/dsl/wasm.rs b/src/dsl/wasm.rs index 62998be2..a09b0247 100644 --- a/src/dsl/wasm.rs +++ b/src/dsl/wasm.rs @@ -2,7 +2,8 @@ use std::ops::Range; use std::path::Path; use std::sync::Mutex; -use serde_json; +#[allow(unused)] +use crate::dsl::model_info; use wasmtime::{Engine, Instance, Linker, Memory, Module, Store, TypedFunc}; use super::compiled_backend_abi::{ @@ -496,15 +497,13 @@ fn load_wasm_artifact_from_module( .get_func(&mut store, ROUTE_BIOAVAILABILITY_SYMBOL) .is_some(), }; - if let Some(expected_kernels) = expected_kernels { - let found_kernels = kernels.compiled(); - if found_kernels != expected_kernels { - return Err(WasmError::KernelMetadataMismatch { - model: info.name.clone(), - expected: expected_kernels, - found: found_kernels, - }); - } + let found_kernels = kernels.compiled(); + if found_kernels != expected_kernels { + return Err(WasmError::KernelMetadataMismatch { + model: info.name.clone(), + expected: expected_kernels, + found: found_kernels, + }); } Ok(( @@ -633,7 +632,7 @@ fn read_model_info_envelope( instance: &Instance, store: &mut Store<()>, memory: &Memory, -) -> Result<(NativeModelInfo, Option), WasmError> { +) -> Result<(NativeModelInfo, CompiledKernelAvailability), WasmError> { let ptr = typed_func::<(), i32>(instance, store, MODEL_INFO_JSON_PTR_SYMBOL)? .call(&mut *store, ()) .map_err(|error| WasmError::Load(error.to_string()))?; @@ -649,16 +648,14 @@ fn read_model_info_envelope( })?; let range = byte_range(ptr, len, data.len(), "model info")?; let bytes = &data[range]; - if let Ok(envelope) = decode_compiled_model_info(bytes) { - if envelope.abi_version != WASM_API_VERSION { - return Err(WasmError::ApiVersionMismatch { - expected: WASM_API_VERSION, - found: envelope.abi_version, - }); - } - return Ok((envelope.model, Some(envelope.kernels))); + let envelope = decode_compiled_model_info(bytes)?; + if envelope.abi_version != WASM_API_VERSION { + return Err(WasmError::ApiVersionMismatch { + expected: WASM_API_VERSION, + found: envelope.abi_version, + }); } - Ok((serde_json::from_slice(bytes)?, None)) + Ok((envelope.model, envelope.kernels)) } fn write_f64s( @@ -776,7 +773,7 @@ mod tests { use crate::test_fixtures::STRUCTURED_BLOCK_CORPUS; use approx::assert_relative_eq; use pharmsol_dsl::{ - analyze_module, lower_typed_model, parse_module, ExecutionModel, ModelKind, + analyze_module, lower_typed_model, parse_module, ExecutionModel, ModelKind, RouteKind, }; use std::path::{Path, PathBuf}; use tempfile::tempdir; @@ -805,12 +802,19 @@ mod tests { parameters: vec!["ka".to_string()], derived: Vec::new(), covariates: Vec::new(), + states: vec![super::model_info::NativeStateInfo { + name: "depot".to_string(), + offset: 0, + }], routes: vec![NativeRouteInfo { name: "oral".to_string(), declaration_index: 0, index: 0, - kind: None, + kind: Some(RouteKind::Bolus), destination_offset: 0, + destination_name: "depot".to_string(), + has_lag: false, + has_bioavailability: false, inject_input_to_destination: true, }], outputs: vec![NativeOutputInfo { @@ -1007,16 +1011,15 @@ mod tests { } #[test] - fn accepts_legacy_plain_model_info_metadata() { + fn rejects_plain_model_info_metadata_without_compiled_envelope() { let model_info = loader_test_model_info("legacy_plain_metadata"); let metadata = serde_json::to_vec(&model_info).expect("legacy metadata json"); - let (loaded, artifact) = + let error = load_wasm_artifact_bytes(&loader_test_module_bytes(WASM_API_VERSION, &metadata, true)) - .expect("legacy metadata should still load"); + .expect_err("plain model info metadata must be rejected"); - assert_eq!(loaded, model_info); - assert!(artifact.has_kernel(KernelRole::Outputs)); + assert!(matches!(error, WasmError::Json(_))); } #[test] diff --git a/src/parameter_order.rs b/src/parameter_order.rs index b62eb4a8..6c33f323 100644 --- a/src/parameter_order.rs +++ b/src/parameter_order.rs @@ -309,6 +309,7 @@ mod tests { parameters: vec!["ka".to_string(), "ke".to_string()], derived: Vec::new(), covariates: Vec::new(), + states: Vec::new(), routes: Vec::new(), outputs: Vec::new(), state_len: 0, From 4a7e67f37938c326254a0f71a62e0fde66fe71a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Thu, 14 May 2026 18:44:54 +0100 Subject: [PATCH 2/4] fix: addressing some issues --- src/dsl/native.rs | 263 +++++++++++++++++++++++++++++++++++++++++---- src/dsl/runtime.rs | 21 ++++ 2 files changed, 261 insertions(+), 23 deletions(-) diff --git a/src/dsl/native.rs b/src/dsl/native.rs index c080202d..1b60d3aa 100644 --- a/src/dsl/native.rs +++ b/src/dsl/native.rs @@ -322,10 +322,104 @@ fn sorted_dense_metadata<'a, T>( Ok(sorted) } +fn sorted_state_metadata<'a>( + info: &'a NativeModelInfo, +) -> Result, PharmsolError> { + if info.state_len == 0 { + if info.states.is_empty() { + return Ok(Vec::new()); + } + return Err(compiled_metadata_error( + info, + format!( + "expected no state metadata for an empty state buffer, found {} declaration(s)", + info.states.len() + ), + )); + } + + if info.states.is_empty() { + return Err(compiled_metadata_error( + info, + format!( + "expected state metadata for {} state slot(s), found none", + info.state_len + ), + )); + } + + let mut states = info.states.iter().collect::>(); + states.sort_by_key(|state| state.offset); + + if states[0].offset != 0 { + return Err(compiled_metadata_error( + info, + format!( + "state metadata must start at offset 0; first declaration starts at {}", + states[0].offset + ), + )); + } + + for window in states.windows(2) { + let current = window[0]; + let next = window[1]; + if next.offset <= current.offset { + return Err(compiled_metadata_error( + info, + format!( + "state metadata offsets must be strictly increasing; saw {} followed by {}", + current.offset, next.offset + ), + )); + } + } + + let last_offset = states.last().expect("non-empty states").offset; + if last_offset >= info.state_len { + return Err(compiled_metadata_error( + info, + format!( + "state metadata offset {} is out of range for state buffer length {}", + last_offset, info.state_len + ), + )); + } + + Ok(states) +} + +fn state_declaration_for_offset<'a>( + info: &NativeModelInfo, + states: &[&'a NativeStateInfo], + offset: usize, +) -> Result<(usize, &'a NativeStateInfo), PharmsolError> { + if offset >= info.state_len { + return Err(compiled_metadata_error( + info, + format!( + "state offset {} is out of range for state buffer length {}", + offset, info.state_len + ), + )); + } + + let declaration_index = match states.binary_search_by_key(&offset, |state| state.offset) { + Ok(index) => index, + Err(0) => { + return Err(compiled_metadata_error( + info, + format!("state offset {} precedes the first declared state", offset), + )); + } + Err(index) => index - 1, + }; + + Ok((declaration_index, states[declaration_index])) +} + fn runtime_model_metadata(info: &NativeModelInfo) -> Result { - let states = sorted_dense_metadata(info, "state", info.state_len, &info.states, |state| { - state.offset - })?; + let states = sorted_state_metadata(info)?; let state_names = states .iter() .map(|state| state.name.clone()) @@ -373,18 +467,9 @@ fn runtime_model_metadata(info: &NativeModelInfo) -> Result Result Result Result { + let metadata = Arc::new(runtime_model_metadata(&info)?); + let route_semantics = Arc::new(RouteInputSemantics::from_model_info(&info)); + Ok(Self { + info: Arc::new(info), + metadata, + route_semantics, + artifact: Arc::clone(&self.artifact), + }) + } + fn new( info: NativeModelInfo, artifact: impl RuntimeArtifact + 'static, ) -> Result { + let artifact = Arc::new(artifact); let metadata = Arc::new(runtime_model_metadata(&info)?); + let route_semantics = Arc::new(RouteInputSemantics::from_model_info(&info)); Ok(Self { metadata, - route_semantics: Arc::new(RouteInputSemantics::from_model_info(&info)), + route_semantics, info: Arc::new(info), - artifact: Arc::new(artifact), + artifact, }) } @@ -1905,6 +2005,21 @@ impl NativeSdeModel { } pub fn with_particles(mut self, nparticles: usize) -> Self { + if self.nparticles == nparticles { + return self; + } + + if let Some(cache) = &self.cache { + cache.invalidate_all(); + } + + let mut info = self.shared.info.as_ref().clone(); + info.particles = Some(nparticles); + self.shared = Arc::new( + self.shared + .with_info(info) + .expect("compiled SDE metadata should stay valid after particle override"), + ); self.nparticles = nparticles; self } @@ -2633,8 +2748,8 @@ mod tests { use super::{ build_analytical_parameter_projection, canonical_numeric_alias, project_analytical_parameters, KernelSession, NativeAnalyticalModel, NativeCovariateInfo, - NativeModelInfo, NativeOdeModel, NativeOutputInfo, NativeRouteInfo, NativeStateInfo, - RuntimeArtifact, RuntimeBackend, SharedNativeModel, NUMERIC_OUTPUT_PREFIX, + NativeModelInfo, NativeOdeModel, NativeOutputInfo, NativeRouteInfo, NativeSdeModel, + NativeStateInfo, RuntimeArtifact, RuntimeBackend, SharedNativeModel, NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, }; #[cfg(any( @@ -2823,6 +2938,74 @@ mod tests { ); } + #[test] + fn runtime_ode_models_map_array_state_offsets_to_declarations() { + let model = NativeOdeModel::new( + NativeModelInfo { + name: "array_state_runtime_metadata".to_string(), + kind: ModelKind::Ode, + parameters: vec!["ke".to_string(), "v".to_string()], + derived: Vec::new(), + covariates: Vec::new(), + states: vec![ + NativeStateInfo { + name: "transit".to_string(), + offset: 0, + }, + NativeStateInfo { + name: "central".to_string(), + offset: 4, + }, + ], + routes: vec![ + NativeRouteInfo { + name: "oral".to_string(), + declaration_index: 0, + index: 0, + kind: Some(RouteKind::Bolus), + destination_offset: 0, + destination_name: "transit".to_string(), + has_lag: false, + has_bioavailability: false, + inject_input_to_destination: false, + }, + NativeRouteInfo { + name: "iv".to_string(), + declaration_index: 1, + index: 0, + kind: Some(RouteKind::Infusion), + destination_offset: 4, + destination_name: "central".to_string(), + has_lag: false, + has_bioavailability: false, + inject_input_to_destination: false, + }, + ], + outputs: vec![NativeOutputInfo { + name: "cp".to_string(), + index: 0, + }], + state_len: 5, + derived_len: 0, + output_len: 1, + route_len: 1, + analytical: None, + particles: None, + }, + DummyArtifact, + ) + .expect("array-state runtime metadata should build"); + + let metadata = model.metadata(); + assert_eq!(metadata.states()[0].name(), "transit"); + assert_eq!(metadata.states()[1].name(), "central"); + assert_eq!(metadata.route_input_count(), 1); + assert_eq!(metadata.route("oral").unwrap().destination(), "transit"); + assert_eq!(metadata.route("oral").unwrap().destination_index(), 0); + assert_eq!(metadata.route("iv").unwrap().destination(), "central"); + assert_eq!(metadata.route("iv").unwrap().destination_index(), 1); + } + #[test] fn runtime_ode_model_setup_rejects_invalid_route_destination_metadata() { let error = NativeOdeModel::new( @@ -2867,7 +3050,41 @@ mod tests { )); assert!(error .to_string() - .contains("route `iv` targets out-of-range state offset 1")); + .contains("state offset 1 is out of range for state buffer length 1")); + } + + #[test] + fn runtime_sde_with_particles_updates_metadata_and_info() { + let model = NativeSdeModel::new( + NativeModelInfo { + name: "runtime_sde_particles".to_string(), + kind: ModelKind::Sde, + parameters: vec!["ke".to_string()], + derived: Vec::new(), + covariates: Vec::new(), + states: vec![NativeStateInfo { + name: "central".to_string(), + offset: 0, + }], + routes: Vec::new(), + outputs: vec![NativeOutputInfo { + name: "cp".to_string(), + index: 0, + }], + state_len: 1, + derived_len: 0, + output_len: 1, + route_len: 0, + analytical: None, + particles: Some(32), + }, + DummyArtifact, + ) + .expect("runtime SDE metadata should build") + .with_particles(64); + + assert_eq!(model.info().particles, Some(64)); + assert_eq!(model.metadata().particles(), Some(64)); } fn analytical_projection_values( diff --git a/src/dsl/runtime.rs b/src/dsl/runtime.rs index c056afd3..c176e793 100644 --- a/src/dsl/runtime.rs +++ b/src/dsl/runtime.rs @@ -856,6 +856,27 @@ out(cp) = central / v ~ continuous() } } + #[test] + fn runtime_jit_preserves_array_state_metadata() { + let model = compile_module_source_to_runtime( + corpus_source(), + Some("transit_absorption"), + RuntimeCompilationTarget::Jit, + |_, _| {}, + ) + .expect("compile jit runtime model"); + + let metadata = model.metadata(); + assert_eq!(metadata.states()[0].name(), "transit"); + assert_eq!(metadata.states()[1].name(), "central"); + assert_eq!(metadata.route("oral").unwrap().destination(), "transit"); + assert_eq!(metadata.route("oral").unwrap().destination_index(), 0); + + assert_eq!(model.info().state_len, 5); + assert_eq!(model.info().states[0].offset, 0); + assert_eq!(model.info().states[1].offset, 4); + } + #[test] fn runtime_backend_matrix_reports_route_kind_mismatch() { let work_dir = tempdir().expect("tempdir"); From 1932cc14379f099c1cdca2ad8a13d6224cc5ef89 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 14 May 2026 20:07:41 +0200 Subject: [PATCH 3/4] chore: New error variant To reduce use of OtherError --- src/dsl/native.rs | 135 +++++++++++++++++++++++----------------------- src/error/mod.rs | 2 + 2 files changed, 70 insertions(+), 67 deletions(-) diff --git a/src/dsl/native.rs b/src/dsl/native.rs index 1b60d3aa..cbd7f515 100644 --- a/src/dsl/native.rs +++ b/src/dsl/native.rs @@ -279,14 +279,6 @@ struct SharedNativeModel { artifact: Arc, } -fn compiled_metadata_error(info: &NativeModelInfo, detail: impl Into) -> PharmsolError { - PharmsolError::OtherError(format!( - "compiled model `{}` has invalid runtime metadata: {}", - info.name, - detail.into() - )) -} - fn sorted_dense_metadata<'a, T>( info: &NativeModelInfo, domain: &str, @@ -295,14 +287,14 @@ fn sorted_dense_metadata<'a, T>( index_of: impl Fn(&T) -> usize, ) -> Result, PharmsolError> { if entries.len() != expected_len { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "expected {expected_len} {domain} entr{} but found {}", if expected_len == 1 { "y" } else { "ies" }, entries.len() ), - )); + }); } let mut sorted = entries.iter().collect::>(); @@ -310,12 +302,12 @@ fn sorted_dense_metadata<'a, T>( for (expected, entry) in sorted.iter().enumerate() { let found = index_of(entry); if found != expected { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "{domain} metadata must use dense 0-based indices; expected {expected}, found {found}" ), - )); + }); } } @@ -329,61 +321,61 @@ fn sorted_state_metadata<'a>( if info.states.is_empty() { return Ok(Vec::new()); } - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "expected no state metadata for an empty state buffer, found {} declaration(s)", info.states.len() ), - )); + }); } if info.states.is_empty() { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "expected state metadata for {} state slot(s), found none", info.state_len ), - )); + }); } let mut states = info.states.iter().collect::>(); states.sort_by_key(|state| state.offset); if states[0].offset != 0 { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "state metadata must start at offset 0; first declaration starts at {}", states[0].offset ), - )); + }); } for window in states.windows(2) { let current = window[0]; let next = window[1]; if next.offset <= current.offset { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "state metadata offsets must be strictly increasing; saw {} followed by {}", current.offset, next.offset ), - )); + }); } } let last_offset = states.last().expect("non-empty states").offset; if last_offset >= info.state_len { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "state metadata offset {} is out of range for state buffer length {}", last_offset, info.state_len ), - )); + }); } Ok(states) @@ -395,22 +387,22 @@ fn state_declaration_for_offset<'a>( offset: usize, ) -> Result<(usize, &'a NativeStateInfo), PharmsolError> { if offset >= info.state_len { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "state offset {} is out of range for state buffer length {}", offset, info.state_len ), - )); + }); } let declaration_index = match states.binary_search_by_key(&offset, |state| state.offset) { Ok(index) => index, Err(0) => { - return Err(compiled_metadata_error( - info, - format!("state offset {} precedes the first declared state", offset), - )); + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!("state offset {} precedes the first declared state", offset), + }); } Err(index) => index - 1, }; @@ -471,13 +463,13 @@ fn runtime_model_metadata(info: &NativeModelInfo) -> Result Result { - let particles = info.particles.ok_or_else(|| { - compiled_metadata_error(info, "SDE models must declare a particle count") - })?; + let particles = info + .particles + .ok_or_else(|| PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: "SDE models must declare a particle count".to_string(), + })?; metadata.validate_for_with_particles(ModelKind::Sde, particles) } kind => metadata.validate_for(kind), } - .map_err(|error| compiled_metadata_error(info, error.to_string()))?; + .map_err(|error| PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: error.to_string(), + })?; if validated.route_input_count() != info.route_len { - return Err(compiled_metadata_error( - info, - format!( + return Err(PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: format!( "route input count {} does not match declared route buffer length {}", validated.route_input_count(), info.route_len ), - )); + }); } for route in routes { @@ -538,26 +536,26 @@ fn runtime_model_metadata(info: &NativeModelInfo) -> Result Result { - let nparticles = info.particles.ok_or_else(|| { - compiled_metadata_error(&info, "SDE models must declare a particle count") - })?; + let nparticles = info + .particles + .ok_or_else(|| PharmsolError::InvalidMetadata { + model: info.name.clone(), + detail: "SDE models must declare a particle count".to_string(), + })?; Ok(Self { shared: Arc::new(SharedNativeModel::new(info, artifact)?), nparticles, diff --git a/src/error/mod.rs b/src/error/mod.rs index 25fe05f4..9e1b0f21 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -52,6 +52,8 @@ pub enum PharmsolError { InputOutOfRange { input: usize, ndrugs: usize }, #[error("Output equation {outeq} is out of range (nout = {nout})")] OuteqOutOfRange { outeq: usize, nout: usize }, + #[error("Compiled model `{model}` has invalid runtime metadata: {detail}")] + InvalidMetadata { model: String, detail: String }, } #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] From d8bd2d9a4231a855c0a380be67e4ea77d8ef170a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Thu, 14 May 2026 19:25:39 +0100 Subject: [PATCH 4/4] fix: c->C it is always C... --- src/dsl/native.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dsl/native.rs b/src/dsl/native.rs index cbd7f515..634dddb1 100644 --- a/src/dsl/native.rs +++ b/src/dsl/native.rs @@ -3047,7 +3047,7 @@ mod tests { .expect_err("invalid route destination metadata must fail at setup"); assert!(error.to_string().contains( - "compiled model `runtime_metadata_invalid_destination` has invalid runtime metadata" + "Compiled model `runtime_metadata_invalid_destination` has invalid runtime metadata" )); assert!(error .to_string()