From 445dd7b91d2e36bff966609dd684f4b9acd912ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 15 Sep 2026 13:45:44 +0100 Subject: [PATCH] fix: keep derived values real-valued in f64 buffers Derived and output symbols took their type from the expression assigned to them, so a whole-number initialiser narrowed them to Int. Their storage is the f64 derived buffer, so the value was written as a double and read back as an integer: `z1 = 1.0` then `out(cp) = z1` returned the bit pattern of 1.0 (4.607e18) instead of 1.0. A conditional with whole-number branches hit the same path: z1 = if (p1 > 0.0) 1.0 else 2.0 out(cp) = z1 Derived and output symbols are now registered as real-valued, which matches the buffer they live in. Whole-number constants and parameter-derived values are unaffected. --- pharmsol-dsl/src/analyze.rs | 6 +- tests/dsl_constant_derived.rs | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 tests/dsl_constant_derived.rs diff --git a/pharmsol-dsl/src/analyze.rs b/pharmsol-dsl/src/analyze.rs index 034708d7..2750efd5 100644 --- a/pharmsol-dsl/src/analyze.rs +++ b/pharmsol-dsl/src/analyze.rs @@ -916,7 +916,11 @@ impl<'a> Analyzer<'a> { let id = self.insert_global_symbol( &ident.text, kind, - PendingSymbolType::Scalar(None), + // Derived values and outputs are stored in f64 buffers, so they + // are always real-valued. Letting a whole-number initialiser + // narrow one to Int writes an f64 and reads it back as an + // integer, which silently returns the bit pattern. + PendingSymbolType::Scalar(Some(ValueType::Real)), ident.span, )?; match kind { diff --git a/tests/dsl_constant_derived.rs b/tests/dsl_constant_derived.rs new file mode 100644 index 00000000..295ea377 --- /dev/null +++ b/tests/dsl_constant_derived.rs @@ -0,0 +1,128 @@ +//! A derived assignment whose value folds to a compile-time constant must still +//! be materialised, because other statements read it by slot. + +#![cfg(feature = "dsl")] + +use pharmsol::dsl::compile_module_source_to_runtime; +use pharmsol::{prelude::*, Parameters}; + +fn prediction( + model_source: &str, + params: &[(&str, f64)], +) -> Result> { + let model = compile_module_source_to_runtime(model_source, Some("const_derived"), |_, _| {})?; + let support_point = Parameters::with_model(&model, params.iter().copied())?; + let subject = Subject::builder("const_derived") + .missing_observation(0.0, "cp") + .build(); + let predictions = model + .estimate_predictions(&subject, &support_point)? + .into_subject() + .ok_or("expected subject predictions")?; + Ok(predictions.predictions()[0].prediction()) +} + +const CONSTANT_DERIVED: &str = r#" +name = const_derived +kind = ode + +params = p1, p2 +states = central, sink +outputs = cp + +z1 = 1.0 + +init(central) = 2.0 +init(sink) = 0.0 + +dx(central) = 0.0 +dx(sink) = 0.0 + +out(cp) = z1 +"#; + +const FRACTIONAL_DERIVED: &str = r#" +name = const_derived +kind = ode + +params = p1, p2 +states = central, sink +outputs = cp + +z1 = 1.5 + +init(central) = 2.0 +init(sink) = 0.0 + +dx(central) = 0.0 +dx(sink) = 0.0 + +out(cp) = z1 +"#; + +const PARAMETER_DERIVED: &str = r#" +name = const_derived +kind = ode + +params = p1, p2 +states = central, sink +outputs = cp + +z1 = p1 + +init(central) = 2.0 +init(sink) = 0.0 + +dx(central) = 0.0 +dx(sink) = 0.0 + +out(cp) = z1 +"#; + +const MIN_MAX_CONSTANT_BRANCHES: &str = r#" +name = const_derived +kind = ode + +params = p1, p2 +states = central, sink +outputs = cp + +z1 = if (p1 > 0.0) 1.0 else 2.0 + +init(central) = 2.0 +init(sink) = 0.0 + +dx(central) = 0.0 +dx(sink) = 0.0 + +out(cp) = z1 +"#; + +#[test] +fn derived_value_from_a_parameter_is_read_correctly() -> Result<(), Box> { + let value = prediction(PARAMETER_DERIVED, &[("p1", 4.25), ("p2", 1.0)])?; + assert!((value - 4.25).abs() < 1e-9, "got {value}"); + Ok(()) +} + +#[test] +fn derived_value_from_a_constant_is_read_correctly() -> Result<(), Box> { + let value = prediction(CONSTANT_DERIVED, &[("p1", 4.25), ("p2", 1.0)])?; + assert!((value - 1.0).abs() < 1e-9, "got {value}"); + Ok(()) +} + +#[test] +fn derived_value_from_a_fractional_constant_is_read_correctly( +) -> Result<(), Box> { + let value = prediction(FRACTIONAL_DERIVED, &[("p1", 4.25), ("p2", 1.0)])?; + assert!((value - 1.5).abs() < 1e-9, "got {value}"); + Ok(()) +} + +#[test] +fn constant_branch_conditional_is_read_correctly() -> Result<(), Box> { + let value = prediction(MIN_MAX_CONSTANT_BRANCHES, &[("p1", 1.0), ("p2", 1.0)])?; + assert!((value - 1.0).abs() < 1e-9, "got {value}"); + Ok(()) +}