From 13e414ab7c3158f75199e3bf005ab08941aab5b5 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 7 May 2026 00:24:57 +0100 Subject: [PATCH 01/38] WIP: Create model of th_signed_polynomial_system in mass_action.rs WIP: Rethinking traits WIP: Some tests only half failing WIP: Tests passing; time to tidy ENH: Build derived model of th_signed_polynomial_ode_system in mass_action ENH: Mass-action for stock-flow; DEL: Mass-action for signed stock-flow ENH: struct for transition / flow interfaces WIP: Starting on Lotka-Volterra WIP: Failing tests FIX: Lotka-Volterra tests passing FIX: Working analysis (frontend) ENH: Lotka-Volterra equations ENH: Linear ODE refactor ENH: Linear ODE equations WIP: Starting on ODESemantics WIP: lotka_volterra_semantics() WIP: build_system_from_ode_semantics WIP: DblModelForODESemantics WIP: ODESemanticsAnalysis and ODESemanticsProblemData WIP: ODESemantics trait WIP: Documentation WIP: ODESemantics for mass-action WIP: Cleaning up types, but mass-action still frustrating WIP: Big reshuffle (moving functions out from a struct) WIP: Fixing mass-action again WIP: terrible code WIP: Changed from ObGen to Ob WIP: Stock-flow mass-action FIX: Passing catlog tests Rename LinearODE -> LCC FIX: Documentation TODO: Redesign --- packages/catlog-wasm/src/analyses.rs | 117 ++- packages/catlog-wasm/src/latex.rs | 144 ++- packages/catlog-wasm/src/theories.rs | 58 +- packages/catlog/src/stdlib/analyses/mod.rs | 1 + .../src/stdlib/analyses/ode/linear_ode.rs | 318 ++++-- .../src/stdlib/analyses/ode/lotka_volterra.rs | 356 ++++--- .../src/stdlib/analyses/ode/mass_action.rs | 904 +++++++++++------- .../catlog/src/stdlib/analyses/ode/mod.rs | 4 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 341 +++++++ .../src/stdlib/analyses/ode/polynomial_ode.rs | 86 +- .../analyses/ode/signed_coefficients.rs | 84 -- packages/catlog/src/stdlib/analyses/petri.rs | 38 +- .../src/stdlib/analyses/reachability.rs | 16 +- .../stdlib/analyses/stochastic/mass_action.rs | 19 +- .../catlog/src/stdlib/analyses/stock_flow.rs | 46 + packages/catlog/src/stdlib/models.rs | 61 +- packages/catlog/src/stdlib/theories.rs | 1 + .../src/help/analysis/mass-action.mdx | 6 +- .../frontend/src/help/logics/petri-net.mdx | 4 +- packages/frontend/src/stdlib/analyses.tsx | 66 +- .../src/stdlib/analyses/linear_ode.tsx | 38 +- .../stdlib/analyses/linear_ode_equations.tsx | 36 + .../src/stdlib/analyses/lotka_volterra.tsx | 28 +- .../analyses/lotka_volterra_equations.tsx | 36 + .../src/stdlib/analyses/mass_action.tsx | 8 +- .../analyses/mass_action_config_form.tsx | 8 +- .../src/stdlib/analyses/simulator_types.ts | 26 +- .../src/stdlib/theories/causal-loop.ts | 10 + .../theories/primitive-signed-stock-flow.ts | 16 - .../frontend/src/stdlib/theories/reg-net.ts | 12 +- 30 files changed, 2049 insertions(+), 839 deletions(-) create mode 100644 packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs delete mode 100644 packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs create mode 100644 packages/catlog/src/stdlib/analyses/stock_flow.rs create mode 100644 packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx create mode 100644 packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 57fee9811..394abd693 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -4,9 +4,11 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; -use catlog::stdlib::analyses::ode; +use catlog::stdlib::analyses::ode::{self, ODESemanticsAnalysis, ODESemanticsProblemData}; use catlog::zero::QualifiedName; +use crate::latex::{latex_mor_names_linear_ode, latex_mor_names_lotka_volterra}; + use super::latex::{LatexEquations, latex_mor_names, latex_mor_names_mass_action, latex_ob_names}; use super::model::DblModel; use super::result::JsResult; @@ -74,8 +76,8 @@ pub(crate) fn polynomial_ode_simulation( }) } -/// The mass-action analysis is currently implemented for Petri nets and stock-flow -/// diagrams, and we can avoid some code reduplication by making this explicit. +/// Mass-action analysis is currently implemented for Petri nets and stock-flow diagrams +/// and we can avoid some code reduplication by making this explicit. pub enum MassActionAnalysisLogic { /// The modal theory of Petri nets. PetriNet, @@ -88,17 +90,23 @@ fn mass_action_system( model: &DblModel, mass_conservation_type: ode::MassConservationType, logic: MassActionAnalysisLogic, -) -> Result, i8>, String> { +) -> Result, i8>, String> { match logic { MassActionAnalysisLogic::PetriNet => { let realised_model = model.modal_unital()?; - let analysis = ode::PetriNetMassActionAnalysis::default(); - Ok(analysis.build_system(realised_model, mass_conservation_type)) + let analysis = ode::PetriNetMassActionAnalysis { + mass_conservation_type, + ..ode::PetriNetMassActionAnalysis::default() + }; + Ok(analysis.build_system(realised_model)) } MassActionAnalysisLogic::StockFlow => { let realised_model = model.discrete_tab()?; - let analysis = ode::StockFlowMassActionAnalysis::default(); - Ok(analysis.build_system(realised_model, mass_conservation_type)) + let analysis = ode::StockFlowMassActionAnalysis { + mass_conservation_type, + ..ode::StockFlowMassActionAnalysis::default() + }; + Ok(analysis.build_system(realised_model)) } } } @@ -133,10 +141,99 @@ pub(crate) fn mass_action_simulation( logic: MassActionAnalysisLogic, ) -> Result { let sys = mass_action_system(model, data.mass_conservation_type, logic); - let sys_extended_scalars = ode::extend_mass_action_scalars(sys?, &data); + let sys_extended_scalars = data.extend_scalars(sys?); + let latex_equations = + sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + let analysis = data.build_analysis(sys_extended_scalars); + let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); + Ok(ODEResultWithEquations { + solution: ODEResult(solution.into()), + latex_equations: LatexEquations(latex_equations), + }) +} + +/// Generates the PolynomialSystem for Lotka-Volterra dynamics. +fn lotka_volterra_system( + model: &DblModel, +) -> Result, i8>, String> +{ + let realised_model = model.discrete()?; + let analysis = ode::LotkaVolterraAnalysis::default(); + Ok(analysis.build_system(realised_model)) +} + +/// The analysis data for polynomial ODE equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct LotkaVolterraEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} + +/// Generates Lotka-Volterra equations for the system. +pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { + let sys = lotka_volterra_system(model); + let equations = sys? + .map_variables(latex_ob_names(model)) + .extend_scalars(|param| param.map_variables(latex_mor_names_lotka_volterra(model))) + .to_latex_equations(); + Ok(LatexEquations(equations)) +} + +/// Simulates Lotka-Volterra ODEs. +pub(crate) fn lotka_volterra_simulation( + model: &DblModel, + data: ode::LotkaVolterraProblemData, +) -> Result { + let sys = lotka_volterra_system(model); + let sys_extended_scalars = data.extend_scalars(sys?); + let latex_equations = + sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + let analysis = data.build_analysis(sys_extended_scalars); + let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); + Ok(ODEResultWithEquations { + solution: ODEResult(solution.into()), + latex_equations: LatexEquations(latex_equations), + }) +} + +/// Generates the PolynomialSystem for linear ODE dynamics. +fn linear_ode_system( + model: &DblModel, +) -> Result, i8>, String> { + let realised_model = model.discrete()?; + let analysis = ode::LCCAnalysis::default(); + Ok(analysis.build_system(realised_model)) +} + +/// The analysis data for polynomial ODE equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct LCCEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} + +/// Generates linear ODE equations for the system. +pub(crate) fn linear_ode_equations(model: &DblModel) -> Result { + let sys = linear_ode_system(model); + let equations = sys? + .map_variables(latex_ob_names(model)) + .extend_scalars(|param| param.map_variables(latex_mor_names_linear_ode(model))) + .to_latex_equations(); + Ok(LatexEquations(equations)) +} + +/// Simulates linear ODE equations. +pub(crate) fn linear_ode_simulation( + model: &DblModel, + data: ode::LCCProblemData, +) -> Result { + let sys = linear_ode_system(model); + let sys_extended_scalars = data.extend_scalars(sys?); let latex_equations = sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); - let analysis = ode::into_mass_action_analysis(sys_extended_scalars, data); + let analysis = data.build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 848494f22..4234fef0b 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -57,7 +57,7 @@ pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> St /// falls back to the domain→codomain format (e.g., `X \to Y`). pub(crate) fn latex_mor_names_mass_action( model: &DblModel, -) -> impl Fn(&ode::FlowParameter) -> String { +) -> impl Fn(&ode::MassActionParameter) -> String { // Returns a LaTeX fragment for a transition, suitable for use as a subscript. // Named morphisms produce `\text{name}`, unnamed ones produce // `\text{dom} \to \text{cod}` so that `\to` is in math mode. @@ -72,31 +72,106 @@ pub(crate) fn latex_mor_names_mass_action( } }; - move |id: &ode::FlowParameter| match id { - ode::FlowParameter::Balanced { transition } => { + move |id: &ode::MassActionParameter| match id { + ode::MassActionParameter::Balanced { flow: transition } => { let sub = transition_subscript(transition); format!("r_{{{sub}}}") } - ode::FlowParameter::Unbalanced { direction, parameter } => match (direction, parameter) { - (ode::Direction::IncomingFlow, ode::RateParameter::PerTransition { transition }) => { - let sub = transition_subscript(transition); - format!("\\rho_{{{sub}}}") + ode::MassActionParameter::Unbalanced { direction, parameter } => { + match (direction, parameter) { + ( + ode::Direction::IncomingFlow, + ode::RateParameter::PerFlow { flow: transition }, + ) => { + let sub = transition_subscript(transition); + format!("\\rho_{{{sub}}}") + } + ( + ode::Direction::OutgoingFlow, + ode::RateParameter::PerFlow { flow: transition }, + ) => { + let sub = transition_subscript(transition); + format!("\\kappa_{{{sub}}}") + } + ( + ode::Direction::IncomingFlow, + ode::RateParameter::PerStock { flow: transition, stock: place }, + ) => { + let sub = transition_subscript(transition); + let output_place_label = model.ob_namespace.label_string(place); + format!("\\rho_{{{sub}}}^{{\\text{{{output_place_label}}}}}") + } + ( + ode::Direction::OutgoingFlow, + ode::RateParameter::PerStock { flow: transition, stock: place }, + ) => { + let sub = transition_subscript(transition); + let input_place_label = model.ob_namespace.label_string(place); + format!("\\kappa_{{{sub}}}^{{\\text{{{input_place_label}}}}}") + } } - (ode::Direction::OutgoingFlow, ode::RateParameter::PerTransition { transition }) => { - let sub = transition_subscript(transition); - format!("\\kappa_{{{sub}}}") - } - (ode::Direction::IncomingFlow, ode::RateParameter::PerPlace { transition, place }) => { - let sub = transition_subscript(transition); - let output_place_label = model.ob_namespace.label_string(place); - format!("\\rho_{{{sub}}}^{{\\text{{{output_place_label}}}}}") - } - (ode::Direction::OutgoingFlow, ode::RateParameter::PerPlace { transition, place }) => { - let sub = transition_subscript(transition); - let input_place_label = model.ob_namespace.label_string(place); - format!("\\kappa_{{{sub}}}^{{\\text{{{input_place_label}}}}}") - } - }, + } + } +} + +/// Creates a closure that formats morphism names for Lotka-Volterra LaTeX output. +/// +/// When a morphism has a label, it is used directly. When unnamed, the label +/// falls back to the domain→codomain format (e.g., `X \to Y`). +pub(crate) fn latex_mor_names_lotka_volterra( + model: &DblModel, +) -> impl Fn(&ode::LotkaVolterraParameter) -> String { + // Returns a LaTeX fragment for a transition, suitable for use as a subscript. + // Named morphisms produce `\text{name}`, unnamed ones produce + // `\text{dom} \to \text{cod}` so that `\to` is in math mode. + let transition_subscript = |transition: &QualifiedName| -> String { + if let Some(label) = model.mor_namespace.label(transition) { + format!("\\text{{{label}}}") + } else { + let (dom, cod) = model + .mor_generator_dom_cod_label_strings(transition) + .expect("Morphism in equation system should have domain and codomain"); + format!("\\text{{{dom}}} \\to \\text{{{cod}}}") + } + }; + + move |id: &ode::LotkaVolterraParameter| match id { + ode::LotkaVolterraParameter::Growth { variable } => { + format!("g_{{{variable}}}") + } + ode::LotkaVolterraParameter::Interaction { link } => { + let sub = transition_subscript(link); + format!("k_{{{sub}}}") + } + } +} + +/// Creates a closure that formats morphism names for mass-action LaTeX output. +/// +/// When a morphism has a label, it is used directly. When unnamed, the label +/// falls back to the domain→codomain format (e.g., `X \to Y`). +pub(crate) fn latex_mor_names_linear_ode( + model: &DblModel, +) -> impl Fn(&ode::LCCParameter) -> String { + // Returns a LaTeX fragment for a transition, suitable for use as a subscript. + // Named morphisms produce `\text{name}`, unnamed ones produce + // `\text{dom} \to \text{cod}` so that `\to` is in math mode. + let transition_subscript = |transition: &QualifiedName| -> String { + if let Some(label) = model.mor_namespace.label(transition) { + format!("\\text{{{label}}}") + } else { + let (dom, cod) = model + .mor_generator_dom_cod_label_strings(transition) + .expect("Morphism in equation system should have domain and codomain"); + format!("\\text{{{dom}}} \\to \\text{{{cod}}}") + } + }; + + move |id: &ode::LCCParameter| match id { + ode::LCCParameter::Parameter { morphism } => { + let sub = transition_subscript(morphism); + format!("\\lambda_{{{sub}}}") + } } } @@ -105,6 +180,7 @@ mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::simulate::ode::LatexEquation; + use catlog::stdlib::analyses::ode::{StockFlowMassActionAnalysis, ode_semantics::*}; use catlog::stdlib::{analyses::ode, theories}; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; use std::rc::Rc; @@ -117,11 +193,13 @@ mod tests { fn unbalanced_mass_action_latex_equations() { let model = backward_link("xxx", "yyy", "fff"); let tab_model = model.discrete_tab().unwrap(); - let analysis = ode::StockFlowMassActionAnalysis::default(); - let sys = analysis.build_system( - tab_model, - ode::MassConservationType::Unbalanced(ode::RateGranularity::PerTransition), - ); + let analysis = StockFlowMassActionAnalysis { + mass_conservation_type: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerFlow, + ), + ..StockFlowMassActionAnalysis::default() + }; + let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) @@ -144,11 +222,13 @@ mod tests { fn unnamed_mor_uses_dom_cod_in_equations() { let model = backward_link("xxx", "yyy", ""); let tab_model = model.discrete_tab().unwrap(); - let analysis = ode::StockFlowMassActionAnalysis::default(); - let sys = analysis.build_system( - tab_model, - ode::MassConservationType::Unbalanced(ode::RateGranularity::PerTransition), - ); + let analysis = StockFlowMassActionAnalysis { + mass_conservation_type: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerFlow, + ), + ..StockFlowMassActionAnalysis::default() + }; + let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index e4cb2e747..1bc7c87fb 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -150,16 +150,14 @@ impl ThSignedCategory { &self, model: &DblModel, data: analyses::ode::LotkaVolterraProblemData, - ) -> Result { - Ok(ODEResult( - analyses::ode::SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(name("Negative").into()) - .lotka_volterra_analysis(model.discrete()?, data) - .solve_with_defaults() - .map_err(|err| format!("{err:?}")) - .into(), - )) + ) -> Result { + lotka_volterra_simulation(model, data) + } + + /// Show the equations of the Lotka-Volterra system derived from a model. + #[wasm_bindgen(js_name = "lotkaVolterraEquations")] + pub fn lotka_volterra_equations(&self, model: &DblModel) -> Result { + lotka_volterra_equations(model) } /// Simulate the linear ODE system derived from a model. @@ -167,17 +165,15 @@ impl ThSignedCategory { pub fn linear_ode( &self, model: &DblModel, - data: analyses::ode::LinearODEProblemData, - ) -> Result { - Ok(ODEResult( - analyses::ode::SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(name("Negative").into()) - .linear_ode_analysis(model.discrete()?, data) - .solve_with_defaults() - .map_err(|err| format!("{err:?}")) - .into(), - )) + data: analyses::ode::LCCProblemData, + ) -> Result { + linear_ode_simulation(model, data) + } + + /// Show the equations of the linear ODE system derived from a model. + #[wasm_bindgen(js_name = "linearODEEquations")] + pub fn linear_ode_equations(&self, model: &DblModel) -> Result { + linear_ode_equations(model) } } @@ -370,26 +366,6 @@ impl ThCategorySignedLinks { pub fn theory(&self) -> DblTheory { DblTheory(self.0.clone().into()) } - - /// Simulates the mass-action ODE system derived from a model. - #[wasm_bindgen(js_name = "massAction")] - pub fn mass_action( - &self, - model: &DblModel, - data: analyses::ode::MassActionProblemData, - ) -> Result { - mass_action_simulation(model, data, MassActionAnalysisLogic::StockFlow) - } - - /// Returns the symbolic mass-action equations in LaTeX format. - #[wasm_bindgen(js_name = "massActionEquations")] - pub fn mass_action_equations( - &self, - model: &DblModel, - data: MassActionEquationsData, - ) -> Result { - mass_action_equations(model, data, MassActionAnalysisLogic::StockFlow) - } } /// The theory of strict symmetric monoidal categories. diff --git a/packages/catlog/src/stdlib/analyses/mod.rs b/packages/catlog/src/stdlib/analyses/mod.rs index 861f3ef8c..2fffc0f45 100644 --- a/packages/catlog/src/stdlib/analyses/mod.rs +++ b/packages/catlog/src/stdlib/analyses/mod.rs @@ -1,6 +1,7 @@ //! Various analyses that can be performed on models. pub(crate) mod petri; +pub(crate) mod stock_flow; #[cfg(feature = "ode")] pub mod ode; diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 83f4a6c22..4364f0761 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -1,29 +1,130 @@ -//! Constant-coefficient linear first-order ODE analysis of models. +//! Linear constant-coefficient (LCC) first-order ODE analysis of models. //! -//! The main entry point for this module is -//! [`linear_ode_analysis`](SignedCoefficientBuilder::linear_ode_analysis). +//! This follows the structure of [`ode::ode_semantics`], implementing `ODESemantics` for +//! the struct `LCCSemantics`. For heritage reasons, "LCC" is sometimes referred to as "LinearODE". +//! +//! [`ode::ode_semantics`]: crate::stdlib::analyses::ode::ode_semantics use std::collections::HashMap; -use std::hash::Hash; -use std::ops::Add; - -use indexmap::IndexMap; -use itertools::Itertools; -use nalgebra::{DMatrix, DVector}; -use num_traits::Zero; +use std::fmt; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::{ODEAnalysis, Parameter, SignedCoefficientBuilder}; -use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; -use crate::{ - dbl::model::DiscreteDblModel, - one::QualifiedPath, - zero::{QualifiedName, rig::Monomial}, -}; +use super::Parameter; +use crate::dbl::model::MutDblModel; +use crate::one::Path; +use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::ode_semantics::*; +use crate::zero::name; +use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; + +/// Implementing LCC as an ODE semantics for models of type `DiscreteDblModel`. +pub struct LCCSemantics; + +impl ODESemantics for LCCSemantics { + type ModelType = DiscreteDblModel; + type ParameterType = LCCParameter; + type AnalysisType = LCCAnalysis; + type ProblemDataType = LCCProblemData; +} + +/// Parameters in the linear equations correspond only to morphisms. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum LCCParameter { + /// The parameter associated to a morphism. + Parameter { + /// The morphism. + morphism: QualifiedName, + }, +} + +impl fmt::Display for LCCParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parameter { morphism } => { + write!(f, "Parameter({})", morphism) + } + } + } +} + +impl ODEParameterType for LCCParameter {} + +/// Linear ODE analysis for causal loop diagrams (CLDs). +pub struct LCCAnalysis { + /// Object type for variables. + pub var_ob_type: QualifiedName, + /// Morphism type for positive links. + pub pos_link_type: QualifiedPath, + /// Morphism type for negative links. + pub neg_link_type: QualifiedPath, +} + +impl Default for LCCAnalysis { + fn default() -> Self { + let ob_type = name("Object"); + Self { + var_ob_type: ob_type.clone(), + pos_link_type: Path::Id(ob_type.clone()), + neg_link_type: Path::single(name("Negative")), + } + } +} + +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for LCCAnalysis +{ + /// Creates a linear system with symbolic rate coefficients. + /// + /// A system of ODEs for building arbitrary LCC ODEs from CLDs. + fn build_semantics( + &self, + ) -> ODESemanticsBuilder< + ::ModelType, + ::ParameterType, + > { + // Each variable in the CLD gives a variable in the ODE system. + let variable_builders = vec![ODEVariableBuilder::Object { + ob_type: LCCAnalysis::default().var_ob_type, + }]; + + // Links in the CLD give contributions to the ODEs governing their *codomain*, in an amount + // proportionate to their *domain*, i.e. x -> y gives (d/dt)y += x. Each positive link + // in the CLD gives a positive contribution and each negative link a negative contribution. + let interaction = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Morphism { + mor_types_and_signs: vec![ + (LCCAnalysis::default().pos_link_type, ContributionSign::Positive), + (LCCAnalysis::default().neg_link_type, ContributionSign::Negative), + ], + mor_contributions: vec![{ + |link, model| { + let dom = model.get_dom(link).unwrap(); + let cod = model.get_cod(link).unwrap(); + vec![Contribution { + name: link.clone(), + monomial: vec![dom.clone()], + parameter: LCCParameter::Parameter { morphism: link.clone() }, + target: cod.clone(), + }] + } + }], + }; + + ODESemanticsBuilder { + variable_builders, + contribution_builders: vec![interaction], + } + } +} /// Data defining a linear ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -32,7 +133,7 @@ use crate::{ feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] -pub struct LinearODEProblemData { +pub struct LCCProblemData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "coefficients"))] coefficients: HashMap, @@ -45,73 +146,32 @@ pub struct LinearODEProblemData { duration: f32, } -/// Construct a linear (first-order) dynamical system; -/// a semantics for causal loop diagrams. -pub fn linear_polynomial_system( - vars: &[Var], - coefficients: DMatrix, -) -> PolynomialSystem -where - Var: Clone + Hash + Ord, - Coef: Clone + Add + Zero, -{ - let system = PolynomialSystem { - components: coefficients - .row_iter() - .zip(vars) - .map(|(row, i)| { - ( - i.clone(), - row.iter() - .zip(vars) - .map(|(a, j)| (a.clone(), Monomial::generator(j.clone()))) - .collect(), - ) - }) - .collect(), - }; - system.normalize() -} +impl ODESemanticsProblemData<::ParameterType> for LCCProblemData { + fn initial_values(&self) -> HashMap { + self.initial_values.clone() + } -impl SignedCoefficientBuilder { - /// Linear ODE analysis for a model of a double theory. - /// - /// This analysis is a special case of linear ODE analysis for *extended* causal - /// loop diagrams but can serve as a simple/naive semantics for causal loop - /// diagrams, hopefully useful for toy models and demonstration purposes. - pub fn linear_ode_analysis( - &self, - model: &DiscreteDblModel, - data: LinearODEProblemData, - ) -> ODEAnalysis> { - let (system, ob_index) = self.linear_ode_system(model); - let n = ob_index.len(); - - let initial_values = ob_index - .keys() - .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); - - let system = system - .extend_scalars(|poly| { - poly.eval(|id| data.coefficients.get(id).copied().unwrap_or_default()) - }) - .to_numerical(); - let problem = ODEProblem::new(system, x0).end_time(data.duration); - ODEAnalysis::new(problem, ob_index) + fn duration(&self) -> f32 { + self.duration } - /// Linear ODE system for a model of a double theory. - pub fn linear_ode_system( + fn extend_scalars( &self, - model: &DiscreteDblModel, - ) -> ( - PolynomialSystem, u8>, - IndexMap, - ) { - let (matrix, ob_index) = self.build_matrix(model); - let system = linear_polynomial_system(&ob_index.keys().cloned().collect_vec(), matrix); - (system, ob_index) + sys: PolynomialSystem< + QualifiedName, + Parameter<::ParameterType>, + i8, + >, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|param| match param { + LCCParameter::Parameter { morphism } => { + self.coefficients.get(morphism).cloned().unwrap_or_default() + } + }) + }); + + sys.normalize() } } @@ -121,43 +181,89 @@ mod test { use std::rc::Rc; use super::*; - use crate::stdlib; - use crate::{one::Path, zero::name}; + use crate::{ + dbl::model::MutDblModel, + simulate::ode::LatexEquation, + stdlib::{models::*, theories::*}, + }; + + // Symbolic tests. - fn builder() -> SignedCoefficientBuilder { - SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(Path::single(name("Negative"))) + #[test] + fn predator_prey_symbolic() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = LCCAnalysis::default().build_system(&model); + let expected = expect!([r#" + dx = -Parameter(negative) y + dy = Parameter(positive) x + "#]); + expected.assert_eq(&sys.to_string()); } #[test] - fn negative_feedback_symbolic() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); - let (sys, _) = builder().linear_ode_system(&neg_feedback); - let expected = expect![[r#" - dx = -negative y - dy = positive x - "#]]; + fn complicated_symbolic() { + let th = Rc::new(th_signed_category()); + let mut model = DiscreteDblModel::new(th); + model.add_ob(name("a"), name("Object")); + model.add_ob(name("b"), name("Object")); + model.add_ob(name("c"), name("Object")); + model.add_ob(name("d"), name("Object")); + model.add_mor(name("f"), name("a"), name("b"), Path::Id(name("Object"))); + model.add_mor(name("g"), name("b"), name("a"), Path::Id(name("Object"))); + model.add_mor(name("h"), name("b"), name("a"), name("Negative").into()); + model.add_mor(name("i"), name("a"), name("c"), name("Negative").into()); + model.add_mor(name("j"), name("c"), name("d"), Path::Id(name("Object"))); + model.add_mor(name("k"), name("d"), name("b"), name("Negative").into()); + let sys = LCCAnalysis::default().build_system(&model); + let expected = expect!([r#" + da = (Parameter(g) - Parameter(h)) b + db = Parameter(f) a - Parameter(k) d + dc = -Parameter(i) a + dd = Parameter(j) c + "#]); expected.assert_eq(&sys.to_string()); } + // Test for LaTeX. + #[test] - fn negative_feedback_numerical() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); + fn to_latex() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = LCCAnalysis::default().build_system(&model); + let expected = vec![ + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), + rhs: "-Parameter(negative) \\cdot y".to_string(), + }, + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), + rhs: "Parameter(positive) \\cdot x".to_string(), + }, + ]; + assert_eq!(expected, sys.to_latex_equations()); + } + + // Numerical test. - let data = LinearODEProblemData { - coefficients: [(name("positive"), 2.0), (name("negative"), 1.0)].into_iter().collect(), + #[test] + fn predator_prey_numerical() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + + let data = LCCProblemData { + coefficients: [(name("positive"), 3.0), (name("negative"), 2.0)].into_iter().collect(), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, }; - let sys = builder().linear_ode_analysis(&neg_feedback, data).problem.system; - let expected = expect![[r#" - dx0 = -x1 - dx1 = 2 x0 - "#]]; - expected.assert_eq(&sys.to_string()); + let sys = LCCAnalysis::default().build_system(&model); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dx = -2 y + dy = 3 x + "#]); + expected.assert_eq(&analysis.to_string()); } } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 9a1853f16..d656b6627 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -1,30 +1,168 @@ //! Lotka-Volterra ODE analysis of models. //! -//! The main entry point for this module is -//! [`lotka_volterra_analysis`](SignedCoefficientBuilder::lotka_volterra_analysis). +//! This follows the structure of [`ode::ode_semantics`], implementing `ODESemantics` for +//! the struct `LotkaVolterraSemantics`. +//! +//! [`ode::ode_semantics`]: crate::stdlib::analyses::ode::ode_semantics use std::collections::HashMap; -use std::hash::Hash; -use std::ops::Add; - -use indexmap::IndexMap; -use itertools::Itertools; -use nalgebra::{DMatrix, DVector, Scalar}; -use num_traits::{One, Zero}; +use std::fmt; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::{ODEAnalysis, Parameter, SignedCoefficientBuilder}; -use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; +use super::Parameter; +use crate::one::Path; +use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::ode_semantics::*; +use crate::zero::name; use crate::{ - dbl::model::DiscreteDblModel, + dbl::model::{DiscreteDblModel, MutDblModel}, one::QualifiedPath, - zero::{QualifiedName, alg::Polynomial, rig::Monomial}, + zero::QualifiedName, }; +/// Implementing Lotka-Volterra as an ODE semantics for models of type `DiscreteDblModel`. +pub struct LotkaVolterraSemantics; + +impl ODESemantics for LotkaVolterraSemantics { + type ModelType = DiscreteDblModel; + type ParameterType = LotkaVolterraParameter; + type AnalysisType = LotkaVolterraAnalysis; + type ProblemDataType = LotkaVolterraProblemData; +} + +/// Parameters in the Lotka-Volterra equations come in two flavours, corresponding to +/// either variables or links. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum LotkaVolterraParameter { + /// The parameter associated to a variable. + Growth { + /// The variable. + variable: QualifiedName, + }, + /// The parameter associated to a link. + Interaction { + /// The link. + link: QualifiedName, + }, +} + +impl fmt::Display for LotkaVolterraParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::Growth { variable } => { + write!(f, "Growth({})", variable) + } + Self::Interaction { link } => { + write!(f, "Interaction({})", link) + } + } + } +} + +impl ODEParameterType for LotkaVolterraParameter {} + +/// This Lotka-Volterra ODE analysis is intended for application to CLDs. +pub struct LotkaVolterraAnalysis { + /// Object type for variables. + pub var_ob_type: QualifiedName, + /// Morphism type for positive links. + pub pos_link_type: QualifiedPath, + /// Morphism type for negative links. + pub neg_link_type: QualifiedPath, +} + +impl Default for LotkaVolterraAnalysis { + fn default() -> Self { + let ob_type = name("Object"); + Self { + var_ob_type: ob_type.clone(), + pos_link_type: Path::Id(ob_type.clone()), + neg_link_type: Path::single(name("Negative")), + } + } +} + +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for LotkaVolterraAnalysis +{ + /// Creates a Lotka-Volterra system with symbolic rate coefficients. + /// + /// A system of ODEs that is affine in its *logarithmic* derivative. These are + /// sometimes called the "generalized Lotka-Volterra equations." For more, see + /// [Wikipedia](https://en.wikipedia.org/wiki/Generalized_Lotka%E2%80%93Volterra_equation) + /// and [our paper on regulatory networks](crate::refs::RegNets). + fn build_semantics( + &self, + ) -> ODESemanticsBuilder< + ::ModelType, + ::ParameterType, + > { + // Each variable in the CLD gives a variable in the ODE system. + let variable_builders = vec![ODEVariableBuilder::Object { + ob_type: LotkaVolterraAnalysis::default().var_ob_type, + }]; + + // Each variable in the CLD *also* gives its growth contribution: + // "(d/dt)x += g_x x" for a coefficient g_x. + let growth = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Object { + ob_types_and_signs: vec![( + LotkaVolterraAnalysis::default().var_ob_type, + ContributionSign::Positive, + )], + ob_contributions: vec![{ + |var, _| { + vec![Contribution { + name: var.clone(), + monomial: vec![var.clone()], + parameter: LotkaVolterraParameter::Growth { variable: var.clone() }, + target: var.clone(), + }] + } + }], + }; + + // Links in the CLD give contributions to the ODEs governing their codomain, namely + // x -> y gives "(d/dt)y += k_xy xy" for a coefficient k_xy. Each positive link + // in the CLD gives a positive contribution, and each negative link a negative contribution. + let interaction = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Morphism { + mor_types_and_signs: vec![ + (LotkaVolterraAnalysis::default().pos_link_type, ContributionSign::Positive), + (LotkaVolterraAnalysis::default().neg_link_type, ContributionSign::Negative), + ], + mor_contributions: vec![{ + |link, model| { + let dom = model.get_dom(link).unwrap(); + let cod = model.get_cod(link).unwrap(); + vec![Contribution { + name: link.clone(), + monomial: vec![dom.clone(), cod.clone()], + parameter: LotkaVolterraParameter::Interaction { link: link.clone() }, + target: cod.clone(), + }] + } + }], + }; + + ODESemanticsBuilder { + variable_builders, + contribution_builders: vec![growth, interaction], + } + } +} + /// Data defining a Lotka-Volterra ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -49,94 +187,37 @@ pub struct LotkaVolterraProblemData { duration: f32, } -/// Construct a Lotka-Volterra dynamical system. -/// -/// A system of ODEs that is affine in its *logarithmic* derivative. These are -/// sometimes called the "generalized Lotka-Volterra equations." For more, see -/// [Wikipedia](https://en.wikipedia.org/wiki/Generalized_Lotka%E2%80%93Volterra_equation). -pub fn lotka_volterra_system( - vars: &[Var], - interaction_coeffs: DMatrix, - growth_rates: DVector, -) -> PolynomialSystem -where - Var: Clone + Hash + Ord, - Coef: Clone + Add + One + Scalar + Zero, +impl ODESemanticsProblemData<::ParameterType> + for LotkaVolterraProblemData { - let system = PolynomialSystem { - components: interaction_coeffs - .row_iter() - .zip(vars) - .zip(&growth_rates) - .map(|((row, i), r)| { - ( - i.clone(), - Polynomial::<_, Coef, _>::generator(i.clone()) - * (row - .iter() - .zip(vars) - .map(|(a, j)| (a.clone(), Monomial::generator(j.clone()))) - .collect::>() - + r.clone()), - ) - }) - .collect(), - }; - system.normalize() -} + fn initial_values(&self) -> HashMap { + self.initial_values.clone() + } -impl SignedCoefficientBuilder { - /// Lotka-Volterra ODE analysis for a model of a double theory. - /// - /// The main application we have in mind is the Lotka-Volterra ODE semantics for - /// signed graphs described in our [paper on regulatory - /// networks](crate::refs::RegNets). - pub fn lotka_volterra_analysis( - &self, - model: &DiscreteDblModel, - data: LotkaVolterraProblemData, - ) -> ODEAnalysis> { - let (system, ob_index) = self.lotka_volterra_system(model); - let n = ob_index.len(); - - let initial_values = ob_index - .keys() - .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); - - let system = system - .extend_scalars(|poly| { - poly.eval(|id| { - data.interaction_coeffs - .get(id) - .or(data.growth_rates.get(id)) - .copied() - .unwrap_or_default() - }) - }) - .to_numerical(); - let problem = ODEProblem::new(system, x0).end_time(data.duration); - ODEAnalysis::new(problem, ob_index) + fn duration(&self) -> f32 { + self.duration } - /// Lotka-Volterra ODE system for an model of a double theory. - pub fn lotka_volterra_system( + fn extend_scalars( &self, - model: &DiscreteDblModel, - ) -> ( - PolynomialSystem, u8>, - IndexMap, - ) { - let (matrix, ob_index) = self.build_matrix(model); - let n = ob_index.len(); - - let growth_rate_params = ob_index - .keys() - .map(|ob| [(1.0, Monomial::generator(ob.clone()))].into_iter().collect()); - let b = DVector::from_iterator(n, growth_rate_params); - - let system = lotka_volterra_system(&ob_index.keys().cloned().collect_vec(), matrix, b); - (system, ob_index) + sys: PolynomialSystem< + QualifiedName, + Parameter<::ParameterType>, + i8, + >, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|param| match param { + LotkaVolterraParameter::Growth { variable } => { + self.growth_rates.get(variable).cloned().unwrap_or_default() + } + LotkaVolterraParameter::Interaction { link } => { + self.interaction_coeffs.get(link).cloned().unwrap_or_default() + } + }) + }); + + sys.normalize() } } @@ -146,32 +227,76 @@ mod test { use std::rc::Rc; use super::*; - use crate::stdlib; - use crate::{one::Path, zero::name}; + use crate::{ + dbl::model::MutDblModel, + simulate::ode::LatexEquation, + stdlib::{models::*, theories::*}, + }; - fn builder() -> SignedCoefficientBuilder { - SignedCoefficientBuilder::new(name("Object")) - .add_positive(Path::Id(name("Object"))) - .add_negative(Path::single(name("Negative"))) - } + // Symbolic tests. #[test] fn predator_prey_symbolic() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); - let (sys, _) = builder().lotka_volterra_system(&neg_feedback); - let sys = sys.extend_scalars(|coef| coef.map_variables(|name| format!("Param({name})"))); + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = LotkaVolterraAnalysis::default().build_system(&model); let expected = expect!([r#" - dx = Param(x) x - Param(negative) x y - dy = Param(positive) x y + Param(y) y + dx = Growth(x) x - Interaction(negative) x y + dy = Interaction(positive) x y + Growth(y) y "#]); expected.assert_eq(&sys.to_string()); } + #[test] + fn complicated_symbolic() { + let th = Rc::new(th_signed_category()); + let mut model = DiscreteDblModel::new(th); + model.add_ob(name("a"), name("Object")); + model.add_ob(name("b"), name("Object")); + model.add_ob(name("c"), name("Object")); + model.add_ob(name("d"), name("Object")); + model.add_mor(name("f"), name("a"), name("b"), Path::Id(name("Object"))); + model.add_mor(name("g"), name("b"), name("a"), Path::Id(name("Object"))); + model.add_mor(name("h"), name("b"), name("a"), name("Negative").into()); + model.add_mor(name("i"), name("a"), name("c"), name("Negative").into()); + model.add_mor(name("j"), name("c"), name("d"), Path::Id(name("Object"))); + model.add_mor(name("k"), name("d"), name("b"), name("Negative").into()); + let sys = LotkaVolterraAnalysis::default().build_system(&model); + let expected = expect!([r#" + da = Growth(a) a + (Interaction(g) - Interaction(h)) a b + db = Interaction(f) a b + Growth(b) b - Interaction(k) b d + dc = -Interaction(i) a c + Growth(c) c + dd = Interaction(j) c d + Growth(d) d + "#]); + expected.assert_eq(&sys.to_string()); + } + + // Test for LaTeX. + + #[test] + fn to_latex() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + let sys = LotkaVolterraAnalysis::default().build_system(&model); + let expected = vec![ + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), + rhs: "Growth(x) \\cdot x - Interaction(negative) \\cdot x \\cdot y".to_string(), + }, + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), + rhs: "Interaction(positive) \\cdot x \\cdot y + Growth(y) \\cdot y".to_string(), + }, + ]; + assert_eq!(expected, sys.to_latex_equations()); + } + + // Numerical test. + #[test] fn predator_prey_numerical() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); let data = LotkaVolterraProblemData { interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] @@ -182,11 +307,12 @@ mod test { duration: 10.0, }; - let sys = builder().lotka_volterra_analysis(&neg_feedback, data).problem.system; + let sys = LotkaVolterraAnalysis::default().build_system(&model); + let analysis = data.extend_scalars(sys); let expected = expect!([r#" - dx0 = 2 x0 - x0 x1 - dx1 = x0 x1 - x1 + dx = 2 x - x y + dy = x y - y "#]); - expected.assert_eq(&sys.to_string()); + expected.assert_eq(&analysis.to_string()); } } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 9daac606b..2eab1bc02 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -7,24 +7,41 @@ use std::{collections::HashMap, fmt}; -use indexmap::IndexMap; -use nalgebra::DVector; -use num_traits::Zero; - #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::{ODEAnalysis, Parameter}; +use super::Parameter; use crate::dbl::{ - model::{DiscreteTabModel, FpDblModel, ModalDblModel, TabEdge}, + model::{DiscreteTabModel, ModalDblModel}, theory::{ModalMorType, ModalObType, TabMorType, TabObType, Unital}, }; -use crate::one::FgCategory; -use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; +use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::ode_semantics::*; use crate::stdlib::analyses::petri::transition_interface; -use crate::zero::{QualifiedName, alg::Polynomial, name, rig::Monomial}; +use crate::stdlib::analyses::stock_flow::flow_interface; +use crate::zero::name_seg; +use crate::zero::{QualifiedName, name}; + +/// Mass-action semantics for Petri nets. +pub struct PetriNetMassActionSemantics; +/// Mass-action semantics for stock-flow diagrams. +pub struct StockFlowMassActionSemantics; + +impl ODESemantics for PetriNetMassActionSemantics { + type ModelType = ModalDblModel; + type ParameterType = MassActionParameter; + type AnalysisType = PetriNetMassActionAnalysis; + type ProblemDataType = MassActionProblemData; +} + +impl ODESemantics for StockFlowMassActionSemantics { + type ModelType = DiscreteTabModel; + type ParameterType = MassActionParameter; + type AnalysisType = StockFlowMassActionAnalysis; + type ProblemDataType = MassActionProblemData; +} /// There are three types of mass-action semantics, each more expressive than the previous: /// - balanced @@ -49,22 +66,23 @@ pub enum MassConservationType { #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] pub enum RateGranularity { - /// Each transition gets assigned a single consumption and single production rate. - PerTransition, + /// Each flow gets assigned a single consumption and single production rate. + PerFlow, - /// Each transition gets assigned a consumption rate for each input place and - /// a production rate for each output place. - PerPlace, + /// Each flow gets assigned a consumption rate for each input stock and + /// a production rate for each output stock. + PerStock, } +/// Now, corresponding to each term of `MassConvervationType`, we have different terms for `MassActionParameter`. /// Parameters in the generated polynomial equations are *undirected* in the /// balanced case and *directed* in the unbalanced case. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum FlowParameter { +pub enum MassActionParameter { /// If mass is conserved, we don't need to worry whether a flow is incoming or outgoing. Balanced { /// Since there is no direction, the rate parameter corresponds to a single transition. - transition: QualifiedName, + flow: QualifiedName, }, /// If mass is not conserved, then we need to know whether a flow is incoming or outgoing. Unbalanced { @@ -78,19 +96,19 @@ pub enum FlowParameter { /// Depending on the rate granularity, the parameters are specified by different structures. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum RateParameter { - /// For per transition rates, we simply need to know the associated transition. - PerTransition { - /// The transition to which we associate the rate parameter. - transition: QualifiedName, + /// For per flow rates, we simply need to know the associated flow. + PerFlow { + /// The flow to which we associate the rate parameter. + flow: QualifiedName, }, - /// For per place rates, we need to know both the transition and the corresponding - /// input/output place. - PerPlace { - /// The transition whose input/output objects we wish to associate rate parameters. - transition: QualifiedName, - /// The input/output object to which we associate the rate parameter. - place: QualifiedName, + /// For per stock rates, we need to know both the transition and the corresponding + /// input/output stock. + PerStock { + /// The flow whose input/output objects we wish to associate rate parameters. + flow: QualifiedName, + /// The input/output stock to which we associate the rate parameter. + stock: QualifiedName, }, } @@ -106,33 +124,33 @@ pub enum Direction { OutgoingFlow, } -impl fmt::Display for FlowParameter { +impl fmt::Display for MassActionParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { - FlowParameter::Balanced { transition: trans } => { + Self::Balanced { flow: trans } => { write!(f, "{}", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerTransition { transition: trans }, + parameter: RateParameter::PerFlow { flow: trans }, } => { write!(f, "Incoming({})", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerPlace { transition: trans, place: output }, + parameter: RateParameter::PerStock { flow: trans, stock: output }, } => { write!(f, "([{}]->{})", trans, output) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { transition: trans }, + parameter: RateParameter::PerFlow { flow: trans }, } => { write!(f, "Outgoing({})", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerPlace { transition: trans, place: input }, + parameter: RateParameter::PerStock { flow: trans, stock: input }, } => { write!(f, "({}->[{}])", input, trans) } @@ -140,51 +158,7 @@ impl fmt::Display for FlowParameter { } } -/// Data defining an unbalanced mass-action ODE problem for a model. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr( - feature = "serde-wasm", - tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) -)] -pub struct MassActionProblemData { - /// Whether or not mass is conserved. - #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] - pub mass_conservation_type: MassConservationType, - - /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), - /// for the balanced per transition case. - /// N.B. This is renamed to "rates" in catlog-wasm for backwards compatibility. - #[cfg_attr(feature = "serde", serde(rename = "rates"))] - transition_rates: HashMap, - - /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), - /// for the unbalanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionConsumptionRates"))] - transition_consumption_rates: HashMap, - - /// Map from morphism IDs to production rate coefficients (nonnegative reals), - /// for the unbalanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionProductionRates"))] - transition_production_rates: HashMap, - - /// Map from morphism IDs to (map from input objects to consumption rate coefficients), - /// for the unbalanced per place case (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "placeConsumptionRates"))] - place_consumption_rates: HashMap>, - - /// Map from morphism IDs to (map from output objects to production rate coefficients), - /// for the unbalanced per place case (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "placeProductionRates"))] - place_production_rates: HashMap>, - - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] - pub initial_values: HashMap, - - /// Duration of simulation. - pub duration: f32, -} +impl ODEParameterType for MassActionParameter {} /// Mass-action ODE analysis for Petri nets. /// @@ -195,6 +169,8 @@ pub struct PetriNetMassActionAnalysis { pub place_ob_type: ModalObType, /// Morphism type for transitions. pub transition_mor_type: ModalMorType, + /// Mass-conservation type. + pub mass_conservation_type: MassConservationType, } impl Default for PetriNetMassActionAnalysis { @@ -203,104 +179,238 @@ impl Default for PetriNetMassActionAnalysis { Self { place_ob_type: ob_type.clone(), transition_mor_type: ModalMorType::Zero(ob_type), + mass_conservation_type: MassConservationType::Balanced, } } } -impl PetriNetMassActionAnalysis { - /// Creates a mass-action system with symbolic rate coefficients. - pub fn build_system( +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for PetriNetMassActionAnalysis +{ + fn build_semantics( &self, - model: &ModalDblModel, - mass_conservation_type: MassConservationType, - ) -> PolynomialSystem, i8> { - let mut sys = PolynomialSystem::new(); - for ob in model.ob_generators_with_type(&self.place_ob_type) { - sys.add_term(ob, Polynomial::zero()); - } - for mor in model.mor_generators_with_type(&self.transition_mor_type) { - let (inputs, outputs) = transition_interface(model, &mor); - let term: Monomial<_, _> = - inputs.iter().map(|ob| (ob.clone().unwrap_generator(), 1)).collect(); - - match mass_conservation_type { + ) -> ODESemanticsBuilder< + ::ModelType, + ::ParameterType, + > { + let variable_builders = vec![ODEVariableBuilder::Object { + ob_type: PetriNetMassActionAnalysis::default().place_ob_type, + }]; + + // REQUEST | The following code is horrible, with so much duplication that it makes + // FOR | editing (and inspecting) it really difficult. This is all because we store + // FEEDBACK | `mass_conservation_type` in `PetriNetMassActionAnalysis`, and we can't use + // _________/ `self.mass_conservation_type` in any of the closures constructed for + // `mor_contributions` (otherwise it'd try to coerce some captured values or something). + // + // I can see a few possible fixes here: + // + // 1. Use some Rust magic to just refactor everything and make it work without any + // substantial design changes to code elsewhere (both here and in `ode_semantics`). + // + // 2. Move `mass_conservation_type` elsewhere, into a different struct, or pass it as an + // argument into `build_semantics()` (which will require quite a reshuffle in other place). + // + // 3. Actually create three separate structs here: one `PetriNetMassActionAnalysis` for each + // mass-conservation type. + // + // 4. Do some Rust wizardry that allows you to essentially fake a dependent type + // `PetriNetMassActionAnalysis(MassConservationType)`. + + // Note that a single morphism in a Petri net gives rise to multiple morphisms in the + // derived model of signed polynomial ODE systems, according to its interface. For example, + // a single transition T: [a,b] -> [x,y] in `model` will give four morphisms in `ode_model`, + // namely two positive contributions (ab -> x , ab -> y) and two negative (ab -> a , ab -> b). + // + // First we look at all the *negative* contributions coming from a transition, to its input places. + let transition_inputs = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Morphism { + mor_types_and_signs: vec![( + PetriNetMassActionAnalysis::default().transition_mor_type, + ContributionSign::Negative, + )], + mor_contributions: match self.mass_conservation_type { MassConservationType::Balanced => { - let term: Polynomial<_, _, _> = [( - Parameter::generator(FlowParameter::Balanced { transition: mor }), - term.clone(), - )] - .into_iter() - .collect(); - - for input in inputs { - sys.add_term(input.unwrap_generator(), -term.clone()); + vec![{ + |transition, model| { + let inputs = + transition_interface(model, transition).input_places.clone(); + + inputs + .iter() + .map(|input| Contribution { + name: transition + .clone() + .snoc(name_seg("ToInput")) + .snoc(input.clone().only().unwrap()), + monomial: inputs.clone(), + parameter: MassActionParameter::Balanced { + flow: transition.clone(), + }, + target: input.clone(), + }) + .collect() + } + }] + } + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerFlow => { + vec![{ + |transition, model| { + let inputs = + transition_interface(model, transition).input_places.clone(); + + inputs + .iter() + .map(|input| Contribution { + name: transition + .clone() + .snoc(name_seg("ToInput")) + .snoc(input.clone().only().unwrap()), + monomial: inputs.clone(), + parameter: MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerFlow { + flow: transition.clone(), + }, + }, + target: input.clone(), + }) + .collect() + } + }] } - - for output in outputs { - sys.add_term(output.unwrap_generator(), term.clone()); + RateGranularity::PerStock => { + vec![{ + |transition, model| { + let inputs = + transition_interface(model, transition).input_places.clone(); + + inputs + .iter() + .map(|input| Contribution { + name: transition + .clone() + .snoc(name_seg("ToInput")) + .snoc(input.clone().only().unwrap()), + monomial: inputs.clone(), + parameter: MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerStock { + flow: transition.clone(), + stock: input.clone(), + }, + }, + target: input.clone(), + }) + .collect() + } + }] } - } + }, + }, + }; - MassConservationType::Unbalanced(granularity) => { - for input in inputs { - let input_term: Polynomial<_, _, _> = match granularity { - RateGranularity::PerTransition => [( - Parameter::generator(FlowParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { - transition: mor.clone(), - }, - }), - term.clone(), - )], - RateGranularity::PerPlace => [( - Parameter::generator(FlowParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerPlace { - transition: mor.clone(), - place: input.clone().unwrap_generator(), + // Now we look at all the *positive* contributions coming from a transition, to its output places. + let transition_outputs = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Morphism { + mor_types_and_signs: vec![( + PetriNetMassActionAnalysis::default().transition_mor_type, + ContributionSign::Positive, + )], + mor_contributions: match self.mass_conservation_type { + MassConservationType::Balanced => { + vec![{ + |transition, model| { + let inputs = transition_interface(model, transition).input_places; + let outputs = transition_interface(model, transition).output_places; + + outputs + .iter() + .map(|output| Contribution { + name: transition + .clone() + .snoc(name_seg("ToOutPut")) + .snoc(output.clone().only().unwrap()), + monomial: inputs.clone(), + parameter: MassActionParameter::Balanced { + flow: transition.clone(), }, - }), - term.clone(), - )], + target: output.clone(), + }) + .collect() } - .into_iter() - .collect(); - - sys.add_term(input.unwrap_generator(), -input_term.clone()); + }] + } + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerFlow => { + vec![{ + |transition, model| { + let inputs = transition_interface(model, transition).input_places; + let outputs = transition_interface(model, transition).output_places; + + outputs + .iter() + .map(|output| Contribution { + name: transition + .clone() + .snoc(name_seg("ToOutput")) + .snoc(output.clone().only().unwrap()), + monomial: inputs.clone(), + parameter: MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerFlow { + flow: transition.clone(), + }, + }, + target: output.clone(), + }) + .collect() + } + }] } - for output in outputs { - let output_term: Polynomial<_, _, _> = match granularity { - RateGranularity::PerTransition => [( - Parameter::generator(FlowParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerTransition { - transition: mor.clone(), - }, - }), - term.clone(), - )], - RateGranularity::PerPlace => [( - Parameter::generator(FlowParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerPlace { - transition: mor.clone(), - place: output.clone().unwrap_generator(), - }, - }), - term.clone(), - )], - } - .into_iter() - .collect(); - - sys.add_term(output.unwrap_generator(), output_term.clone()); + RateGranularity::PerStock => { + vec![{ + |transition, model| { + let inputs = transition_interface(model, transition).input_places; + let outputs = transition_interface(model, transition).output_places; + + outputs + .iter() + .map(|output| Contribution { + name: transition + .clone() + .snoc(name_seg("ToOutput")) + .snoc(output.clone().only().unwrap()), + monomial: inputs.clone(), + parameter: MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerStock { + flow: transition.clone(), + stock: output.clone(), + }, + }, + target: output.clone(), + }) + .collect() + } + }] } - } - } - } + }, + }, + }; - sys.normalize() + ODESemanticsBuilder { + variable_builders, + contribution_builders: vec![transition_inputs, transition_outputs], + } } } @@ -314,156 +424,265 @@ pub struct StockFlowMassActionAnalysis { pub pos_link_mor_type: TabMorType, /// Morphism type for negative links from stocks to flows. pub neg_link_mor_type: TabMorType, + /// Mass-conservation type. + pub mass_conservation_type: MassConservationType, } impl Default for StockFlowMassActionAnalysis { fn default() -> Self { - let stock_ob_type = TabObType::Basic(name("Object")); - let flow_mor_type = TabMorType::Hom(Box::new(stock_ob_type.clone())); + let ob_type = TabObType::Basic(name("Object")); Self { - stock_ob_type, - flow_mor_type, + stock_ob_type: ob_type.clone(), + flow_mor_type: TabMorType::Hom(Box::new(ob_type.clone())), pos_link_mor_type: TabMorType::Basic(name("Link")), neg_link_mor_type: TabMorType::Basic(name("NegativeLink")), + mass_conservation_type: MassConservationType::Balanced, } } } -impl StockFlowMassActionAnalysis { - /// Creates a mass-action system with symbolic rate coefficients. - pub fn build_system( +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for StockFlowMassActionAnalysis +{ + fn build_semantics( &self, - model: &DiscreteTabModel, - mass_conservation_type: MassConservationType, - ) -> PolynomialSystem, i8> { - let terms: Vec<_> = self.flow_monomials(model).into_iter().collect(); - - let mut sys = PolynomialSystem::new(); - for ob in model.ob_generators_with_type(&self.stock_ob_type) { - sys.add_term(ob, Polynomial::zero()); - } - for (flow, term) in terms { - let dom = model.mor_generator_dom(&flow).unwrap_basic(); - let cod = model.mor_generator_cod(&flow).unwrap_basic(); - match mass_conservation_type { + ) -> ODESemanticsBuilder< + ::ModelType, + ::ParameterType, + > { + let variable_builders = vec![ODEVariableBuilder::Object { + ob_type: StockFlowMassActionAnalysis::default().stock_ob_type, + }]; + + let flow_input = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Morphism { + mor_types_and_signs: vec![( + StockFlowMassActionAnalysis::default().flow_mor_type, + ContributionSign::Negative, + )], + mor_contributions: match self.mass_conservation_type { MassConservationType::Balanced => { - let param = Parameter::generator(FlowParameter::Balanced { transition: flow }); - let term: Polynomial<_, _, _> = [(param, term.clone())].into_iter().collect(); - sys.add_term(dom, -term.clone()); - sys.add_term(cod, term); + vec![{ + |flow, model| { + let flow_interface = flow_interface(model, flow); + let dom = flow_interface.input_stock; + // N.B. We completely ignore negative links. + let mut term = flow_interface.input_pos_link_doms; + term.push(dom.clone()); + + vec![Contribution { + name: flow + .clone() + .snoc(name_seg("ToInput")) + .snoc(dom.clone().only().unwrap()), + monomial: term, + parameter: MassActionParameter::Balanced { flow: flow.clone() }, + target: dom.clone(), + }] + } + }] } MassConservationType::Unbalanced(_) => { - let dom_param = Parameter::generator(FlowParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { transition: flow.clone() }, - }); - let cod_param = Parameter::generator(FlowParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerTransition { transition: flow }, - }); - let dom_term: Polynomial<_, _, _> = - [(dom_param, term.clone())].into_iter().collect(); - let cod_term: Polynomial<_, _, _> = [(cod_param, term)].into_iter().collect(); - sys.add_term(dom, -dom_term); - sys.add_term(cod, cod_term); + vec![{ + |flow, model| { + let flow_interface = flow_interface(model, flow); + let dom = flow_interface.input_stock; + // N.B. We completely ignore negative links. + let mut term = flow_interface.input_pos_link_doms; + term.push(dom.clone()); + + vec![Contribution { + name: flow + .clone() + .snoc(name_seg("ToInput")) + .snoc(dom.clone().only().unwrap()), + monomial: term, + parameter: MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerFlow { flow: flow.clone() }, + }, + target: dom.clone(), + }] + } + }] } - } - } - sys - } + }, + }; - /// Constructs a monomial for each flow in the model. - pub(super) fn flow_monomials( - &self, - model: &DiscreteTabModel, - ) -> HashMap> { - let mut terms: HashMap<_, _> = model - .mor_generators_with_type(&self.flow_mor_type) - .map(|flow| { - let dom = model.mor_generator_dom(&flow).unwrap_basic(); - (flow, Monomial::generator(dom)) - }) - .collect(); - - let mut multiply_for_link = |link: QualifiedName, exponent: i8| { - let dom = model.mor_generator_dom(&link).unwrap_basic(); - let path = model.mor_generator_cod(&link).unwrap_tabulated(); - let Some(TabEdge::Basic(cod)) = path.only() else { - panic!("Codomain of link should be basic morphism"); - }; - if let Some(term) = terms.get_mut(&cod) { - let mon: Monomial<_, i8> = [(dom, exponent)].into_iter().collect(); - *term = std::mem::take(term) * mon; - } else { - panic!("Codomain of link does not belong to model"); - }; + let flow_output = ODEContributionBuilder::< + ::ModelType, + ::ParameterType, + >::Morphism { + mor_types_and_signs: vec![( + StockFlowMassActionAnalysis::default().flow_mor_type, + ContributionSign::Positive, + )], + mor_contributions: match self.mass_conservation_type { + MassConservationType::Balanced => { + vec![{ + |flow, model| { + let flow_interface = flow_interface(model, flow); + let dom = flow_interface.input_stock; + let cod = flow_interface.output_stock; + // N.B. We completely ignore negative links. + let mut term = flow_interface.input_pos_link_doms; + term.push(dom.clone()); + + vec![Contribution { + name: flow + .clone() + .snoc(name_seg("ToOutput")) + .snoc(cod.clone().only().unwrap()), + monomial: term, + parameter: MassActionParameter::Balanced { flow: flow.clone() }, + target: cod.clone(), + }] + } + }] + } + MassConservationType::Unbalanced(_) => { + vec![{ + |flow, model| { + let flow_interface = flow_interface(model, flow); + let dom = flow_interface.input_stock; + let cod = flow_interface.output_stock; + // N.B. We completely ignore negative links. + let mut term = flow_interface.input_pos_link_doms; + term.push(dom.clone()); + + vec![Contribution { + name: flow + .clone() + .snoc(name_seg("ToOutput")) + .snoc(cod.clone().only().unwrap()), + monomial: term, + parameter: MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerFlow { flow: flow.clone() }, + }, + target: cod.clone(), + }] + } + }] + } + }, }; - for link in model.mor_generators_with_type(&self.pos_link_mor_type) { - multiply_for_link(link, 1); + ODESemanticsBuilder { + variable_builders, + contribution_builders: vec![flow_input, flow_output], } - for link in model.mor_generators_with_type(&self.neg_link_mor_type) { - multiply_for_link(link, -1); - } - - terms } } -/// Substitutes numerical rate coefficients into a symbolic mass-action system. -pub fn extend_mass_action_scalars( - sys: PolynomialSystem, i8>, - data: &MassActionProblemData, -) -> PolynomialSystem { - let sys = sys.extend_scalars(|poly| { - poly.eval(|flow| match flow { - FlowParameter::Balanced { transition } => { - data.transition_rates.get(transition).cloned().unwrap_or_default() - } - FlowParameter::Unbalanced { direction, parameter } => match (direction, parameter) { - (Direction::IncomingFlow, RateParameter::PerTransition { transition }) => { - data.transition_production_rates.get(transition).cloned().unwrap_or_default() - } - (Direction::OutgoingFlow, RateParameter::PerTransition { transition }) => { - data.transition_consumption_rates.get(transition).cloned().unwrap_or_default() - } - (Direction::IncomingFlow, RateParameter::PerPlace { transition, place }) => data - .place_production_rates - .get(transition) - .and_then(|rate| rate.get(place)) - .copied() - .unwrap_or_default(), - (Direction::OutgoingFlow, RateParameter::PerPlace { transition, place }) => data - .place_consumption_rates - .get(transition) - .and_then(|rate| rate.get(place)) - .copied() - .unwrap_or_default(), - }, - }) - }); +/// Data defining an unbalanced mass-action ODE problem for a model. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr( + feature = "serde-wasm", + tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) +)] +pub struct MassActionProblemData { + /// Whether or not mass is conserved. + #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] + pub mass_conservation_type: MassConservationType, + + /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), + /// for the balanced per transition case. + /// N.B. This is renamed to "rates" in catlog-wasm for backwards compatibility. + #[cfg_attr(feature = "serde", serde(rename = "rates"))] + transition_rates: HashMap, + + /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), + /// for the unbalanced per transition case. + #[cfg_attr(feature = "serde", serde(rename = "transitionConsumptionRates"))] + transition_consumption_rates: HashMap, + + /// Map from morphism IDs to production rate coefficients (nonnegative reals), + /// for the unbalanced per transition case. + #[cfg_attr(feature = "serde", serde(rename = "transitionProductionRates"))] + transition_production_rates: HashMap, - sys.normalize() + /// Map from morphism IDs to (map from input objects to consumption rate coefficients), + /// for the unbalanced per place case (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "placeConsumptionRates"))] + place_consumption_rates: HashMap>, + + /// Map from morphism IDs to (map from output objects to production rate coefficients), + /// for the unbalanced per place case (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "placeProductionRates"))] + place_production_rates: HashMap>, + + /// Map from object IDs to initial values (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] + pub initial_values: HashMap, + + /// Duration of simulation. + pub duration: f32, } -/// Builds the numerical ODE analysis for a mass-action system whose scalars have been substituted. -pub fn into_mass_action_analysis( - sys: PolynomialSystem, - data: MassActionProblemData, -) -> ODEAnalysis> { - let ob_index: IndexMap<_, _> = - sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); - let n = ob_index.len(); +impl ODESemanticsProblemData for MassActionProblemData { + fn initial_values(&self) -> HashMap { + self.initial_values.clone() + } - let initial_values = ob_index - .keys() - .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); + fn duration(&self) -> f32 { + self.duration + } - let num_sys = sys.to_numerical(); - let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); + fn extend_scalars( + &self, + sys: PolynomialSystem, i8>, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|flow| match flow { + MassActionParameter::Balanced { flow: transition } => { + self.transition_rates.get(transition).cloned().unwrap_or_default() + } + MassActionParameter::Unbalanced { direction, parameter } => { + match (direction, parameter) { + (Direction::IncomingFlow, RateParameter::PerFlow { flow: transition }) => { + self.transition_production_rates + .get(transition) + .cloned() + .unwrap_or_default() + } + (Direction::OutgoingFlow, RateParameter::PerFlow { flow: transition }) => { + self.transition_consumption_rates + .get(transition) + .cloned() + .unwrap_or_default() + } + ( + Direction::IncomingFlow, + RateParameter::PerStock { flow: transition, stock: place }, + ) => self + .place_production_rates + .get(transition) + .and_then(|rate| rate.get(place)) + .copied() + .unwrap_or_default(), + ( + Direction::OutgoingFlow, + RateParameter::PerStock { flow: transition, stock: place }, + ) => self + .place_consumption_rates + .get(transition) + .and_then(|rate| rate.get(place)) + .copied() + .unwrap_or_default(), + } + } + }) + }); - ODEAnalysis::new(problem, ob_index) + sys.normalize() + } } #[cfg(test)] @@ -482,8 +701,7 @@ mod tests { fn balanced_stock_flow() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let sys = StockFlowMassActionAnalysis::default() - .build_system(&model, analyses::ode::MassConservationType::Balanced); + let sys = StockFlowMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" dx = -f x y dy = f x y @@ -495,12 +713,13 @@ mod tests { fn unbalanced_stock_flow() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let sys = StockFlowMassActionAnalysis::default().build_system( - &model, - analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerTransition, + let sys = StockFlowMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerFlow, ), - ); + ..StockFlowMassActionAnalysis::default() + } + .build_system(&model); let expected = expect!([r#" dx = -Outgoing(f) x y dy = Incoming(f) x y @@ -511,35 +730,38 @@ mod tests { // Tests for signed stock-flow diagrams. These all use the negative_backwards_link() // model, which has a single flow x==f=>y and a single negative link y->f. - #[test] - fn balanced_signed_stock_flow() { - let th = Rc::new(th_category_signed_links()); - let model = negative_backward_link(th); - let sys = StockFlowMassActionAnalysis::default() - .build_system(&model, analyses::ode::MassConservationType::Balanced); - let expected = expect!([r#" - dx = -f x y^{-1} - dy = f x y^{-1} - "#]); - expected.assert_eq(&sys.to_string()); - } - - #[test] - fn unbalanced_signed_stock_flow() { - let th = Rc::new(th_category_signed_links()); - let model = negative_backward_link(th); - let sys = StockFlowMassActionAnalysis::default().build_system( - &model, - analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerTransition, - ), - ); - let expected = expect!([r#" - dx = -Outgoing(f) x y^{-1} - dy = Incoming(f) x y^{-1} - "#]); - expected.assert_eq(&sys.to_string()); - } + // N.B. These tests are currently disabled, because they require a theory of *rational*, + // not merely polynomial, ODE systems. + + // #[test] + // fn balanced_signed_stock_flow() { + // let th = Rc::new(th_category_signed_links()); + // let model = negative_backward_link(th); + // let sys = StockFlowMassActionAnalysis::default() + // .build_system(&model, analyses::ode::MassConservationType::Balanced); + // let expected = expect!([r#" + // dx = -f x y^{-1} + // dy = f x y^{-1} + // "#]); + // expected.assert_eq(&sys.to_string()); + // } + + // #[test] + // fn unbalanced_signed_stock_flow() { + // let th = Rc::new(th_category_signed_links()); + // let model = negative_backward_link(th); + // let sys = StockFlowMassActionAnalysis::default().build_system( + // &model, + // analyses::ode::MassConservationType::Unbalanced( + // analyses::ode::RateGranularity::PerFlow, + // ), + // ); + // let expected = expect!([r#" + // dx = -Outgoing(f) x y^{-1} + // dy = Incoming(f) x y^{-1} + // "#]); + // expected.assert_eq(&sys.to_string()); + // } // Tests for Petri nets. These all use the catalyzed_reaction() model, which // has a single transition [x,c]-->f-->[y,c]. @@ -548,8 +770,7 @@ mod tests { fn balanced_petri() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let sys = PetriNetMassActionAnalysis::default() - .build_system(&model, analyses::ode::MassConservationType::Balanced); + let sys = PetriNetMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" dx = -f c x dy = f c x @@ -562,12 +783,13 @@ mod tests { fn unbalanced_petri_per_transition() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let sys = PetriNetMassActionAnalysis::default().build_system( - &model, - analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerTransition, + let sys = PetriNetMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerFlow, ), - ); + ..PetriNetMassActionAnalysis::default() + } + .build_system(&model); let expected = expect!([r#" dx = -Outgoing(f) c x dy = Incoming(f) c x @@ -580,12 +802,13 @@ mod tests { fn unbalanced_petri_per_place() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let sys = PetriNetMassActionAnalysis::default().build_system( - &model, - analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerPlace, + let sys = PetriNetMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerStock, ), - ); + ..PetriNetMassActionAnalysis::default() + } + .build_system(&model); let expected = expect!([r#" dx = -(x->[f]) c x dy = ([f]->y) c x @@ -600,12 +823,13 @@ mod tests { fn to_latex() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let sys = StockFlowMassActionAnalysis::default().build_system( - &model, - analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerTransition, + let sys = StockFlowMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerFlow, ), - ); + ..StockFlowMassActionAnalysis::default() + } + .build_system(&model); let expected = vec![ LatexEquation { lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), diff --git a/packages/catlog/src/stdlib/analyses/ode/mod.rs b/packages/catlog/src/stdlib/analyses/ode/mod.rs index 4c9b2a862..c5ce791d2 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mod.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mod.rs @@ -73,12 +73,12 @@ pub mod kuramoto; pub mod linear_ode; pub mod lotka_volterra; pub mod mass_action; +pub mod ode_semantics; pub mod polynomial_ode; -pub mod signed_coefficients; pub use kuramoto::*; pub use linear_ode::*; pub use lotka_volterra::*; pub use mass_action::*; +pub use ode_semantics::*; pub use polynomial_ode::*; -pub use signed_coefficients::*; diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs new file mode 100644 index 000000000..ca2d40fcc --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -0,0 +1,341 @@ +//! Analyses for different ODE semantics on models. +//! +//! Following inspiration from schema migration, we define the data of an ODE semantics on +//! models in a theory to be a migration into the theory of multicategories (more specifically, +//! [`th_polynomial_ode_system()`]). We then simply use the "canonical" interpretation of +//! multicategories as systems of polynomial ODEs as implemented in [`ode::polynomial_ode`] +//! (and see there also for documentation on this interpretation of models as systems of ODEs). +//! +//! That is, we take some `model: T` where `T: DblModelForODESemantics`, and from this use +//! `ODESemanticsAnalysis::build_semantics()` to build `ode_model: ModalDblModel` (to be +//! understood as a model for [`th_polynomial_ode_system()`]), and finally use +//! [`ode::polynomial_ode`] to build `system: PolynomialSystem, i8>` +//! where `P: ODEParameterType`. Finally, for an actual front-end analysis, we use +//! `ODESemanticsProblemData::extend_scalars()` and `ODESemanticsProblemData::build_analysis()` +//! to construct `analysis: ODEAnalysis>`, which we can feed into +//! the ODE solver. +//! +//! To implement a new ODE semantics for models in some theory, one essentially needs to create +//! an empty struct and implement `ODESemantics`, and then follow the compiler. +//! +//! [`th_polynomial_ode_system()`]: crate::stdlib::theories +//! [`ode::polynomial_ode`]: crate::stdlib::analyses::ode::polynomial_ode + +use indexmap::IndexMap; +use nalgebra::DVector; +use std::{collections::HashMap, fmt, rc::Rc}; + +use crate::{ + dbl::{ + modal::List, + model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, + theory::{NonUnital, Unital}, + }, + one::FgCategory, + simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, + stdlib::{ + analyses::ode::{ODEAnalysis, Parameter, PolynomialODEAnalysis}, + th_signed_polynomial_ode_system, + }, + zero::QualifiedName, +}; + +/// The trait for an ODE semantics on models. +pub trait ODESemantics { + /// The type of the model for which these ODE semantics are intended. + type ModelType: DblModelForODESemantics; + /// The type of the parameters associated to each contribution in the multicategory + /// built from the model. The "default" value for this would be `QualifiedName`, but + /// it can be useful to have a more descriptive type. For example, we might wish for + /// certain parameters to be identified with one another, or to be rendered differently + /// in debug/LaTeX output. An instructive example of this is `LotkaVolterraParameter`; + /// a more complicated example is `MassActionParameter`. + type ParameterType: ODEParameterType; + /// The data describing the things that the ODE semantics "cares about". (See the + /// documentation for `ODESemanticsAnalysis`). + type AnalysisType: ODESemanticsAnalysis; + /// The data describing how to turn the algebraic system of equations into a simulation, + /// including e.g. which values that appear in the front-end analysis correspond to + /// which parameters within the equations. + type ProblemDataType: ODESemanticsProblemData; +} + +/// The models for which we support ODE semantics need to be sufficiently nice, though +/// these bounds are not particularly restrictive. +pub trait DblModelForODESemantics: + FgCategory + MutDblModel + Clone +{ +} + +impl DblModelForODESemantics for DiscreteDblModel {} +impl DblModelForODESemantics for DiscreteTabModel {} +impl DblModelForODESemantics for ModalDblModel {} +impl DblModelForODESemantics for ModalDblModel {} + +/// The type of the parameters in the ODE system need to be sufficiently nice, though +/// (again) these bounds are not particularly restrictive. +pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} + +/// This trait is where we give the actual functions for building the data that +/// `ode::polynomial_ode::build_system_from_ode_semantics()` needs in order to construct +/// the multicategory. The implementation of `build_semantics()` is where the actual +/// migration (i.e. the actual ODE semantics) is specified, but `build_system()` can +/// essentially always use the default implementation given below. +/// +/// Note that the type that implements this trait is also where you are expected to state +/// everything that your semantics "cares about". For example, the expected minimum is to +/// give the values of `ObType` and `MorType` that you want to distinguish between and +/// iterate over. It can also hold any extra data upon which your semantics can depend +/// (see e.g. `ode::mass_action::PetriNetMassActionAnalysis`, which contains the data of +/// some `MassConservationType`, whose value is fundamental in constructing the semantics). +/// However, this is left to the user: the type checker will not enforce any of these extras. +pub trait ODESemanticsAnalysis: Default { + /// Construct the data required by `ode::polynomial_ode::build_system_from_ode_semantics()` + /// to actually build the multicategory. + fn build_semantics(&self) -> ODESemanticsBuilder; + + // TODO: SWITCH THIS AROUND! i.e. from here we should EXPOSE add_contribution() functions + // and then e.g. lotka_volterra.rs should USE them (we pop out a new blank ODESemantics + // and lotka_volterra populates it) + /// Construct the polynomial system from the `ODESemanticsBuilder`. This default + /// implementation should hopefully essentially always be the desired one. + fn build_system(&self, model: &T) -> PolynomialSystem, i8> { + build_system_from_ode_semantics::(model, self.build_semantics()) + } +} + +/// The data required by `ode::polynomial_ode::build_system_from_ode_semantics()` consists of +/// information on how to construct *variables* (objects) and *contributions* (multimorphisms). +pub struct ODESemanticsBuilder { + /// The list of terms of `T::ObType` to iterate over when constructing variables in the + /// ODE system. + pub variable_builders: Vec>, + /// The list of terms of `T::ObType` and of `T::MorType` to iterate over when constructing + /// contributions in the ODE system, along with the corresponding migrations. + pub contribution_builders: Vec>, +} + +/// The type that describes how to construct *variables* in the ODE system. +pub enum ODEVariableBuilder { + /// Construct variables from *objects* in the original model. + Object { + /// The type of objects in the original model to use to construct variables. + /// In short, this is used in `ode::polynomial_ode` in the following way: + /// ```ignore + /// for ob in model.ob_generators_with_type(&self.variable_ob_type) { + /// sys.add_term(ob, Polynomial::zero()); + /// } + /// ``` + ob_type: T::ObType, + }, + // N.B. Constructing variables from *morphisms* in the original model is not currently + // supported, but would be useful for e.g. "span migration", where flows x--[f]->y in a stock-flow + // diagram are viewed as spans x<-f->y and so a new apex variable f needs to be created. +} + +/// The type that describes how to construct *contributions* in the ODE system. +pub enum ODEContributionBuilder { + /// Construct contributions from *variables* in the original model. + Object { + /// The type(s) of objects in the original model to use to construct variables. + /// Analogous to `ODEVariableBuilder::Object`, this is used to iterate over in + /// `ode::polynomial_ode`. The only extra data here is that of a term of type + /// `ContributionSign`, which happens to be a convenient way of reducing duplication + /// in the existing ODE semantics. For example, in all current ODE semantics on + /// CLDs, the migration defined on positive links and the one on negative links are + /// identical in terms of their monomial, target, and parameter, but differ in the + /// *sign* of the contribution. However, this is purely a convention of convenience, + /// i.e. there is no good mathematical reason to put this data here instead of inside + /// `ob_contributions`. Indeed, at some point it might be more sensible to move it there. + ob_types_and_signs: Vec<(T::ObType, ContributionSign)>, + /// A list of contributions, as described in `Contribution`. + ob_contributions: Vec Vec>>, + }, + /// Construct contributions from *morphisms* in the original model. + Morphism { + /// Analogous to `Object.ob_types_and_signs`, but for morphisms types. + mor_types_and_signs: Vec<(T::MorType, ContributionSign)>, + /// A list of contributions, as described in `Contribution`. + mor_contributions: Vec Vec>>, + }, +} + +/// A contribution to the ODE system consists of all the data that `ModalDblModel::add_mor()` +/// requires to create a multimorphism. +#[derive(Clone)] +pub struct Contribution { + /// The name of the multimorphism. + pub name: QualifiedName, + /// The source of the multimorphism (a list of objects), to be interpreted + /// as the monomial given by the product of all the list elements. + pub monomial: Vec, + /// The parameter (coefficient) to be associated with this contribution. + pub parameter: P, + /// The target of the multimorphism, to be interpreted as the variable whose + /// first derivative is affected by the monomial. + pub target: QualifiedName, +} + +/// The sign of the contribution, since we work in *signed* multicategories. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +pub enum ContributionSign { + /// Positive contribution: (d/dt)y -= x. + Positive, + /// Negative contribution: (d/dt)y += x. + Negative, +} + +/// The trait describing how to turn the formal system of ODEs into a numerical problem, to be +/// solved by an ODE solver and presented to the front-end. At minimum, such data must contain +/// initial values for variables and the intended duration of simulation, as well as the method +/// for converting the parameters (which are of type `ODEParameterType`) into floats. +// REQUEST | If you look at a struct that implements this trait (such as `LotkaVolterraProblemData`), +// FOR | there are a lot of serde statements going on. Should I be able to just move them +// FEEDBACK | (that is, those that come *before* the struct) here and have things all work? I'm still +// _________/ a bit intimidated by all these `crg_attr(feature = "serde")` bits. +// +// #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +// #[cfg_attr(feature = "serde-wasm", derive(Tsify))] +// #[cfg_attr( +// feature = "serde-wasm", +// tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) +// )] +pub trait ODESemanticsProblemData { + // REQUEST | The two getters (`initial_values()` and `duration()`) are annoying boilerplate to + // FOR | ask to be implemented. Is there a nice way to get rid of them here? Without them, + // FEEDBACK | the call to `self.initial_values` in `build_analysis()` fails because there is no + // _________/ way of knowing whether a struct implementing this trait actually has those fields. + /// Map from object IDs to initial values (nonnegative reals). + fn initial_values(&self) -> HashMap; + /// Duration of simulation. + fn duration(&self) -> f32; + + /// How to convert the formal parameters of type `ODEParameterType` into floats using values that + /// will eventually be filled in by the user from the front-end. + fn extend_scalars( + &self, + sys: PolynomialSystem, i8>, + ) -> PolynomialSystem; + + /// Converting the polynomial system into a system ready for use in numerical solvers. The default + /// implementation here should essentially always be the desired one. + fn build_analysis( + &self, + sys: PolynomialSystem, + ) -> ODEAnalysis> { + let ob_index: IndexMap<_, _> = + sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); + let n = ob_index.len(); + + let initial_values = ob_index + .keys() + .map(|ob| self.initial_values().get(ob).copied().unwrap_or_default()); + let x0 = DVector::from_iterator(n, initial_values); + + let num_sys = sys.to_numerical(); + let problem = ODEProblem::new(num_sys, x0).end_time(self.duration()); + + ODEAnalysis::new(problem, ob_index) + } +} + +/// The main function of this module: taking the data of an `ODESemanticsBuilder` +/// and constructing a `PolynomialSystem` (with parameters of type `P`). We first construct +/// `ode_model: ModalDblModel` in the theory of signed polynomial ODE systems, +/// along with a hash map of parameters associated to names. This data is precisely what we +/// need to then simply call `PolynomialODEAnalysis::default().build_system_custom_parameters` +/// to build the desired `PolynomialSystem`. +pub fn build_system_from_ode_semantics( + model: &T, + ode_semantics: ODESemanticsBuilder, +) -> PolynomialSystem, i8> +where + T: DblModelForODESemantics, + P: ODEParameterType, +{ + let ode_theory = Rc::new(th_signed_polynomial_ode_system()); + let mut ode_model = ModalDblModel::new(ode_theory); + + let ode_analysis = PolynomialODEAnalysis::default(); + let ode_ob_type = ode_analysis.variable_ob_type; + let ode_pos_cont_type = ode_analysis.positive_contribution_mor_type; + let ode_neg_cont_type = ode_analysis.negative_contribution_mor_type; + + let mut associated_parameters: HashMap = HashMap::new(); + + for var_build in ode_semantics.variable_builders { + let ODEVariableBuilder::Object { ob_type } = var_build; + for ob in model.ob_generators_with_type(&ob_type) { + ode_model.add_ob(ob, ode_ob_type.clone()); + } + } + + let apply_contribution = { + |contribution: Contribution

, + sign: ContributionSign, + associated_parameters: &mut HashMap, + ode_model: &mut ModalDblModel| { + associated_parameters.insert(contribution.name.clone(), contribution.parameter); + ode_model.add_mor( + contribution.name, + ModalOb::List( + List::Symmetric, + contribution + .monomial + .iter() + .map(|var| ModalOb::Generator(var.clone())) + .collect(), + ), + ModalOb::Generator(contribution.target), + match sign { + ContributionSign::Positive => ode_pos_cont_type.clone(), + ContributionSign::Negative => ode_neg_cont_type.clone(), + }, + ) + } + }; + + // REQUEST | The below is the most naive way of doing this, but it involves a *lot* of nested + // FOR | loops. Is there a nicer way of doing this? Note that both arms of the `match` + // FEEDBACK | are essentially identical, differing only in their use of `ob_generators_with_type` + // _________/ versus `mor_generators_with_type`. + for cont_build in ode_semantics.contribution_builders { + match cont_build { + ODEContributionBuilder::Object { ob_types_and_signs, ob_contributions } => { + for (ob_type, sign) in ob_types_and_signs { + for ob in model.ob_generators_with_type(&ob_type) { + for contribution in ob_contributions.clone() { + for contribution in contribution(&ob, model) { + apply_contribution( + contribution.clone(), + sign, + &mut associated_parameters, + &mut ode_model, + ) + } + } + } + } + } + ODEContributionBuilder::Morphism { mor_types_and_signs, mor_contributions } => { + for (mor_type, sign) in mor_types_and_signs { + for mor in model.mor_generators_with_type(&mor_type) { + for contribution in mor_contributions.clone() { + for contribution in contribution(&mor, model) { + apply_contribution( + contribution.clone(), + sign, + &mut associated_parameters, + &mut ode_model, + ) + } + } + } + } + } + } + } + + PolynomialODEAnalysis::default() + .build_system_custom_parameters(&ode_model, associated_parameters) +} diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index 0b9d49f9f..d1fcfa08e 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -1,5 +1,17 @@ //! ODE analysis of models of the logic of systems of polynomial ODEs. -use std::collections::HashMap; +//! +//! This is used for the the simulation and equations analyses for models in the theory of +//! systems of polynomial ODEs [`th_polynomial_ode_system()`]. However, *all* ODE analyses +//! now factor through this by implementing [`ode::ode_semantics::ODESemantics`]; for further +//! documentation, see there. +//! +//! The interpretation of multicategories as systems of polynomial ODEs is explained in [RFC-0001]. +//! +//! [`th_polynomial_ode_system()`]: crate::stdlib::theories +//! [`ode::ode_semantics::ODESemantics`]: crate::stdlib::analyses::ode::ode_semantics::ODESemantics +//! [RFC-0001]: https://next.catcolab.org/rfc/0001 + +use std::{collections::HashMap, fmt}; use indexmap::IndexMap; use nalgebra::DVector; @@ -64,11 +76,37 @@ impl Default for PolynomialODEAnalysis { } impl PolynomialODEAnalysis { - /// Creates a system with symbolic coefficients. + /// Creates a `PolynomialSystem` with symbolic coefficients of type `QualifiedName`. pub fn build_system( &self, model: &ModalDblModel, ) -> PolynomialSystem, i8> { + // The default is to build a system whose parameters are in bijective correspondence + // with morphisms, given by using the `QualifiedName` of the morphism as the parameter + // generator. We thus build the graph of the identity function to pass as the HashMap + // of associated parameters. + let mut associated_parameters: HashMap = HashMap::new(); + for mor in model.mor_generators_with_type(&self.positive_contribution_mor_type) { + associated_parameters.insert(mor.clone(), mor.clone()); + } + for mor in model.mor_generators_with_type(&self.negative_contribution_mor_type) { + associated_parameters.insert(mor.clone(), mor.clone()); + } + + self.build_system_custom_parameters::(model, associated_parameters) + } + + /// Creates a `PolynomialSystem` with symbolic coefficients of some generic type. + /// + /// When constructing a system as a derived model from another model (as in e.g. `mass_action`), + /// it is not necessarily the case that each morphism will give rise to a unique parameter. This + /// function allows for the construction of a `PolynomialSystem<_ , Parameter, _>` using some + /// specified `HashMap` that describes how to associate parameters to morphisms. + pub fn build_system_custom_parameters( + &self, + model: &ModalDblModel, + associated_parameters: HashMap, + ) -> PolynomialSystem, i8> { let mut sys = PolynomialSystem::new(); // Create a variable for each object. @@ -76,17 +114,31 @@ impl PolynomialODEAnalysis { sys.add_term(ob, Polynomial::zero()); } + // Every morphism will give a term, i.e. a pair consisting of a monomial and a parameter. + // Although the *monomial* depends only on the input objects to the morphism, the *parameter* + // might be described by external data. For example, multiple morphisms might share the same + // parameter. + // + // This closure builds a term to add to the `PolynomialSystem` given a morphism and the + // hash map `associated_parameters`. let make_term = |mor: QualifiedName| { + // Find the inputs and output of the morphism. let (Some(ModalOb::List(_, inputs)), Some(output)) = (model.get_dom(&mor), model.get_cod(&mor)) else { return None; }; - let term: Monomial<_, _> = + // Construct the monomial given by the product of all of the inputs. + let monomial: Monomial<_, _> = inputs.iter().cloned().map(|ob| (ob.unwrap_generator(), 1)).collect(); - let term: Polynomial<_, _, _> = - [(Parameter::generator(mor), term.clone())].into_iter().collect(); + // Construct the term given by the monomial and the parameter from `associated_parameters`. + let term: Polynomial<_, _, _> = [( + Parameter::generator(associated_parameters.get(&mor).unwrap().clone()), + monomial.clone(), + )] + .into_iter() + .collect(); Some((output.clone().unwrap_generator(), term)) }; @@ -97,7 +149,6 @@ impl PolynomialODEAnalysis { sys.add_term(var, term); } } - // Add a monomial with negative sign for each negative contribution. for mor in model.mor_generators_with_type(&self.negative_contribution_mor_type) { if let Some((var, term)) = make_term(mor) { @@ -153,11 +204,11 @@ mod tests { tt, }; - // (Unsigned) Lotka–Volterra dynamics on a two-level model. + /// (Unsigned) Lotka-Volterra dynamics on a two-level model. #[test] - fn lotka_volterra_equations() { + fn unsigned_lotka_volterra_equations() { let th = Rc::new(th_polynomial_ode_system()); - let model = lotka_volterra_dynamics(th); + let model = unsigned_lotka_volterra_dynamics(th); let sys = PolynomialODEAnalysis::default().build_system(&model); let expected = expect!([r#" dA = A_growth A + BA_interaction A B @@ -167,31 +218,26 @@ mod tests { expected.assert_eq(&sys.to_string()); } - // (Unsigned) Lotka–Volterra dynamics on a two-level model with LaTeX. + /// Lotka-Volterra dynamics on a two-level model with LaTeX. #[test] fn lotka_volterra_equations_latex() { - let th = Rc::new(th_polynomial_ode_system()); - let model = lotka_volterra_dynamics(th); + let th = Rc::new(th_signed_polynomial_ode_system()); + let model = signed_lotka_volterra_dynamics(th); let sys = PolynomialODEAnalysis::default().build_system(&model); let expected = vec![ LatexEquation { lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} A".to_string(), - rhs: "A_growth \\cdot A + BA_interaction \\cdot A \\cdot B".to_string(), + rhs: "A_growth \\cdot A - BA_interaction \\cdot A \\cdot B".to_string(), }, LatexEquation { lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} B".to_string(), - rhs: "AB_interaction \\cdot A \\cdot B + B_growth \\cdot B + CB_interaction \\cdot B \\cdot C" - .to_string(), - }, - LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} C".to_string(), - rhs: "BC_interaction \\cdot B \\cdot C + C_growth \\cdot C".to_string(), + rhs: "AB_interaction \\cdot A \\cdot B + B_growth \\cdot B".to_string(), }, ]; assert_eq!(expected, sys.to_latex_equations()); } - // DoubleTT elaboration from text. + /// DoubleTT elaboration from text. #[test] fn ode_system_from_text() { let th = Rc::new(th_polynomial_ode_system()); diff --git a/packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs b/packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs deleted file mode 100644 index ce106e909..000000000 --- a/packages/catlog/src/stdlib/analyses/ode/signed_coefficients.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Helper module to build analyses based on signed coefficient matrices. - -use indexmap::IndexMap; -use nalgebra::DMatrix; -use num_traits::zero; - -use super::Parameter; -use crate::{ - dbl::model::FpDblModel, - zero::{QualifiedName, rig::Monomial}, -}; - -/// Builder for signed coefficient matrices and analyses based on them. -/// -/// Used to construct the [linear](Self::linear_ode_analysis) and -/// [Lotka-Volterra](Self::lotka_volterra_analysis) ODE analyses. -pub struct SignedCoefficientBuilder { - var_ob_type: ObType, - positive_mor_types: Vec, - negative_mor_types: Vec, -} - -impl SignedCoefficientBuilder { - /// Creates a new builder for the given object type. - pub fn new(var_ob_type: ObType) -> Self { - Self { - var_ob_type, - positive_mor_types: Vec::new(), - negative_mor_types: Vec::new(), - } - } - - /// Adds a morphism type defining a positive interaction between objects. - pub fn add_positive(mut self, mor_type: MorType) -> Self { - self.positive_mor_types.push(mor_type); - self - } - - /// Adds a morphism type defining a negative interaction between objects. - pub fn add_negative(mut self, mor_type: MorType) -> Self { - self.negative_mor_types.push(mor_type); - self - } - - /// Builds the matrix of symbolic coefficients for the given model. - /// - /// Returns the coefficient matrix along with an ordered map from object - /// generators to integer indices. - pub fn build_matrix( - &self, - model: &impl FpDblModel< - ObType = ObType, - MorType = MorType, - Ob = QualifiedName, - ObGen = QualifiedName, - MorGen = QualifiedName, - >, - ) -> (DMatrix>, IndexMap) { - let ob_index: IndexMap<_, _> = model - .ob_generators_with_type(&self.var_ob_type) - .enumerate() - .map(|(i, x)| (x, i)) - .collect(); - - let n = ob_index.len(); - let mut mat = DMatrix::from_element(n, n, zero()); - for mor_type in self.positive_mor_types.iter() { - for mor in model.mor_generators_with_type(mor_type) { - let i = *ob_index.get(&model.mor_generator_dom(&mor)).unwrap(); - let j = *ob_index.get(&model.mor_generator_cod(&mor)).unwrap(); - mat[(j, i)] += (1.0, Monomial::generator(mor)); - } - } - for mor_type in self.negative_mor_types.iter() { - for mor in model.mor_generators_with_type(mor_type) { - let i = *ob_index.get(&model.mor_generator_dom(&mor)).unwrap(); - let j = *ob_index.get(&model.mor_generator_cod(&mor)).unwrap(); - mat[(j, i)] += (-1.0, Monomial::generator(mor)); - } - } - - (mat, ob_index) - } -} diff --git a/packages/catlog/src/stdlib/analyses/petri.rs b/packages/catlog/src/stdlib/analyses/petri.rs index ec28171ca..f00fecaa8 100644 --- a/packages/catlog/src/stdlib/analyses/petri.rs +++ b/packages/catlog/src/stdlib/analyses/petri.rs @@ -1,21 +1,49 @@ //! Helpers for analyses on Petri nets. -use crate::dbl::model::{ModalDblModel, ModalOb, MutDblModel}; +use crate::dbl::model::{ModalDblModel, MutDblModel}; use crate::dbl::theory::Unital; use crate::zero::QualifiedName; +pub struct TransitionInterface { + pub input_places: Vec, + pub output_places: Vec, +} + +// TODO: Unfortunately, in the case of transition_interface, there is a further +// subtlety that isn't addressed by these considerations. The collect_product +// function only collects one level of operation application, as opposed to +// acting recursively. Thus, I'd say it's technically incorrect to unwrap +// generators from the lists returned. This point is a bit academic since in +// the notebook editor you couldn't construct such a model anyway, but it is +// perfectly valid in the text elaborator to write tensor [a, tensor [b, c]] +// and we shouldn't bomb on that. +// +// To do this safely, you should collect recursively rather than at one level; +// however, under the validation assumption, you are allowed (in fact +// encouraged) to panic if you encounter anything that is not an basic object +// or an application of tensor to a list. + /// Gets the inputs and outputs of a transition in a Petri net. pub fn transition_interface( model: &ModalDblModel, id: &QualifiedName, -) -> (Vec, Vec) { +) -> TransitionInterface { let inputs = model .get_dom(id) .and_then(|ob| ob.clone().collect_product(None)) - .unwrap_or_default(); + .unwrap_or_default() + .into_iter() + .map(|ob| ob.unwrap_generator()) + .collect(); let outputs = model .get_cod(id) .and_then(|ob| ob.clone().collect_product(None)) - .unwrap_or_default(); - (inputs, outputs) + .unwrap_or_default() + .into_iter() + .map(|ob| ob.unwrap_generator()) + .collect(); + TransitionInterface { + input_places: inputs, + output_places: outputs, + } } diff --git a/packages/catlog/src/stdlib/analyses/reachability.rs b/packages/catlog/src/stdlib/analyses/reachability.rs index 402b5dac5..8a7c91028 100644 --- a/packages/catlog/src/stdlib/analyses/reachability.rs +++ b/packages/catlog/src/stdlib/analyses/reachability.rs @@ -3,10 +3,10 @@ use itertools::Itertools; use std::collections::HashMap; -use crate::dbl::modal::model::{ModalDblModel, ModalOb}; +use crate::dbl::modal::model::ModalDblModel; use crate::dbl::theory::Unital; use crate::one::category::FgCategory; -use crate::stdlib::analyses::petri::transition_interface; +use crate::stdlib::analyses::petri::{TransitionInterface, transition_interface}; use crate::zero::QualifiedName; #[cfg(feature = "serde")] @@ -57,16 +57,14 @@ pub fn subreachability(m: &ModalDblModel, data: ReachabilityProblemData) for e in m.mor_generators() { let e_idx = *hom_inv.get(&e).unwrap(); - let (inputs, outputs) = transition_interface(m, &e); + let transition_interface: TransitionInterface = transition_interface(m, &e); + let inputs = transition_interface.input_places.clone(); + let outputs = transition_interface.output_places.clone(); for ob in inputs { - if let ModalOb::Generator(u) = ob { - i_mat[*ob_inv.get(&u).unwrap()][e_idx] += 1; - } + i_mat[*ob_inv.get(&ob).unwrap()][e_idx] += 1; } for ob in outputs { - if let ModalOb::Generator(u) = ob { - o_mat[*ob_inv.get(&u).unwrap()][e_idx] += 1; - } + o_mat[*ob_inv.get(&ob).unwrap()][e_idx] += 1; } } let (i_mat_, o_mat_) = (&i_mat, &o_mat); diff --git a/packages/catlog/src/stdlib/analyses/stochastic/mass_action.rs b/packages/catlog/src/stdlib/analyses/stochastic/mass_action.rs index b800f6ad0..dffdcd4a7 100644 --- a/packages/catlog/src/stdlib/analyses/stochastic/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/stochastic/mass_action.rs @@ -8,7 +8,10 @@ use std::collections::HashMap; use crate::{ dbl::{modal::*, model::FpDblModel, theory::Unital}, - stdlib::analyses::{ode::ODESolution, petri::transition_interface}, + stdlib::analyses::{ + ode::ODESolution, + petri::{TransitionInterface, transition_interface}, + }, zero::{QualifiedName, name}, }; @@ -114,20 +117,16 @@ impl PetriNetStochasticMassActionAnalysis { }; for mor in model.mor_generators_with_type(&self.transition_mor_type) { - let (inputs, outputs) = transition_interface(model, &mor); + let transition_interface: TransitionInterface = transition_interface(model, &mor); + let inputs = transition_interface.input_places.clone(); + let outputs = transition_interface.output_places.clone(); // 1. convert the inputs/outputs to sequences of counts let input_vec = ob_generators.iter().map(|id| { - inputs - .iter() - .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id)) - .count() as u32 + inputs.iter().filter(|&ob| matches!(ob, id2 if id2 == id)).count() as u32 }); let output_vec = ob_generators.iter().map(|id| { - outputs - .iter() - .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id)) - .count() as isize + outputs.iter().filter(|&ob| matches!(ob, id2 if id2 == id)).count() as isize }); // 2. output := output - input diff --git a/packages/catlog/src/stdlib/analyses/stock_flow.rs b/packages/catlog/src/stdlib/analyses/stock_flow.rs new file mode 100644 index 000000000..2cb83ee55 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/stock_flow.rs @@ -0,0 +1,46 @@ +//! Helpers for analyses on stock-flow diagrams. + +use crate::dbl::discrete_tabulator::DiscreteTabModel; +use crate::dbl::discrete_tabulator::TabEdge; +use crate::dbl::discrete_tabulator::TabMorType; +use crate::dbl::model::FpDblModel; +use crate::dbl::model::TabOb; +use crate::one::category::FgCategory; +use crate::zero::QualifiedName; +use crate::zero::name; + +pub struct FlowInterface { + pub input_stock: QualifiedName, + pub input_pos_link_doms: Vec, + pub output_stock: QualifiedName, +} + +/// Gets the inputs (including links) and output of a flow in a stock-flow diagram. +pub fn flow_interface(model: &DiscreteTabModel, flow: &QualifiedName) -> FlowInterface { + let dom = model.mor_generator_dom(flow).unwrap_basic(); + let cod = model.mor_generator_cod(flow).unwrap_basic(); + + let mut input_pos_link_doms: Vec = Vec::new(); + + // Iterate over positive links and add them to the interface if their codomain is the + // link in question. + for link in model.mor_generators_with_type(&TabMorType::Basic(name("Link"))) { + let dom = model.mor_generator_dom(&link); + let path = model.mor_generator_cod(&link).unwrap_tabulated(); + let Some(TabEdge::Basic(cod)) = path.only() else { + panic!("Codomain of link should be basic morphism"); + }; + if cod == *flow { + input_pos_link_doms.push(dom) + }; + } + + FlowInterface { + input_stock: dom, + input_pos_link_doms: input_pos_link_doms + .iter() + .map(|stock| stock.clone().unwrap_basic()) + .collect(), + output_stock: cod, + } +} diff --git a/packages/catlog/src/stdlib/models.rs b/packages/catlog/src/stdlib/models.rs index 7a784733e..3d97910b2 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -175,14 +175,17 @@ pub fn sir_petri(th: Rc>) -> ModalDblModel { model } -/// An example of Lotka–Volterra dynamics viewed as a non-unital theory for a symmetric multicategory. -pub fn lotka_volterra_dynamics(th: Rc>) -> ModalDblModel { +/// An example of (unsigned) Lotka-Volterra dynamics viewed as a non-unital theory for +/// a symmetric multicategory. +pub fn unsigned_lotka_volterra_dynamics( + th: Rc>, +) -> ModalDblModel { let ob_type = ModalObType::new(name("State")); let mor_type: ModalMorType = ModeApp::new(name("Contribution")).into(); let mut model = ModalDblModel::new(th); - // We're going to build a two-level predator-prey model, but where (in absence of signed - // arrows) all interactions have positive coefficients. + // A two-level predator-prey model, but where (in absence of signed arrows) all + // interactions have positive coefficients. let (a, b, c) = (name("A"), name("B"), name("C")); model.add_ob(a.clone(), ob_type.clone()); @@ -243,6 +246,54 @@ pub fn lotka_volterra_dynamics(th: Rc>) -> ModalDblMod model } +/// An example of Lotka-Volterra dynamics viewed as a non-unital theory for a symmetric multicategory. +pub fn signed_lotka_volterra_dynamics( + th: Rc>, +) -> ModalDblModel { + let ob_type = ModalObType::new(name("State")); + let pos_mor_type: ModalMorType = ModeApp::new(name("Contribution")).into(); + let neg_mor_type: ModalMorType = ModeApp::new(name("NegativeContribution")).into(); + + let mut model = ModalDblModel::new(th); + // We're going to build a simple predator-prey model. + let (a, b) = (name("A"), name("B")); + + model.add_ob(a.clone(), ob_type.clone()); + model.add_ob(b.clone(), ob_type.clone()); + // The growth terms, corresponding to + // dA/dt += g_A A + // dB/dt += g_B B + model.add_mor( + name("A_growth"), + ModalOb::List(List::Symmetric, vec![a.clone().into()]), + a.clone().into(), + pos_mor_type.clone(), + ); + model.add_mor( + name("B_growth"), + ModalOb::List(List::Symmetric, vec![b.clone().into()]), + b.clone().into(), + pos_mor_type.clone(), + ); + // The interaction terms, corresponding to + // dB/dt += k_AB AB + // dA/dt -= k_BA AB + model.add_mor( + name("AB_interaction"), + ModalOb::List(List::Symmetric, vec![a.clone().into(), b.clone().into()]), + b.clone().into(), + pos_mor_type.clone(), + ); + model.add_mor( + name("BA_interaction"), + ModalOb::List(List::Symmetric, vec![a.clone().into(), b.clone().into()]), + a.clone().into(), + neg_mor_type.clone(), + ); + + model +} + #[cfg(test)] mod tests { use super::super::theories::*; @@ -296,6 +347,6 @@ mod tests { #[test] fn polynomial_ode_systems() { let th = Rc::new(th_polynomial_ode_system()); - assert!(lotka_volterra_dynamics(th.clone()).validate().is_ok()); + assert!(unsigned_lotka_volterra_dynamics(th.clone()).validate().is_ok()); } } diff --git a/packages/catlog/src/stdlib/theories.rs b/packages/catlog/src/stdlib/theories.rs index a0a6e3275..0c1a13070 100644 --- a/packages/catlog/src/stdlib/theories.rs +++ b/packages/catlog/src/stdlib/theories.rs @@ -379,6 +379,7 @@ mod tests { assert!(th_sym_multicategory().validate().is_ok()); assert!(modal_th_power_system().validate().is_ok()); assert!(th_polynomial_ode_system().validate().is_ok()); + assert!(th_signed_polynomial_ode_system().validate().is_ok()); } #[test] diff --git a/packages/frontend/src/help/analysis/mass-action.mdx b/packages/frontend/src/help/analysis/mass-action.mdx index 68286d712..29c298088 100644 --- a/packages/frontend/src/help/analysis/mass-action.mdx +++ b/packages/frontend/src/help/analysis/mass-action.mdx @@ -7,12 +7,12 @@

Whether or not flows should preserve mass
Rate: $\mathbb{R}_{\geqslant0}$
*(Only if **Mass conservation** = `True`)* The rate coefficient ($r$) of the reaction
-
Rate granularity: `Per transition | Per place`
+
Rate granularity: `Per flow | Per stock`
*(Only if **Mass conservation** = `False`)* If flows can have multiple inputs/outputs (e.g. in the case of Petri nets) then rates can be given per flow or per individual input/output
Consumption: $\mathbb{R}_{\geqslant0}$
-
The consumption rate coefficient ($\kappa$), either per transition (flow) or per place (input/output) depending on **Mass conservation**
+
The consumption rate coefficient ($\kappa$), either per flow or per stock (input/output) depending on **Mass conservation**
Production: $\mathbb{R}_{\geqslant0}$
-
The production rate coefficient ($\rho$), either per transition (flow) or per place (input/output) depending on **Mass conservation**
+
The production rate coefficient ($\rho$), either per flow or per stock (input/output) depending on **Mass conservation**
Duration: $\mathbb{R}_{\geqslant0}$
The total duration of the simulation in units of time
diff --git a/packages/frontend/src/help/logics/petri-net.mdx b/packages/frontend/src/help/logics/petri-net.mdx index 5c652ca8f..3ac06d8ac 100644 --- a/packages/frontend/src/help/logics/petri-net.mdx +++ b/packages/frontend/src/help/logics/petri-net.mdx @@ -53,7 +53,7 @@ The rest of the analysis depends on whether **mass conservation** is checked as - $\dot{X}=r_T AB$ - $\dot{Y}=r_T AB$ -- If **mass conservation** is checked as _false_, and **rate granularity** is set to _per transition_, then a transition $A\xrightarrow{T}B$ between places $A$ and $B$ is interpreted as the equations $\{\dot{A}=-\kappa_T,\dot{B}=\rho_T\}$. Here $\kappa_T$ and $\rho_T$ are the **consumption** and **production** rate coefficients of the transition. +- If **mass conservation** is checked as _false_, and **rate granularity** is set to _per flow_, then a transition $A\xrightarrow{T}B$ between places $A$ and $B$ is interpreted as the equations $\{\dot{A}=-\kappa_T,\dot{B}=\rho_T\}$. Here $\kappa_T$ and $\rho_T$ are the **consumption** and **production** rate coefficients of the transition. A transition $[A,B]\xrightarrow{T}[X,Y]$ gives the equations - $\dot{A}=-\kappa_T AB$ @@ -61,7 +61,7 @@ The rest of the analysis depends on whether **mass conservation** is checked as - $\dot{X}=\rho_T AB$ - $\dot{Y}=\rho_T AB$ -- If **mass conservation** is checked as _false_, and **rate granularity** is set to _per place_, then a transition $A\xrightarrow{T}B$ between places $A$ and $B$ is interpreted as the equations $\{\dot{A}=-\kappa_T^A,\dot{B}=\rho_T^B\}$. Here $\kappa_T^A$ and $\rho_T^B$ are the **consumption** and **production** rate coefficients of the objects $A$ and $B$ with respect to the transition $T$. +- If **mass conservation** is checked as _false_, and **rate granularity** is set to _per stock_, then a transition $A\xrightarrow{T}B$ between places $A$ and $B$ is interpreted as the equations $\{\dot{A}=-\kappa_T^A,\dot{B}=\rho_T^B\}$. Here $\kappa_T^A$ and $\rho_T^B$ are the **consumption** and **production** rate coefficients of the objects $A$ and $B$ with respect to the transition $T$. A transition $[A,B]\xrightarrow{T}[X,Y]$ gives the equations - $\dot{A}=-\kappa_T^A AB$ diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 636daee0b..a5ebbead1 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,6 +1,8 @@ import { lazy } from "solid-js"; import type { + LCCEquationsData, + LotkaVolterraEquationsData, MassActionEquationsData, MorType, ObType, @@ -107,9 +109,9 @@ const Kuramoto = lazy(() => import("./analyses/kuramoto")); export function linearODE( options: Partial & { - simulate: Simulators.LinearODESimulator; + simulate: Simulators.LCCSimulator; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "linear-ode", name = "Linear ODE dynamics", @@ -122,7 +124,7 @@ export function linearODE( name, description, help, - component: (props) => , + component: (props) => , initialContent: () => ({ coefficients: {}, initialValues: {}, @@ -131,7 +133,32 @@ export function linearODE( }; } -const LinearODE = lazy(() => import("./analyses/linear_ode")); +const LCC = lazy(() => import("./analyses/linear_ode")); + +export function linearODEEquations( + options: Partial & { + getEquations: Simulators.LCCEquations; + }, +): ModelAnalysisMeta { + const { + id = "linear-ode-equations", + name = "Linear ODE equations", + description = "Display the symbolic linear ODE dynamics equations", + help = "linear-ode-equations", + ...otherOptions + } = options; + return { + id, + name, + description, + help, + component: (props) => , + initialContent: () => ({ + trivialData: true, + }), + }; +} +const LCCEquationsDisplay = lazy(() => import("./analyses/linear_ode_equations")); export function lotkaVolterra( options: Partial & { @@ -140,8 +167,8 @@ export function lotkaVolterra( ): ModelAnalysisMeta { const { id = "lotka-volterra", - name = "Lotka-Volterra dynamics", - description = "Simulate the system using a Lotka-Volterra ODE", + name = "Lotka–Volterra dynamics", + description = "Simulate the system using a Lotka–Volterra ODE", help = "lotka-volterra", simulate, } = options; @@ -162,6 +189,33 @@ export function lotkaVolterra( const LotkaVolterra = lazy(() => import("./analyses/lotka_volterra")); +export function lotkaVolterraEquations( + options: Partial & { + getEquations: Simulators.LotkaVolterraEquations; + }, +): ModelAnalysisMeta { + const { + id = "lotka-volterra-equations", + name = "Lotka–Volterra equations", + description = "Display the symbolic Lotka–Volterra dynamics equations", + help = "lotka-volterra-equations", + ...otherOptions + } = options; + return { + id, + name, + description, + help, + component: (props) => ( + + ), + initialContent: () => ({ + trivialData: true, + }), + }; +} +const LotkaVolterraEquationsDisplay = lazy(() => import("./analyses/lotka_volterra_equations")); + export function massAction( options: Partial & { ratesHaveGranularity: boolean; diff --git a/packages/frontend/src/stdlib/analyses/linear_ode.tsx b/packages/frontend/src/stdlib/analyses/linear_ode.tsx index 946a156ca..40e3cd7a4 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode.tsx @@ -4,20 +4,22 @@ import { createNumericalColumn, FixedTableEditor, Foldable, + ExpandableTable, + KatexDisplay, } from "catcolab-ui-components"; -import type { DblModel, LinearODEProblemData, QualifiedName } from "catlog-wasm"; +import type { LCCProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; -import { createModelODEPlot } from "./model_ode_plot"; -import type { LinearODESimulator } from "./simulator_types"; +import { createModelODEPlotWithEquations } from "./model_ode_plot"; +import type { LCCSimulator } from "./simulator_types"; import "./simulation.css"; -/** Analyze a model using LinearODE dynamics. */ -export default function LinearODE( - props: ModelAnalysisProps & { - simulate: LinearODESimulator; +/** Analyze a model using LCC dynamics. */ +export default function LCC( + props: ModelAnalysisProps & { + simulate: LCCSimulator; title?: string; }, ) { @@ -70,11 +72,14 @@ export default function LinearODE( }), ]; - const plotResult = createModelODEPlot( + const result = createModelODEPlotWithEquations( () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + (model) => props.simulate(model, props.content), ); + const plotResult = () => result()?.plotData; + const latexEquations = () => result()?.latexEquations ?? []; + return (
@@ -91,7 +96,20 @@ export default function LinearODE(
- + + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> + + + + ); } diff --git a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx new file mode 100644 index 000000000..73f0be0e0 --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx @@ -0,0 +1,36 @@ +import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; +import { LCCEquationsData } from "catlog-wasm"; +import type { ModelAnalysisProps } from "../../analysis"; +import { createModelODELatex } from "./model_ode_plot"; +import type { LCCEquations } from "./simulator_types"; + +import "./simulation.css"; + +/** Display the symbolic mass-action dynamics equations for a model. */ +export default function LCCEquationsDisplay( + props: ModelAnalysisProps & { + content: LCCEquationsData; + getEquations: LCCEquations; + title?: string; + }, +) { + const latexEquations = createModelODELatex( + () => props.liveModel.validatedModel(), + (model) => props.getEquations(model, props.content), + ); + + return ( +
+ + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> +
+ ); +} diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 9d6006800..062f28189 100644 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx @@ -4,12 +4,14 @@ import { createNumericalColumn, FixedTableEditor, Foldable, + ExpandableTable, + KatexDisplay, } from "catcolab-ui-components"; -import type { DblModel, LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; +import type { LotkaVolterraProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; -import { createModelODEPlot } from "./model_ode_plot"; +import { createModelODEPlotWithEquations } from "./model_ode_plot"; import type { LotkaVolterraSimulator } from "./simulator_types"; import "./simulation.css"; @@ -78,11 +80,14 @@ export default function LotkaVolterra( }), ]; - const plotResult = createModelODEPlot( + const result = createModelODEPlotWithEquations( () => props.liveModel.validatedModel(), - (model: DblModel) => props.simulate(model, props.content), + (model) => props.simulate(model, props.content), ); + const plotResult = () => result()?.plotData; + const latexEquations = () => result()?.latexEquations ?? []; + return (
@@ -99,7 +104,20 @@ export default function LotkaVolterra(
- + + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> + + + + ); } diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx new file mode 100644 index 000000000..dcb6271d4 --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx @@ -0,0 +1,36 @@ +import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; +import { LotkaVolterraEquationsData } from "catlog-wasm"; +import type { ModelAnalysisProps } from "../../analysis"; +import { createModelODELatex } from "./model_ode_plot"; +import type { LotkaVolterraEquations } from "./simulator_types"; + +import "./simulation.css"; + +/** Display the symbolic mass-action dynamics equations for a model. */ +export default function LotkaVolterraEquationsDisplay( + props: ModelAnalysisProps & { + content: LotkaVolterraEquationsData; + getEquations: LotkaVolterraEquations; + title?: string; + }, +) { + const latexEquations = createModelODELatex( + () => props.liveModel.validatedModel(), + (model) => props.getEquations(model, props.content), + ); + + return ( +
+ + }, + { cell: () => }, + { cell: (row) => }, + ]} + /> +
+ ); +} diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 6cfa1fe43..e7c5c72d8 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -160,7 +160,7 @@ export default function MassAction( }), ]; - // Secondly, the case MassConservationType = Unbalanced(PerTransition) + // Secondly, the case MassConservationType = Unbalanced(PerFlow) const morInputSchema: ColumnSchema[] = [ { contentType: "string", @@ -196,7 +196,7 @@ export default function MassAction( }), ]; - // Finally, the case MassConservationType = Unbalanced(PerPlace) + // Finally, the case MassConservationType = Unbalanced(PerStock) const morInputsSchema: ColumnSchema<[QualifiedName, QualifiedName]>[] = [ { contentType: "string", @@ -259,7 +259,7 @@ export default function MassAction( @@ -268,7 +268,7 @@ export default function MassAction( diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index b16365db0..0d9a04aac 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -32,7 +32,7 @@ export function MassActionConfigForm(props: { } else { content.massConservationType = { type: "Unbalanced", - granularity: "PerTransition", + granularity: "PerFlow", }; } }); @@ -41,7 +41,7 @@ export function MassActionConfigForm(props: { { props.changeConfig((content) => { if (content.massConservationType.type === "Unbalanced") { @@ -51,8 +51,8 @@ export function MassActionConfigForm(props: { }); }} > - - + + diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index e5ac07d98..4915c981b 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -2,8 +2,10 @@ import type { DblModel, KuramotoProblemData, LatexEquations, - LinearODEProblemData, + LCCProblemData, + LCCEquationsData, LotkaVolterraProblemData, + LotkaVolterraEquationsData, MassActionEquationsData, MassActionProblemData, ODEResult, @@ -15,27 +17,35 @@ import type { export type { KuramotoProblemData, - LinearODEProblemData, + LCCProblemData, LotkaVolterraProblemData, MassActionProblemData, PolynomialODEProblemData, }; export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; -export type LinearODESimulator = (model: DblModel, data: LinearODEProblemData) => ODEResult; -export type LotkaVolterraSimulator = (model: DblModel, data: LotkaVolterraProblemData) => ODEResult; +export type LCCSimulator = (model: DblModel, data: LCCProblemData) => ODEResultWithEquations; +export type LCCEquations = (model: DblModel, data: LCCEquationsData) => LatexEquations; +export type LotkaVolterraSimulator = ( + model: DblModel, + data: LotkaVolterraProblemData, +) => ODEResultWithEquations; +export type LotkaVolterraEquations = ( + model: DblModel, + data: LotkaVolterraEquationsData, +) => LatexEquations; export type MassActionSimulator = ( model: DblModel, data: MassActionProblemData, ) => ODEResultWithEquations; -export type StochasticMassActionSimulator = ( - model: DblModel, - data: StochasticMassActionProblemData, -) => ODEResult; export type MassActionEquations = ( model: DblModel, data: MassActionEquationsData, ) => LatexEquations; +export type StochasticMassActionSimulator = ( + model: DblModel, + data: StochasticMassActionProblemData, +) => ODEResult; export type PolynomialODESimulator = ( model: DblModel, data: PolynomialODEProblemData, diff --git a/packages/frontend/src/stdlib/theories/causal-loop.ts b/packages/frontend/src/stdlib/theories/causal-loop.ts index 174d40108..8d81ad97b 100644 --- a/packages/frontend/src/stdlib/theories/causal-loop.ts +++ b/packages/frontend/src/stdlib/theories/causal-loop.ts @@ -76,9 +76,19 @@ export default function createCausalLoopTheory(theoryMeta: TheoryMeta): Theory { analyses.linearODE({ simulate: (model, data) => thSignedCategory.linearODE(model, data), }), + analyses.linearODEEquations({ + getEquations(model) { + return thSignedCategory.linearODEEquations(model); + }, + }), analyses.lotkaVolterra({ simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), }), + analyses.lotkaVolterraEquations({ + getEquations(model) { + return thSignedCategory.lotkaVolterraEquations(model); + }, + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts index b99784d6f..a9774c5cb 100644 --- a/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts @@ -68,22 +68,6 @@ export default function createPrimitiveSignedStockFlowTheory(theoryMeta: TheoryM description: "Visualize the stock and flow diagram", help: "visualization", }), - analyses.massAction({ - ratesHaveGranularity: false, - simulate(model, data) { - return thCategorySignedLinks.massAction(model, data); - }, - transitionType: { - tag: "Hom", - content: { tag: "Basic", content: "Object" }, - }, - }), - analyses.massActionEquations({ - ratesHaveGranularity: false, - getEquations(model, data) { - return thCategorySignedLinks.massActionEquations(model, data); - }, - }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/reg-net.ts b/packages/frontend/src/stdlib/theories/reg-net.ts index ff6751faf..29f627c9c 100644 --- a/packages/frontend/src/stdlib/theories/reg-net.ts +++ b/packages/frontend/src/stdlib/theories/reg-net.ts @@ -75,9 +75,17 @@ export default function createRegulatoryNetworkTheory(theoryMeta: TheoryMeta): T analyses.linearODE({ simulate: (model, data) => thSignedCategory.linearODE(model, data), }), + analyses.linearODEEquations({ + getEquations(model) { + return thSignedCategory.linearODEEquations(model); + }, + }), analyses.lotkaVolterra({ - simulate(model, data) { - return thSignedCategory.lotkaVolterra(model, data); + simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), + }), + analyses.lotkaVolterraEquations({ + getEquations(model) { + return thSignedCategory.lotkaVolterraEquations(model); }, }), ], From c8e1b881670391a9818fa4f701e02bfaf695b4a9 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 11 Jun 2026 00:08:39 +0100 Subject: [PATCH 02/38] WIP: Removing the pretend declarative migration; starting again --- .../src/stdlib/analyses/ode/ode_semantics.rs | 72 +++++++++++++++++-- packages/catlog/src/zero/qualified.rs | 7 ++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index ca2d40fcc..231a5caab 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -27,7 +27,7 @@ use std::{collections::HashMap, fmt, rc::Rc}; use crate::{ dbl::{ - modal::List, + modal::{List, ModeApp}, model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, theory::{NonUnital, Unital}, }, @@ -37,9 +37,69 @@ use crate::{ analyses::ode::{ODEAnalysis, Parameter, PolynomialODEAnalysis}, th_signed_polynomial_ode_system, }, - zero::QualifiedName, + zero::{QualifiedName, name}, }; +/// Builder for polynomial ODE systems. +/// +/// This struct is just a convenient interface to construct a model of the +/// [theory of polynomial ODE systems](th_polynomial_ode_system). Being an +/// ordinary mutable Rust struct, it does *not* constitute a declarative +/// language to define ODE semantics for models of other theories. However, the +/// idea is that it should be used in a style that can mechanically translated +/// to a future declarative language for model migration. +/// +/// Since an ODE semantics often has contributions of several types, a useful +/// pattern is to use qualified names with an initial segment indicating the +/// type of contribution. This corresponds to a model migration in which the +/// contributions arise as a coproduct of several queries. +pub struct PolynomialODESystemBuilder { + model: ModalDblModel, +} + +impl Default for PolynomialODESystemBuilder { + fn default() -> Self { + let th = th_signed_polynomial_ode_system(); + Self { model: ModalDblModel::new(th.into()) } + } +} + +impl PolynomialODESystemBuilder { + /// Constructs an empty ODE system. + pub fn new() -> Self { + Self::default() + } + + /// Returns a model of the theory of polynomial ODE systems. + pub fn model(self) -> ModalDblModel { + self.model + } + + // TODO: add_variable() and add_contribution() should both do something to associated_parameters + + /// Adds a state variable to the ODE system. + pub fn add_variable(&mut self, var: QualifiedName) { + self.model.add_ob(var, ModeApp::new(name("State"))); + } + + /// Adds a contribution to the ODE system. + pub fn add_contribution( + &mut self, + id: QualifiedName, + var: QualifiedName, + monomial: impl IntoIterator, + ) { + let monomial = monomial.into_iter().map(ModalOb::Generator).collect(); + // TODO: we land in *signed* polynomial ODEs, so we should worry about the sign + self.model.add_mor( + id, + ModalOb::List(List::Symmetric, monomial), + ModalOb::Generator(var), + ModeApp::new(name("Contribution")).into(), + ) + } +} + /// The trait for an ODE semantics on models. pub trait ODESemantics { /// The type of the model for which these ODE semantics are intended. @@ -77,7 +137,7 @@ impl DblModelForODESemantics for ModalDblModel {} pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} /// This trait is where we give the actual functions for building the data that -/// `ode::polynomial_ode::build_system_from_ode_semantics()` needs in order to construct +/// `build_system_from_ode_semantics()` needs in order to construct /// the multicategory. The implementation of `build_semantics()` is where the actual /// migration (i.e. the actual ODE semantics) is specified, but `build_system()` can /// essentially always use the default implementation given below. @@ -90,7 +150,7 @@ pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} /// some `MassConservationType`, whose value is fundamental in constructing the semantics). /// However, this is left to the user: the type checker will not enforce any of these extras. pub trait ODESemanticsAnalysis: Default { - /// Construct the data required by `ode::polynomial_ode::build_system_from_ode_semantics()` + /// Construct the data required by `build_system_from_ode_semantics()` /// to actually build the multicategory. fn build_semantics(&self) -> ODESemanticsBuilder; @@ -104,7 +164,7 @@ pub trait ODESemanticsAnalysis: } } -/// The data required by `ode::polynomial_ode::build_system_from_ode_semantics()` consists of +/// The data required by `build_system_from_ode_semantics()` consists of /// information on how to construct *variables* (objects) and *contributions* (multimorphisms). pub struct ODESemanticsBuilder { /// The list of terms of `T::ObType` to iterate over when constructing variables in the @@ -246,6 +306,8 @@ pub trait ODESemanticsProblemData { /// need to then simply call `PolynomialODEAnalysis::default().build_system_custom_parameters` /// to build the desired `PolynomialSystem`. pub fn build_system_from_ode_semantics( + // TODO: this should now take in some PolynomialODESystemBuilder instead of + // the now-deleted ODESemanticsBuilder model: &T, ode_semantics: ODESemanticsBuilder, ) -> PolynomialSystem, i8> diff --git a/packages/catlog/src/zero/qualified.rs b/packages/catlog/src/zero/qualified.rs index 908f76d23..60f13c8bf 100644 --- a/packages/catlog/src/zero/qualified.rs +++ b/packages/catlog/src/zero/qualified.rs @@ -294,6 +294,13 @@ impl QualifiedName { } } + /// Prepend a name segment. + pub fn cons(&self, segment: NameSegment) -> Self { + let mut segments = self.0.clone(); + segments.insert(0, segment); + Self(segments) + } + /// Add another segment onto the end. pub fn snoc(&self, segment: NameSegment) -> Self { let mut segments = self.0.clone(); From 4af72f9495cf18264385b5e1e2dd358c2990d41e Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 12:22:09 +0100 Subject: [PATCH 03/38] WIP: Starting to meet in the middle [skip-ci] --- .../src/stdlib/analyses/ode/lotka_volterra.rs | 97 +++--- .../src/stdlib/analyses/ode/ode_semantics.rs | 279 ++++-------------- 2 files changed, 104 insertions(+), 272 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index d656b6627..dba88e753 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -14,10 +14,11 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use super::Parameter; +use crate::dbl::model::FpDblModel; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::ode_semantics::*; -use crate::zero::name; +use crate::stdlib::analyses::ode::ode_semantics::{self, *}; +use crate::zero::{name, name_seg}; use crate::{ dbl::model::{DiscreteDblModel, MutDblModel}, one::QualifiedPath, @@ -98,68 +99,48 @@ impl /// sometimes called the "generalized Lotka-Volterra equations." For more, see /// [Wikipedia](https://en.wikipedia.org/wiki/Generalized_Lotka%E2%80%93Volterra_equation) /// and [our paper on regulatory networks](crate::refs::RegNets). - fn build_semantics( + fn build_system_builder( &self, - ) -> ODESemanticsBuilder< - ::ModelType, + model: &::ModelType, + ) -> ode_semantics::PolynomialODESystemBuilder< ::ParameterType, > { - // Each variable in the CLD gives a variable in the ODE system. - let variable_builders = vec![ODEVariableBuilder::Object { - ob_type: LotkaVolterraAnalysis::default().var_ob_type, - }]; - - // Each variable in the CLD *also* gives its growth contribution: - // "(d/dt)x += g_x x" for a coefficient g_x. - let growth = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Object { - ob_types_and_signs: vec![( - LotkaVolterraAnalysis::default().var_ob_type, - ContributionSign::Positive, - )], - ob_contributions: vec![{ - |var, _| { - vec![Contribution { - name: var.clone(), - monomial: vec![var.clone()], - parameter: LotkaVolterraParameter::Growth { variable: var.clone() }, - target: var.clone(), - }] - } - }], - }; + let mut builder = PolynomialODESystemBuilder::new(); - // Links in the CLD give contributions to the ODEs governing their codomain, namely - // x -> y gives "(d/dt)y += k_xy xy" for a coefficient k_xy. Each positive link - // in the CLD gives a positive contribution, and each negative link a negative contribution. - let interaction = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Morphism { - mor_types_and_signs: vec![ - (LotkaVolterraAnalysis::default().pos_link_type, ContributionSign::Positive), - (LotkaVolterraAnalysis::default().neg_link_type, ContributionSign::Negative), - ], - mor_contributions: vec![{ - |link, model| { - let dom = model.get_dom(link).unwrap(); - let cod = model.get_cod(link).unwrap(); - vec![Contribution { - name: link.clone(), - monomial: vec![dom.clone(), cod.clone()], - parameter: LotkaVolterraParameter::Interaction { link: link.clone() }, - target: cod.clone(), - }] - } - }], - }; + for var in model.ob_generators_with_type(&self.var_ob_type) { + builder.add_variable(var.clone()); - ODESemanticsBuilder { - variable_builders, - contribution_builders: vec![growth, interaction], + // Arbitrarily signed contribution for growth or decay. + let id = var.cons(name_seg("Growth")); + // TODO: explain this contribution (\dot{x} += Growth_x \cdot x) + builder.add_contribution( + id, + var.clone(), + ContributionSign::Positive, + LotkaVolterraParameter::Growth { variable: var.clone() }, + [var], + ); } + + // // FIXME: Should be *positively signed* contributions. + // for mor in model.mor_generators_with_type(&self.pos_link_type) { + // let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { + // continue; + // }; + // let id = mor.cons(name_seg("Influence")); + // builder.add_contribution(id, dom.clone(), [dom.clone(), cod.clone()]); + // } + + // // FIXME: Should be *negatively signed* contributions. + // for mor in model.mor_generators_with_type(&self.neg_link_type) { + // let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { + // continue; + // }; + // let id = mor.cons(name_seg("Influence")); + // builder.add_contribution(id, dom.clone(), [dom.clone(), cod.clone()]); + // } + + builder } } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 231a5caab..e670b3709 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -23,7 +23,7 @@ use indexmap::IndexMap; use nalgebra::DVector; -use std::{collections::HashMap, fmt, rc::Rc}; +use std::{collections::HashMap, fmt}; use crate::{ dbl::{ @@ -40,6 +40,45 @@ use crate::{ zero::{QualifiedName, name}, }; +/// The trait for an ODE semantics on models. +pub trait ODESemantics { + /// The type of the model for which these ODE semantics are intended. + type ModelType: DblModelForODESemantics; + /// The type of the parameters associated to each contribution in the multicategory + /// built from the model. The "default" value for this would be `QualifiedName`, but + /// it can be useful to have a more descriptive type. For example, we might wish for + /// certain parameters to be identified with one another, or to be rendered differently + /// in debug/LaTeX output. An instructive example of this is `LotkaVolterraParameter`; + /// a more complicated example is `MassActionParameter`. + type ParameterType: ODEParameterType; + /// The data describing the things that the ODE semantics "cares about". (See the + /// documentation for `ODESemanticsAnalysis`). + type AnalysisType: ODESemanticsAnalysis; + /// The data describing how to turn the algebraic system of equations into a simulation, + /// including e.g. which values that appear in the front-end analysis correspond to + /// which parameters within the equations. + type ProblemDataType: ODESemanticsProblemData; +} + +/// The models for which we support ODE semantics need to be sufficiently nice, though +/// these bounds are not particularly restrictive. +pub trait DblModelForODESemantics: + FgCategory + MutDblModel + Clone +{ +} + +impl DblModelForODESemantics for DiscreteDblModel {} +impl DblModelForODESemantics for DiscreteTabModel {} +impl DblModelForODESemantics for ModalDblModel {} +impl DblModelForODESemantics for ModalDblModel {} + +/// The type of the parameters in the ODE system need to be sufficiently nice, though +/// (again) these bounds are not particularly restrictive. +pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} + +// TODO: this is the bare minimum +impl ODEParameterType for QualifiedName; + /// Builder for polynomial ODE systems. /// /// This struct is just a convenient interface to construct a model of the @@ -53,18 +92,20 @@ use crate::{ /// pattern is to use qualified names with an initial segment indicating the /// type of contribution. This corresponds to a model migration in which the /// contributions arise as a coproduct of several queries. -pub struct PolynomialODESystemBuilder { +pub struct PolynomialODESystemBuilder { + // TODO: should this struct also have types ????? model: ModalDblModel, + associated_parameters: HashMap } -impl Default for PolynomialODESystemBuilder { +impl Default for PolynomialODESystemBuilder

{ fn default() -> Self { let th = th_signed_polynomial_ode_system(); - Self { model: ModalDblModel::new(th.into()) } + Self { model: ModalDblModel::new(th.into()), associated_parameters: HashMap::new() } } } -impl PolynomialODESystemBuilder { +impl PolynomialODESystemBuilder

{ /// Constructs an empty ODE system. pub fn new() -> Self { Self::default() @@ -75,7 +116,8 @@ impl PolynomialODESystemBuilder { self.model } - // TODO: add_variable() and add_contribution() should both do something to associated_parameters + // TODO: write associated_parameters() (which requires making this struct parametric over

) + // pub fn associated_parameters(self) -> /// Adds a state variable to the ODE system. pub fn add_variable(&mut self, var: QualifiedName) { @@ -87,55 +129,27 @@ impl PolynomialODESystemBuilder { &mut self, id: QualifiedName, var: QualifiedName, + sign: ContributionSign, + parameter: P, monomial: impl IntoIterator, ) { let monomial = monomial.into_iter().map(ModalOb::Generator).collect(); - // TODO: we land in *signed* polynomial ODEs, so we should worry about the sign + let sign = match sign { + ContributionSign::Positive => ModeApp::new(name("Contribution")).into(), + ContributionSign::Negative => ModeApp::new(name("NegativeContribution")).into(), + }; + self.model.add_mor( - id, + id.clone(), ModalOb::List(List::Symmetric, monomial), ModalOb::Generator(var), - ModeApp::new(name("Contribution")).into(), - ) - } -} + sign, + ); -/// The trait for an ODE semantics on models. -pub trait ODESemantics { - /// The type of the model for which these ODE semantics are intended. - type ModelType: DblModelForODESemantics; - /// The type of the parameters associated to each contribution in the multicategory - /// built from the model. The "default" value for this would be `QualifiedName`, but - /// it can be useful to have a more descriptive type. For example, we might wish for - /// certain parameters to be identified with one another, or to be rendered differently - /// in debug/LaTeX output. An instructive example of this is `LotkaVolterraParameter`; - /// a more complicated example is `MassActionParameter`. - type ParameterType: ODEParameterType; - /// The data describing the things that the ODE semantics "cares about". (See the - /// documentation for `ODESemanticsAnalysis`). - type AnalysisType: ODESemanticsAnalysis; - /// The data describing how to turn the algebraic system of equations into a simulation, - /// including e.g. which values that appear in the front-end analysis correspond to - /// which parameters within the equations. - type ProblemDataType: ODESemanticsProblemData; -} - -/// The models for which we support ODE semantics need to be sufficiently nice, though -/// these bounds are not particularly restrictive. -pub trait DblModelForODESemantics: - FgCategory + MutDblModel + Clone -{ + self.associated_parameters.insert(id, parameter); + } } -impl DblModelForODESemantics for DiscreteDblModel {} -impl DblModelForODESemantics for DiscreteTabModel {} -impl DblModelForODESemantics for ModalDblModel {} -impl DblModelForODESemantics for ModalDblModel {} - -/// The type of the parameters in the ODE system need to be sufficiently nice, though -/// (again) these bounds are not particularly restrictive. -pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} - /// This trait is where we give the actual functions for building the data that /// `build_system_from_ode_semantics()` needs in order to construct /// the multicategory. The implementation of `build_semantics()` is where the actual @@ -150,76 +164,16 @@ pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} /// some `MassConservationType`, whose value is fundamental in constructing the semantics). /// However, this is left to the user: the type checker will not enforce any of these extras. pub trait ODESemanticsAnalysis: Default { - /// Construct the data required by `build_system_from_ode_semantics()` - /// to actually build the multicategory. - fn build_semantics(&self) -> ODESemanticsBuilder; + // TODO: change the return type from a tuple to something better + fn build_system_builder(&self, model: &T) -> PolynomialODESystemBuilder

; - // TODO: SWITCH THIS AROUND! i.e. from here we should EXPOSE add_contribution() functions - // and then e.g. lotka_volterra.rs should USE them (we pop out a new blank ODESemantics - // and lotka_volterra populates it) - /// Construct the polynomial system from the `ODESemanticsBuilder`. This default - /// implementation should hopefully essentially always be the desired one. fn build_system(&self, model: &T) -> PolynomialSystem, i8> { - build_system_from_ode_semantics::(model, self.build_semantics()) + let builder = self.build_system_builder(model); + PolynomialODEAnalysis::default() + .build_system_custom_parameters(&builder.model(), builder.associated_parameters()) } } -/// The data required by `build_system_from_ode_semantics()` consists of -/// information on how to construct *variables* (objects) and *contributions* (multimorphisms). -pub struct ODESemanticsBuilder { - /// The list of terms of `T::ObType` to iterate over when constructing variables in the - /// ODE system. - pub variable_builders: Vec>, - /// The list of terms of `T::ObType` and of `T::MorType` to iterate over when constructing - /// contributions in the ODE system, along with the corresponding migrations. - pub contribution_builders: Vec>, -} - -/// The type that describes how to construct *variables* in the ODE system. -pub enum ODEVariableBuilder { - /// Construct variables from *objects* in the original model. - Object { - /// The type of objects in the original model to use to construct variables. - /// In short, this is used in `ode::polynomial_ode` in the following way: - /// ```ignore - /// for ob in model.ob_generators_with_type(&self.variable_ob_type) { - /// sys.add_term(ob, Polynomial::zero()); - /// } - /// ``` - ob_type: T::ObType, - }, - // N.B. Constructing variables from *morphisms* in the original model is not currently - // supported, but would be useful for e.g. "span migration", where flows x--[f]->y in a stock-flow - // diagram are viewed as spans x<-f->y and so a new apex variable f needs to be created. -} - -/// The type that describes how to construct *contributions* in the ODE system. -pub enum ODEContributionBuilder { - /// Construct contributions from *variables* in the original model. - Object { - /// The type(s) of objects in the original model to use to construct variables. - /// Analogous to `ODEVariableBuilder::Object`, this is used to iterate over in - /// `ode::polynomial_ode`. The only extra data here is that of a term of type - /// `ContributionSign`, which happens to be a convenient way of reducing duplication - /// in the existing ODE semantics. For example, in all current ODE semantics on - /// CLDs, the migration defined on positive links and the one on negative links are - /// identical in terms of their monomial, target, and parameter, but differ in the - /// *sign* of the contribution. However, this is purely a convention of convenience, - /// i.e. there is no good mathematical reason to put this data here instead of inside - /// `ob_contributions`. Indeed, at some point it might be more sensible to move it there. - ob_types_and_signs: Vec<(T::ObType, ContributionSign)>, - /// A list of contributions, as described in `Contribution`. - ob_contributions: Vec Vec>>, - }, - /// Construct contributions from *morphisms* in the original model. - Morphism { - /// Analogous to `Object.ob_types_and_signs`, but for morphisms types. - mor_types_and_signs: Vec<(T::MorType, ContributionSign)>, - /// A list of contributions, as described in `Contribution`. - mor_contributions: Vec Vec>>, - }, -} - /// A contribution to the ODE system consists of all the data that `ModalDblModel::add_mor()` /// requires to create a multimorphism. #[derive(Clone)] @@ -298,106 +252,3 @@ pub trait ODESemanticsProblemData { ODEAnalysis::new(problem, ob_index) } } - -/// The main function of this module: taking the data of an `ODESemanticsBuilder` -/// and constructing a `PolynomialSystem` (with parameters of type `P`). We first construct -/// `ode_model: ModalDblModel` in the theory of signed polynomial ODE systems, -/// along with a hash map of parameters associated to names. This data is precisely what we -/// need to then simply call `PolynomialODEAnalysis::default().build_system_custom_parameters` -/// to build the desired `PolynomialSystem`. -pub fn build_system_from_ode_semantics( - // TODO: this should now take in some PolynomialODESystemBuilder instead of - // the now-deleted ODESemanticsBuilder - model: &T, - ode_semantics: ODESemanticsBuilder, -) -> PolynomialSystem, i8> -where - T: DblModelForODESemantics, - P: ODEParameterType, -{ - let ode_theory = Rc::new(th_signed_polynomial_ode_system()); - let mut ode_model = ModalDblModel::new(ode_theory); - - let ode_analysis = PolynomialODEAnalysis::default(); - let ode_ob_type = ode_analysis.variable_ob_type; - let ode_pos_cont_type = ode_analysis.positive_contribution_mor_type; - let ode_neg_cont_type = ode_analysis.negative_contribution_mor_type; - - let mut associated_parameters: HashMap = HashMap::new(); - - for var_build in ode_semantics.variable_builders { - let ODEVariableBuilder::Object { ob_type } = var_build; - for ob in model.ob_generators_with_type(&ob_type) { - ode_model.add_ob(ob, ode_ob_type.clone()); - } - } - - let apply_contribution = { - |contribution: Contribution

, - sign: ContributionSign, - associated_parameters: &mut HashMap, - ode_model: &mut ModalDblModel| { - associated_parameters.insert(contribution.name.clone(), contribution.parameter); - ode_model.add_mor( - contribution.name, - ModalOb::List( - List::Symmetric, - contribution - .monomial - .iter() - .map(|var| ModalOb::Generator(var.clone())) - .collect(), - ), - ModalOb::Generator(contribution.target), - match sign { - ContributionSign::Positive => ode_pos_cont_type.clone(), - ContributionSign::Negative => ode_neg_cont_type.clone(), - }, - ) - } - }; - - // REQUEST | The below is the most naive way of doing this, but it involves a *lot* of nested - // FOR | loops. Is there a nicer way of doing this? Note that both arms of the `match` - // FEEDBACK | are essentially identical, differing only in their use of `ob_generators_with_type` - // _________/ versus `mor_generators_with_type`. - for cont_build in ode_semantics.contribution_builders { - match cont_build { - ODEContributionBuilder::Object { ob_types_and_signs, ob_contributions } => { - for (ob_type, sign) in ob_types_and_signs { - for ob in model.ob_generators_with_type(&ob_type) { - for contribution in ob_contributions.clone() { - for contribution in contribution(&ob, model) { - apply_contribution( - contribution.clone(), - sign, - &mut associated_parameters, - &mut ode_model, - ) - } - } - } - } - } - ODEContributionBuilder::Morphism { mor_types_and_signs, mor_contributions } => { - for (mor_type, sign) in mor_types_and_signs { - for mor in model.mor_generators_with_type(&mor_type) { - for contribution in mor_contributions.clone() { - for contribution in contribution(&mor, model) { - apply_contribution( - contribution.clone(), - sign, - &mut associated_parameters, - &mut ode_model, - ) - } - } - } - } - } - } - } - - PolynomialODEAnalysis::default() - .build_system_custom_parameters(&ode_model, associated_parameters) -} From 0e8b9b5d6f7557c8218c99bda746ed9eb8931f9d Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 15:11:10 +0100 Subject: [PATCH 04/38] WIP: tests running (but failing, of course) --- .../stdlib/analyses/ode/#ode_semantics.rs# | 256 ++++++ .../src/stdlib/analyses/ode/linear_ode.rs | 82 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 70 +- .../src/stdlib/analyses/ode/mass_action.rs | 730 +++++++++--------- .../src/stdlib/analyses/ode/ode_semantics.rs | 14 +- 5 files changed, 730 insertions(+), 422 deletions(-) create mode 100644 packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# diff --git a/packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# b/packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# new file mode 100644 index 000000000..9eb61f817 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# @@ -0,0 +1,256 @@ +//! Analyses for different ODE semantics on models. +//! +//! Following inspiration from schema migration, we define the data of an ODE semantics on +//! models in a theory to be a migration into the theory of multicategories (more specifically, +//! [`th_polynomial_ode_system()`]). We then simply use the "canonical" interpretation of +//! multicategories as systems of polynomial ODEs as implemented in [`ode::polynomial_ode`] +//! (and see there also for documentation on this interpretation of models as systems of ODEs). +//! +//! That is, we take some `model: T` where `T: DblModelForODESemantics`, and from this use +//! `ODESemanticsAnalysis::build_semantics()` to build `ode_model: ModalDblModel` (to be +//! understood as a model for [`th_polynomial_ode_system()`]), and finally use +//! [`ode::polynomial_ode`] to build `system: PolynomialSystem, i8>` +//! where `P: ODEParameterType`. Finally, for an actual front-end analysis, we use +//! `ODESemanticsProblemData::extend_scalars()` and `ODESemanticsProblemData::build_analysis()` +//! to construct `analysis: ODEAnalysis>`, which we can feed into +//! the ODE solver. +//! +//! To implement a new ODE semantics for models in some theory, one essentially needs to create +//! an empty struct and implement `ODESemantics`, and then follow the compiler. +//! +//! [`th_polynomial_ode_system()`]: crate::stdlib::theories +//! [`ode::polynomial_ode`]: crate::stdlib::analyses::ode::polynomial_ode + +use indexmap::IndexMap; +use nalgebra::DVector; +use std::{collections::HashMap, fmt}; + +use crate::{ + dbl::{ + modal::{List, ModeApp}, + model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, + theory::{NonUnital, Unital}, + }, + one::FgCategory, + simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, + stdlib::{ + analyses::ode::{ODEAnalysis, Parameter, PolynomialODEAnalysis}, + th_signed_polynomial_ode_system, + }, + zero::{QualifiedName, name}, +}; + +/// The trait for an ODE semantics on models. +pub trait ODESemantics { + /// The type of the model for which these ODE semantics are intended. + type ModelType: DblModelForODESemantics; + /// The type of the parameters associated to each contribution in the multicategory + /// built from the model. The "default" value for this would be `QualifiedName`, but + /// it can be useful to have a more descriptive type. For example, we might wish for + /// certain parameters to be identified with one another, or to be rendered differently + /// in debug/LaTeX output. An instructive example of this is `LotkaVolterraParameter`; + /// a more complicated example is `MassActionParameter`. + type ParameterType: ODEParameterType; + /// The data describing the things that the ODE semantics "cares about". (See the + /// documentation for `ODESemanticsAnalysis`). + type AnalysisType: ODESemanticsAnalysis; + /// The data describing how to turn the algebraic system of equations into a simulation, + /// including e.g. which values that appear in the front-end analysis correspond to + /// which parameters within the equations. + type ProblemDataType: ODESemanticsProblemData; +} + +/// The models for which we support ODE semantics need to be sufficiently nice, though +/// these bounds are not particularly restrictive. +pub trait DblModelForODESemantics: + FgCategory + MutDblModel + Clone +{ +} + +impl DblModelForODESemantics for DiscreteDblModel {} +impl DblModelForODESemantics for DiscreteTabModel {} +impl DblModelForODESemantics for ModalDblModel {} +impl DblModelForODESemantics for ModalDblModel {} + +/// The type of the parameters in the ODE system need to be sufficiently nice, though +/// (again) these bounds are not particularly restrictive. +pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} + +// TODO: this is the bare minimum +impl ODEParameterType for QualifiedName {} + +/// Builder for polynomial ODE systems. +/// +/// This struct is just a convenient interface to construct a model of the +/// [theory of polynomial ODE systems](th_polynomial_ode_system). Being an +/// ordinary mutable Rust struct, it does *not* constitute a declarative +/// language to define ODE semantics for models of other theories. However, the +/// idea is that it should be used in a style that can mechanically translated +/// to a future declarative language for model migration. +/// +/// Since an ODE semantics often has contributions of several types, a useful +/// pattern is to use qualified names with an initial segment indicating the +/// type of contribution. This corresponds to a model migration in which the +/// contributions arise as a coproduct of several queries. +#[derive(Clone)] +pub struct PolynomialODESystemBuilder { + // TODO: should this struct also have types ????? + model: ModalDblModel, + associated_parameters: HashMap +} + +impl Default for PolynomialODESystemBuilder

{ + fn default() -> Self { + let th = th_signed_polynomial_ode_system(); + Self { model: ModalDblModel::new(th.into()), associated_parameters: HashMap::new() } + } +} + +impl PolynomialODESystemBuilder

{ + /// Constructs an empty ODE system. + pub fn new() -> Self { + Self::default() + } + + /// Returns a model of the theory of polynomial ODE systems. + pub fn model(self) -> ModalDblModel { + self.model + } + + pub fn associated_parameters(self) -> HashMap { + self.associated_parameters + } + + /// Adds a state variable to the ODE system. + pub fn add_variable(&mut self, var: QualifiedName) { + self.model.add_ob(var, ModeApp::new(name("State"))); + } + + /// Adds a contribution to the ODE system. + pub fn add_contribution( + &mut self, + id: QualifiedName, + target: QualifiedName, + sign: ContributionSign, + parameter: P, + monomial: impl IntoIterator, + ) { + let monomial = monomial.into_iter().map(ModalOb::Generator).collect(); + let sign = match sign { + ContributionSign::Positive => ModeApp::new(name("Contribution")).into(), + ContributionSign::Negative => ModeApp::new(name("NegativeContribution")).into(), + }; + + self.model.add_mor( + id.clone(), + ModalOb::List(List::Symmetric, monomial), + ModalOb::Generator(target), + sign, + ); + + self.associated_parameters.insert(id, parameter); + } +} + +/// This trait is where we give the actual functions for building the data that +/// `build_system_from_ode_semantics()` needs in order to construct +/// the multicategory. The implementation of `build_semantics()` is where the actual +/// migration (i.e. the actual ODE semantics) is specified, but `build_system()` can +/// essentially always use the default implementation given below. +/// +/// Note that the type that implements this trait is also where you are expected to state +/// everything that your semantics "cares about". For example, the expected minimum is to +/// give the values of `ObType` and `MorType` that you want to distinguish between and +/// iterate over. It can also hold any extra data upon which your semantics can depend +/// (see e.g. `ode::mass_action::PetriNetMassActionAnalysis`, which contains the data of +/// some `MassConservationType`, whose value is fundamental in constructing the semantics). +/// However, this is left to the user: the type checker will not enforce any of these extras. +pub trait ODESemanticsAnalysis: Default { + // TODO: change the return type from a tuple to something better + fn build_system_builder(&self, model: &T) -> PolynomialODESystemBuilder

; + + fn build_system(&self, model: &T) -> PolynomialSystem, i8> { + let builder = self.build_system_builder(model); + PolynomialODEAnalysis::default() + .build_system_custom_parameters(&builder.model(), builder.associated_parameters()) + } +} + +/// A contribution to the ODE system consists of all the data that `ModalDblModel::add_mor()` +/// requires to create a multimorphism. +#[derive(Clone)] +pub struct Contribution { + /// The name of the multimorphism. + pub name: QualifiedName, + /// The source of the multimorphism (a list of objects), to be interpreted + /// as the monomial given by the product of all the list elements. + pub monomial: Vec, + /// The parameter (coefficient) to be associated with this contribution. + pub parameter: P, + /// The target of the multimorphism, to be interpreted as the variable whose + /// first derivative is affected by the monomial. + pub target: QualifiedName, +} + +/// The sign of the contribution, since we work in *signed* multicategories. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +pub enum ContributionSign { + /// Positive contribution: (d/dt)y -= x. + Positive, + /// Negative contribution: (d/dt)y += x. + Negative, +} + +/// The trait describing how to turn the formal system of ODEs into a numerical problem, to be +/// solved by an ODE solver and presented to the front-end. At minimum, such data must contain +/// initial values for variables and the intended duration of simulation, as well as the method +/// for converting the parameters (which are of type `ODEParameterType`) into floats. +// REQUEST | If you look at a struct that implements this trait (such as `LotkaVolterraProblemData`), +// FOR | there are a lot of serde statements going on. Should I be able to just move them +// FEEDBACK | (that is, those that come *before* the struct) here and have things all work? I'm still +// _________/ a bit intimidated by all these `crg_attr(feature = "serde")` bits. +// +// #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +// #[cfg_attr(feature = "serde-wasm", derive(Tsify))] +// #[cfg_attr( +// feature = "serde-wasm", +// tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) +// )] +pub trait ODESemanticsProblemData { + // REQUEST | The two getters (`initial_values()` and `duration()`) are annoying boilerplate to + // FOR | ask to be implemented. Is there a nice way to get rid of them here? Without them, + // FEEDBACK | the call to `self.initial_values` in `build_analysis()` fails because there is no + // _________/ way of knowing whether a struct implementing this trait actually has those fields. + /// Map from object IDs to initial values (nonnegative reals). + fn initial_values(&self) -> HashMap; + /// Duration of simulation. + fn duration(&self) -> f32; + + /// How to convert the formal parameters of type `ODEParameterType` into floats using values that + /// will eventually be filled in by the user from the front-end. + fn extend_scalars( + &self, + sys: PolynomialSystem, i8>, + ) -> PolynomialSystem; + + /// Converting the polynomial system into a system ready for use in numerical solvers. The default + /// implementation here should essentially always be the desired one. + fn build_analysis( + &self, + sys: PolynomialSystem, + ) -> ODEAnalysis> { + let ob_index: IndexMap<_, _> = + sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); + let n = ob_index.len(); + + let initial_values = ob_index + .keys() + .map(|ob| self.initial_values().get(ob).copied().unwrap_or_default()); + let x0 = DVector::from_iterator(n, initial_values); + + let num_sys = sys.to_numerical(); + let problem = ODEProblem::new(num_sys, x0).end_time(self.duration()); + + ODEAnalysis::new(problem, ob_index) + } +} diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 4364f0761..02954cd1a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -14,11 +14,14 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use super::Parameter; -use crate::dbl::model::MutDblModel; +use crate::dbl::model::{FpDblModel, MutDblModel}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::ode_semantics::*; -use crate::zero::name; +use crate::stdlib::analyses::ode::ode_semantics::{ + ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, + ODESemanticsProblemData, PolynomialODESystemBuilder, +}; +use crate::zero::{name, name_seg}; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; /// Implementing LCC as an ODE semantics for models of type `DiscreteDblModel`. @@ -83,46 +86,53 @@ impl /// Creates a linear system with symbolic rate coefficients. /// /// A system of ODEs for building arbitrary LCC ODEs from CLDs. - fn build_semantics( + fn build_system_builder( &self, - ) -> ODESemanticsBuilder< - ::ModelType, - ::ParameterType, - > { - // Each variable in the CLD gives a variable in the ODE system. - let variable_builders = vec![ODEVariableBuilder::Object { - ob_type: LCCAnalysis::default().var_ob_type, - }]; + model: &::ModelType, + ) -> PolynomialODESystemBuilder<::ParameterType> { + let mut builder = PolynomialODESystemBuilder::new(); + + for var in model.ob_generators_with_type(&self.var_ob_type) { + // TODO: variables + builder.add_variable(var.clone()); + } // Links in the CLD give contributions to the ODEs governing their *codomain*, in an amount // proportionate to their *domain*, i.e. x -> y gives (d/dt)y += x. Each positive link // in the CLD gives a positive contribution and each negative link a negative contribution. - let interaction = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Morphism { - mor_types_and_signs: vec![ - (LCCAnalysis::default().pos_link_type, ContributionSign::Positive), - (LCCAnalysis::default().neg_link_type, ContributionSign::Negative), - ], - mor_contributions: vec![{ - |link, model| { - let dom = model.get_dom(link).unwrap(); - let cod = model.get_cod(link).unwrap(); - vec![Contribution { - name: link.clone(), - monomial: vec![dom.clone()], - parameter: LCCParameter::Parameter { morphism: link.clone() }, - target: cod.clone(), - }] - } - }], - }; + for mor in model.mor_generators_with_type(&self.pos_link_type) { + let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { + continue; + }; - ODESemanticsBuilder { - variable_builders, - contribution_builders: vec![interaction], + // f: x -> y becomes the contribution \dot{y} += Parameter_x x + let id = mor.cons(name_seg("PositiveInfluence")); + builder.add_contribution( + id.clone(), + cod.clone(), + ContributionSign::Positive, + LCCParameter::Parameter { morphism: id }, + [dom.clone()], + ); } + + for mor in model.mor_generators_with_type(&self.neg_link_type) { + let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { + continue; + }; + + // f: x -> y becomes the contribution \dot{y} -= Parameter_f \cdot xy + let id = mor.cons(name_seg("NegativeInfluence")); + builder.add_contribution( + id.clone(), + cod.clone(), + ContributionSign::Negative, + LCCParameter::Parameter { morphism: id }, + [dom.clone()], + ); + } + + builder } } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index dba88e753..23978ddcb 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -14,16 +14,15 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use super::Parameter; -use crate::dbl::model::FpDblModel; +use crate::dbl::model::{FpDblModel, MutDblModel}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::ode_semantics::{self, *}; -use crate::zero::{name, name_seg}; -use crate::{ - dbl::model::{DiscreteDblModel, MutDblModel}, - one::QualifiedPath, - zero::QualifiedName, +use crate::stdlib::analyses::ode::ode_semantics::{ + ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, + ODESemanticsProblemData, PolynomialODESystemBuilder, }; +use crate::zero::{name, name_seg}; +use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; /// Implementing Lotka-Volterra as an ODE semantics for models of type `DiscreteDblModel`. pub struct LotkaVolterraSemantics; @@ -102,17 +101,16 @@ impl fn build_system_builder( &self, model: &::ModelType, - ) -> ode_semantics::PolynomialODESystemBuilder< - ::ParameterType, - > { + ) -> PolynomialODESystemBuilder<::ParameterType> { let mut builder = PolynomialODESystemBuilder::new(); for var in model.ob_generators_with_type(&self.var_ob_type) { + // TODO: variables builder.add_variable(var.clone()); - // Arbitrarily signed contribution for growth or decay. + // TODO: contributions let id = var.cons(name_seg("Growth")); - // TODO: explain this contribution (\dot{x} += Growth_x \cdot x) + // x becomes the contribution \dot{x} += Growth_x \cdot x builder.add_contribution( id, var.clone(), @@ -122,23 +120,37 @@ impl ); } - // // FIXME: Should be *positively signed* contributions. - // for mor in model.mor_generators_with_type(&self.pos_link_type) { - // let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { - // continue; - // }; - // let id = mor.cons(name_seg("Influence")); - // builder.add_contribution(id, dom.clone(), [dom.clone(), cod.clone()]); - // } - - // // FIXME: Should be *negatively signed* contributions. - // for mor in model.mor_generators_with_type(&self.neg_link_type) { - // let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { - // continue; - // }; - // let id = mor.cons(name_seg("Influence")); - // builder.add_contribution(id, dom.clone(), [dom.clone(), cod.clone()]); - // } + for mor in model.mor_generators_with_type(&self.pos_link_type) { + let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { + continue; + }; + + // f: x -> y becomes the contribution \dot{y} += Interaction_f \cdot xy + let id = mor.cons(name_seg("PositiveInfluence")); + builder.add_contribution( + id.clone(), + cod.clone(), + ContributionSign::Positive, + LotkaVolterraParameter::Interaction { link: id }, + [dom.clone(), cod.clone()], + ); + } + + for mor in model.mor_generators_with_type(&self.neg_link_type) { + let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { + continue; + }; + + // f: x -> y becomes the contribution \dot{y} -= Interaction_f \cdot xy + let id = mor.cons(name_seg("NegativeInfluence")); + builder.add_contribution( + id.clone(), + cod.clone(), + ContributionSign::Negative, + LotkaVolterraParameter::Interaction { link: id }, + [dom.clone(), cod.clone()], + ); + } builder } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 2eab1bc02..d8e0afa1a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -14,7 +14,7 @@ use tsify::Tsify; use super::Parameter; use crate::dbl::{ - model::{DiscreteTabModel, ModalDblModel}, + model::{DiscreteTabModel, FpDblModel, ModalDblModel}, theory::{ModalMorType, ModalObType, TabMorType, TabObType, Unital}, }; use crate::simulate::ode::PolynomialSystem; @@ -74,9 +74,9 @@ pub enum RateGranularity { PerStock, } -/// Now, corresponding to each term of `MassConvervationType`, we have different terms for `MassActionParameter`. -/// Parameters in the generated polynomial equations are *undirected* in the -/// balanced case and *directed* in the unbalanced case. +/// Now, corresponding to each term of `MassConvervationType`, we have different +/// terms for `MassActionParameter`. Parameters in the generated polynomial equations +/// are *undirected* in the balanced case and *directed* in the unbalanced case. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum MassActionParameter { /// If mass is conserved, we don't need to worry whether a flow is incoming or outgoing. @@ -190,228 +190,242 @@ impl ::ParameterType, > for PetriNetMassActionAnalysis { - fn build_semantics( + fn build_system_builder( &self, - ) -> ODESemanticsBuilder< - ::ModelType, - ::ParameterType, - > { - let variable_builders = vec![ODEVariableBuilder::Object { - ob_type: PetriNetMassActionAnalysis::default().place_ob_type, - }]; - - // REQUEST | The following code is horrible, with so much duplication that it makes - // FOR | editing (and inspecting) it really difficult. This is all because we store - // FEEDBACK | `mass_conservation_type` in `PetriNetMassActionAnalysis`, and we can't use - // _________/ `self.mass_conservation_type` in any of the closures constructed for - // `mor_contributions` (otherwise it'd try to coerce some captured values or something). - // - // I can see a few possible fixes here: - // - // 1. Use some Rust magic to just refactor everything and make it work without any - // substantial design changes to code elsewhere (both here and in `ode_semantics`). - // - // 2. Move `mass_conservation_type` elsewhere, into a different struct, or pass it as an - // argument into `build_semantics()` (which will require quite a reshuffle in other place). - // - // 3. Actually create three separate structs here: one `PetriNetMassActionAnalysis` for each - // mass-conservation type. - // - // 4. Do some Rust wizardry that allows you to essentially fake a dependent type - // `PetriNetMassActionAnalysis(MassConservationType)`. - - // Note that a single morphism in a Petri net gives rise to multiple morphisms in the - // derived model of signed polynomial ODE systems, according to its interface. For example, - // a single transition T: [a,b] -> [x,y] in `model` will give four morphisms in `ode_model`, - // namely two positive contributions (ab -> x , ab -> y) and two negative (ab -> a , ab -> b). - // - // First we look at all the *negative* contributions coming from a transition, to its input places. - let transition_inputs = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Morphism { - mor_types_and_signs: vec![( - PetriNetMassActionAnalysis::default().transition_mor_type, - ContributionSign::Negative, - )], - mor_contributions: match self.mass_conservation_type { - MassConservationType::Balanced => { - vec![{ - |transition, model| { - let inputs = - transition_interface(model, transition).input_places.clone(); - - inputs - .iter() - .map(|input| Contribution { - name: transition - .clone() - .snoc(name_seg("ToInput")) - .snoc(input.clone().only().unwrap()), - monomial: inputs.clone(), - parameter: MassActionParameter::Balanced { - flow: transition.clone(), - }, - target: input.clone(), - }) - .collect() - } - }] - } - MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerFlow => { - vec![{ - |transition, model| { - let inputs = - transition_interface(model, transition).input_places.clone(); - - inputs - .iter() - .map(|input| Contribution { - name: transition - .clone() - .snoc(name_seg("ToInput")) - .snoc(input.clone().only().unwrap()), - monomial: inputs.clone(), - parameter: MassActionParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerFlow { - flow: transition.clone(), - }, - }, - target: input.clone(), - }) - .collect() - } - }] - } - RateGranularity::PerStock => { - vec![{ - |transition, model| { - let inputs = - transition_interface(model, transition).input_places.clone(); - - inputs - .iter() - .map(|input| Contribution { - name: transition - .clone() - .snoc(name_seg("ToInput")) - .snoc(input.clone().only().unwrap()), - monomial: inputs.clone(), - parameter: MassActionParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerStock { - flow: transition.clone(), - stock: input.clone(), - }, - }, - target: input.clone(), - }) - .collect() - } - }] - } - }, - }, - }; - - // Now we look at all the *positive* contributions coming from a transition, to its output places. - let transition_outputs = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Morphism { - mor_types_and_signs: vec![( - PetriNetMassActionAnalysis::default().transition_mor_type, - ContributionSign::Positive, - )], - mor_contributions: match self.mass_conservation_type { - MassConservationType::Balanced => { - vec![{ - |transition, model| { - let inputs = transition_interface(model, transition).input_places; - let outputs = transition_interface(model, transition).output_places; - - outputs - .iter() - .map(|output| Contribution { - name: transition - .clone() - .snoc(name_seg("ToOutPut")) - .snoc(output.clone().only().unwrap()), - monomial: inputs.clone(), - parameter: MassActionParameter::Balanced { - flow: transition.clone(), - }, - target: output.clone(), - }) - .collect() - } - }] - } - MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerFlow => { - vec![{ - |transition, model| { - let inputs = transition_interface(model, transition).input_places; - let outputs = transition_interface(model, transition).output_places; - - outputs - .iter() - .map(|output| Contribution { - name: transition - .clone() - .snoc(name_seg("ToOutput")) - .snoc(output.clone().only().unwrap()), - monomial: inputs.clone(), - parameter: MassActionParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerFlow { - flow: transition.clone(), - }, - }, - target: output.clone(), - }) - .collect() - } - }] - } - RateGranularity::PerStock => { - vec![{ - |transition, model| { - let inputs = transition_interface(model, transition).input_places; - let outputs = transition_interface(model, transition).output_places; - - outputs - .iter() - .map(|output| Contribution { - name: transition - .clone() - .snoc(name_seg("ToOutput")) - .snoc(output.clone().only().unwrap()), - monomial: inputs.clone(), - parameter: MassActionParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerStock { - flow: transition.clone(), - stock: output.clone(), - }, - }, - target: output.clone(), - }) - .collect() - } - }] - } - }, - }, - }; - - ODESemanticsBuilder { - variable_builders, - contribution_builders: vec![transition_inputs, transition_outputs], + model: &::ModelType, + ) -> PolynomialODESystemBuilder<::ParameterType> + { + let mut builder = PolynomialODESystemBuilder::new(); + + for place in model.ob_generators_with_type(&self.place_ob_type) { + // TODO: variables + builder.add_variable(place.clone()); } + + builder } + // fn build_semantics( + // &self, + // ) -> ODESemanticsBuilder< + // ::ModelType, + // ::ParameterType, + // > { + // let variable_builders = vec![ODEVariableBuilder::Object { + // ob_type: PetriNetMassActionAnalysis::default().place_ob_type, + // }]; + + // // REQUEST | The following code is horrible, with so much duplication that it makes + // // FOR | editing (and inspecting) it really difficult. This is all because we store + // // FEEDBACK | `mass_conservation_type` in `PetriNetMassActionAnalysis`, and we can't use + // // _________/ `self.mass_conservation_type` in any of the closures constructed for + // // `mor_contributions` (otherwise it'd try to coerce some captured values or something). + // // + // // I can see a few possible fixes here: + // // + // // 1. Use some Rust magic to just refactor everything and make it work without any + // // substantial design changes to code elsewhere (both here and in `ode_semantics`). + // // + // // 2. Move `mass_conservation_type` elsewhere, into a different struct, or pass it as an + // // argument into `build_semantics()` (which will require quite a reshuffle in other place). + // // + // // 3. Actually create three separate structs here: one `PetriNetMassActionAnalysis` for each + // // mass-conservation type. + // // + // // 4. Do some Rust wizardry that allows you to essentially fake a dependent type + // // `PetriNetMassActionAnalysis(MassConservationType)`. + + // // Note that a single morphism in a Petri net gives rise to multiple morphisms in the + // // derived model of signed polynomial ODE systems, according to its interface. For example, + // // a single transition T: [a,b] -> [x,y] in `model` will give four morphisms in `ode_model`, + // // namely two positive contributions (ab -> x , ab -> y) and two negative (ab -> a , ab -> b). + // // + // // First we look at all the *negative* contributions coming from a transition, to its input places. + // let transition_inputs = ODEContributionBuilder::< + // ::ModelType, + // ::ParameterType, + // >::Morphism { + // mor_types_and_signs: vec![( + // PetriNetMassActionAnalysis::default().transition_mor_type, + // ContributionSign::Negative, + // )], + // mor_contributions: match self.mass_conservation_type { + // MassConservationType::Balanced => { + // vec![{ + // |transition, model| { + // let inputs = + // transition_interface(model, transition).input_places.clone(); + + // inputs + // .iter() + // .map(|input| Contribution { + // name: transition + // .clone() + // .snoc(name_seg("ToInput")) + // .snoc(input.clone().only().unwrap()), + // monomial: inputs.clone(), + // parameter: MassActionParameter::Balanced { + // flow: transition.clone(), + // }, + // target: input.clone(), + // }) + // .collect() + // } + // }] + // } + // MassConservationType::Unbalanced(granularity) => match granularity { + // RateGranularity::PerFlow => { + // vec![{ + // |transition, model| { + // let inputs = + // transition_interface(model, transition).input_places.clone(); + + // inputs + // .iter() + // .map(|input| Contribution { + // name: transition + // .clone() + // .snoc(name_seg("ToInput")) + // .snoc(input.clone().only().unwrap()), + // monomial: inputs.clone(), + // parameter: MassActionParameter::Unbalanced { + // direction: Direction::OutgoingFlow, + // parameter: RateParameter::PerFlow { + // flow: transition.clone(), + // }, + // }, + // target: input.clone(), + // }) + // .collect() + // } + // }] + // } + // RateGranularity::PerStock => { + // vec![{ + // |transition, model| { + // let inputs = + // transition_interface(model, transition).input_places.clone(); + + // inputs + // .iter() + // .map(|input| Contribution { + // name: transition + // .clone() + // .snoc(name_seg("ToInput")) + // .snoc(input.clone().only().unwrap()), + // monomial: inputs.clone(), + // parameter: MassActionParameter::Unbalanced { + // direction: Direction::OutgoingFlow, + // parameter: RateParameter::PerStock { + // flow: transition.clone(), + // stock: input.clone(), + // }, + // }, + // target: input.clone(), + // }) + // .collect() + // } + // }] + // } + // }, + // }, + // }; + + // // Now we look at all the *positive* contributions coming from a transition, to its output places. + // let transition_outputs = ODEContributionBuilder::< + // ::ModelType, + // ::ParameterType, + // >::Morphism { + // mor_types_and_signs: vec![( + // PetriNetMassActionAnalysis::default().transition_mor_type, + // ContributionSign::Positive, + // )], + // mor_contributions: match self.mass_conservation_type { + // MassConservationType::Balanced => { + // vec![{ + // |transition, model| { + // let inputs = transition_interface(model, transition).input_places; + // let outputs = transition_interface(model, transition).output_places; + + // outputs + // .iter() + // .map(|output| Contribution { + // name: transition + // .clone() + // .snoc(name_seg("ToOutPut")) + // .snoc(output.clone().only().unwrap()), + // monomial: inputs.clone(), + // parameter: MassActionParameter::Balanced { + // flow: transition.clone(), + // }, + // target: output.clone(), + // }) + // .collect() + // } + // }] + // } + // MassConservationType::Unbalanced(granularity) => match granularity { + // RateGranularity::PerFlow => { + // vec![{ + // |transition, model| { + // let inputs = transition_interface(model, transition).input_places; + // let outputs = transition_interface(model, transition).output_places; + + // outputs + // .iter() + // .map(|output| Contribution { + // name: transition + // .clone() + // .snoc(name_seg("ToOutput")) + // .snoc(output.clone().only().unwrap()), + // monomial: inputs.clone(), + // parameter: MassActionParameter::Unbalanced { + // direction: Direction::IncomingFlow, + // parameter: RateParameter::PerFlow { + // flow: transition.clone(), + // }, + // }, + // target: output.clone(), + // }) + // .collect() + // } + // }] + // } + // RateGranularity::PerStock => { + // vec![{ + // |transition, model| { + // let inputs = transition_interface(model, transition).input_places; + // let outputs = transition_interface(model, transition).output_places; + + // outputs + // .iter() + // .map(|output| Contribution { + // name: transition + // .clone() + // .snoc(name_seg("ToOutput")) + // .snoc(output.clone().only().unwrap()), + // monomial: inputs.clone(), + // parameter: MassActionParameter::Unbalanced { + // direction: Direction::IncomingFlow, + // parameter: RateParameter::PerStock { + // flow: transition.clone(), + // stock: output.clone(), + // }, + // }, + // target: output.clone(), + // }) + // .collect() + // } + // }] + // } + // }, + // }, + // }; + + // ODESemanticsBuilder { + // variable_builders, + // contribution_builders: vec![transition_inputs, transition_outputs], + // } + // } } /// Mass-action ODE analysis for stock-flow models. @@ -447,137 +461,151 @@ impl ::ParameterType, > for StockFlowMassActionAnalysis { - fn build_semantics( + fn build_system_builder( &self, - ) -> ODESemanticsBuilder< - ::ModelType, - ::ParameterType, - > { - let variable_builders = vec![ODEVariableBuilder::Object { - ob_type: StockFlowMassActionAnalysis::default().stock_ob_type, - }]; - - let flow_input = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Morphism { - mor_types_and_signs: vec![( - StockFlowMassActionAnalysis::default().flow_mor_type, - ContributionSign::Negative, - )], - mor_contributions: match self.mass_conservation_type { - MassConservationType::Balanced => { - vec![{ - |flow, model| { - let flow_interface = flow_interface(model, flow); - let dom = flow_interface.input_stock; - // N.B. We completely ignore negative links. - let mut term = flow_interface.input_pos_link_doms; - term.push(dom.clone()); - - vec![Contribution { - name: flow - .clone() - .snoc(name_seg("ToInput")) - .snoc(dom.clone().only().unwrap()), - monomial: term, - parameter: MassActionParameter::Balanced { flow: flow.clone() }, - target: dom.clone(), - }] - } - }] - } - MassConservationType::Unbalanced(_) => { - vec![{ - |flow, model| { - let flow_interface = flow_interface(model, flow); - let dom = flow_interface.input_stock; - // N.B. We completely ignore negative links. - let mut term = flow_interface.input_pos_link_doms; - term.push(dom.clone()); - - vec![Contribution { - name: flow - .clone() - .snoc(name_seg("ToInput")) - .snoc(dom.clone().only().unwrap()), - monomial: term, - parameter: MassActionParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerFlow { flow: flow.clone() }, - }, - target: dom.clone(), - }] - } - }] - } - }, - }; - - let flow_output = ODEContributionBuilder::< - ::ModelType, - ::ParameterType, - >::Morphism { - mor_types_and_signs: vec![( - StockFlowMassActionAnalysis::default().flow_mor_type, - ContributionSign::Positive, - )], - mor_contributions: match self.mass_conservation_type { - MassConservationType::Balanced => { - vec![{ - |flow, model| { - let flow_interface = flow_interface(model, flow); - let dom = flow_interface.input_stock; - let cod = flow_interface.output_stock; - // N.B. We completely ignore negative links. - let mut term = flow_interface.input_pos_link_doms; - term.push(dom.clone()); - - vec![Contribution { - name: flow - .clone() - .snoc(name_seg("ToOutput")) - .snoc(cod.clone().only().unwrap()), - monomial: term, - parameter: MassActionParameter::Balanced { flow: flow.clone() }, - target: cod.clone(), - }] - } - }] - } - MassConservationType::Unbalanced(_) => { - vec![{ - |flow, model| { - let flow_interface = flow_interface(model, flow); - let dom = flow_interface.input_stock; - let cod = flow_interface.output_stock; - // N.B. We completely ignore negative links. - let mut term = flow_interface.input_pos_link_doms; - term.push(dom.clone()); - - vec![Contribution { - name: flow - .clone() - .snoc(name_seg("ToOutput")) - .snoc(cod.clone().only().unwrap()), - monomial: term, - parameter: MassActionParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerFlow { flow: flow.clone() }, - }, - target: cod.clone(), - }] - } - }] - } - }, - }; - - ODESemanticsBuilder { - variable_builders, - contribution_builders: vec![flow_input, flow_output], + model: &::ModelType, + ) -> PolynomialODESystemBuilder<::ParameterType> + { + let mut builder = PolynomialODESystemBuilder::new(); + + for stock in model.ob_generators_with_type(&self.stock_ob_type) { + // TODO: variables + builder.add_variable(stock.clone()); } + + builder } + // fn build_semantics( + // &self, + // ) -> ODESemanticsBuilder< + // ::ModelType, + // ::ParameterType, + // > { + // let variable_builders = vec![ODEVariableBuilder::Object { + // ob_type: StockFlowMassActionAnalysis::default().stock_ob_type, + // }]; + + // let flow_input = ODEContributionBuilder::< + // ::ModelType, + // ::ParameterType, + // >::Morphism { + // mor_types_and_signs: vec![( + // StockFlowMassActionAnalysis::default().flow_mor_type, + // ContributionSign::Negative, + // )], + // mor_contributions: match self.mass_conservation_type { + // MassConservationType::Balanced => { + // vec![{ + // |flow, model| { + // let flow_interface = flow_interface(model, flow); + // let dom = flow_interface.input_stock; + // // N.B. We completely ignore negative links. + // let mut term = flow_interface.input_pos_link_doms; + // term.push(dom.clone()); + + // vec![Contribution { + // name: flow + // .clone() + // .snoc(name_seg("ToInput")) + // .snoc(dom.clone().only().unwrap()), + // monomial: term, + // parameter: MassActionParameter::Balanced { flow: flow.clone() }, + // target: dom.clone(), + // }] + // } + // }] + // } + // MassConservationType::Unbalanced(_) => { + // vec![{ + // |flow, model| { + // let flow_interface = flow_interface(model, flow); + // let dom = flow_interface.input_stock; + // // N.B. We completely ignore negative links. + // let mut term = flow_interface.input_pos_link_doms; + // term.push(dom.clone()); + + // vec![Contribution { + // name: flow + // .clone() + // .snoc(name_seg("ToInput")) + // .snoc(dom.clone().only().unwrap()), + // monomial: term, + // parameter: MassActionParameter::Unbalanced { + // direction: Direction::OutgoingFlow, + // parameter: RateParameter::PerFlow { flow: flow.clone() }, + // }, + // target: dom.clone(), + // }] + // } + // }] + // } + // }, + // }; + + // let flow_output = ODEContributionBuilder::< + // ::ModelType, + // ::ParameterType, + // >::Morphism { + // mor_types_and_signs: vec![( + // StockFlowMassActionAnalysis::default().flow_mor_type, + // ContributionSign::Positive, + // )], + // mor_contributions: match self.mass_conservation_type { + // MassConservationType::Balanced => { + // vec![{ + // |flow, model| { + // let flow_interface = flow_interface(model, flow); + // let dom = flow_interface.input_stock; + // let cod = flow_interface.output_stock; + // // N.B. We completely ignore negative links. + // let mut term = flow_interface.input_pos_link_doms; + // term.push(dom.clone()); + + // vec![Contribution { + // name: flow + // .clone() + // .snoc(name_seg("ToOutput")) + // .snoc(cod.clone().only().unwrap()), + // monomial: term, + // parameter: MassActionParameter::Balanced { flow: flow.clone() }, + // target: cod.clone(), + // }] + // } + // }] + // } + // MassConservationType::Unbalanced(_) => { + // vec![{ + // |flow, model| { + // let flow_interface = flow_interface(model, flow); + // let dom = flow_interface.input_stock; + // let cod = flow_interface.output_stock; + // // N.B. We completely ignore negative links. + // let mut term = flow_interface.input_pos_link_doms; + // term.push(dom.clone()); + + // vec![Contribution { + // name: flow + // .clone() + // .snoc(name_seg("ToOutput")) + // .snoc(cod.clone().only().unwrap()), + // monomial: term, + // parameter: MassActionParameter::Unbalanced { + // direction: Direction::IncomingFlow, + // parameter: RateParameter::PerFlow { flow: flow.clone() }, + // }, + // target: cod.clone(), + // }] + // } + // }] + // } + // }, + // }; + + // ODESemanticsBuilder { + // variable_builders, + // contribution_builders: vec![flow_input, flow_output], + // } + // } } /// Data defining an unbalanced mass-action ODE problem for a model. diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index e670b3709..46c3f37ff 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -77,7 +77,7 @@ impl DblModelForODESemantics for ModalDblModel {} pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} // TODO: this is the bare minimum -impl ODEParameterType for QualifiedName; +impl ODEParameterType for QualifiedName {} /// Builder for polynomial ODE systems. /// @@ -92,6 +92,7 @@ impl ODEParameterType for QualifiedName; /// pattern is to use qualified names with an initial segment indicating the /// type of contribution. This corresponds to a model migration in which the /// contributions arise as a coproduct of several queries. +#[derive(Clone)] pub struct PolynomialODESystemBuilder { // TODO: should this struct also have types ????? model: ModalDblModel, @@ -116,8 +117,9 @@ impl PolynomialODESystemBuilder

{ self.model } - // TODO: write associated_parameters() (which requires making this struct parametric over

) - // pub fn associated_parameters(self) -> + pub fn associated_parameters(self) -> HashMap { + self.associated_parameters + } /// Adds a state variable to the ODE system. pub fn add_variable(&mut self, var: QualifiedName) { @@ -128,7 +130,7 @@ impl PolynomialODESystemBuilder

{ pub fn add_contribution( &mut self, id: QualifiedName, - var: QualifiedName, + target: QualifiedName, sign: ContributionSign, parameter: P, monomial: impl IntoIterator, @@ -142,7 +144,7 @@ impl PolynomialODESystemBuilder

{ self.model.add_mor( id.clone(), ModalOb::List(List::Symmetric, monomial), - ModalOb::Generator(var), + ModalOb::Generator(target), sign, ); @@ -170,7 +172,7 @@ pub trait ODESemanticsAnalysis: fn build_system(&self, model: &T) -> PolynomialSystem, i8> { let builder = self.build_system_builder(model); PolynomialODEAnalysis::default() - .build_system_custom_parameters(&builder.model(), builder.associated_parameters()) + .build_system_custom_parameters(&builder.clone().model(), builder.associated_parameters()) } } From 9d8b231891e5390e6d2b8dd983338820d11b539d Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 15:41:18 +0100 Subject: [PATCH 05/38] WIP: Fixed Lotka-Volterra and LCC --- .../src/stdlib/analyses/ode/linear_ode.rs | 10 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 14 +- .../src/stdlib/analyses/ode/mass_action.rs | 316 +++++++++--------- .../src/stdlib/analyses/ode/ode_semantics.rs | 6 +- 4 files changed, 169 insertions(+), 177 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 02954cd1a..e17badbd3 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -106,12 +106,11 @@ impl }; // f: x -> y becomes the contribution \dot{y} += Parameter_x x - let id = mor.cons(name_seg("PositiveInfluence")); builder.add_contribution( - id.clone(), + mor.clone(), cod.clone(), ContributionSign::Positive, - LCCParameter::Parameter { morphism: id }, + LCCParameter::Parameter { morphism: mor }, [dom.clone()], ); } @@ -122,12 +121,11 @@ impl }; // f: x -> y becomes the contribution \dot{y} -= Parameter_f \cdot xy - let id = mor.cons(name_seg("NegativeInfluence")); builder.add_contribution( - id.clone(), + mor.clone(), cod.clone(), ContributionSign::Negative, - LCCParameter::Parameter { morphism: id }, + LCCParameter::Parameter { morphism: mor }, [dom.clone()], ); } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 23978ddcb..2c5b7d28a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -109,10 +109,9 @@ impl builder.add_variable(var.clone()); // TODO: contributions - let id = var.cons(name_seg("Growth")); // x becomes the contribution \dot{x} += Growth_x \cdot x builder.add_contribution( - id, + var.clone(), var.clone(), ContributionSign::Positive, LotkaVolterraParameter::Growth { variable: var.clone() }, @@ -126,12 +125,11 @@ impl }; // f: x -> y becomes the contribution \dot{y} += Interaction_f \cdot xy - let id = mor.cons(name_seg("PositiveInfluence")); builder.add_contribution( - id.clone(), + mor.clone(), cod.clone(), ContributionSign::Positive, - LotkaVolterraParameter::Interaction { link: id }, + LotkaVolterraParameter::Interaction { link: mor }, [dom.clone(), cod.clone()], ); } @@ -142,12 +140,11 @@ impl }; // f: x -> y becomes the contribution \dot{y} -= Interaction_f \cdot xy - let id = mor.cons(name_seg("NegativeInfluence")); builder.add_contribution( - id.clone(), + mor.clone(), cod.clone(), ContributionSign::Negative, - LotkaVolterraParameter::Interaction { link: id }, + LotkaVolterraParameter::Interaction { link: mor }, [dom.clone(), cod.clone()], ); } @@ -202,6 +199,7 @@ impl ODESemanticsProblemData<::Parameter let sys = sys.extend_scalars(|poly| { poly.eval(|param| match param { LotkaVolterraParameter::Growth { variable } => { + // FIXME: this won't work, because `variable` will now be `Growth.variable` self.growth_rates.get(variable).cloned().unwrap_or_default() } LotkaVolterraParameter::Interaction { link } => { diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index d8e0afa1a..a2782a41a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -713,161 +713,161 @@ impl ODESemanticsProblemData for MassActionProblemData { } } -#[cfg(test)] -mod tests { - use expect_test::expect; - use std::rc::Rc; - - use super::*; - use crate::simulate::ode::LatexEquation; - use crate::stdlib::{analyses, models::*, theories::*}; - - // Tests for stock-flow diagrams. These all use the backward_link() model, - // which has a single flow x==f==>y and a single link y->f. - - #[test] - fn balanced_stock_flow() { - let th = Rc::new(th_category_links()); - let model = backward_link(th); - let sys = StockFlowMassActionAnalysis::default().build_system(&model); - let expected = expect!([r#" - dx = -f x y - dy = f x y - "#]); - expected.assert_eq(&sys.to_string()); - } - - #[test] - fn unbalanced_stock_flow() { - let th = Rc::new(th_category_links()); - let model = backward_link(th); - let sys = StockFlowMassActionAnalysis { - mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerFlow, - ), - ..StockFlowMassActionAnalysis::default() - } - .build_system(&model); - let expected = expect!([r#" - dx = -Outgoing(f) x y - dy = Incoming(f) x y - "#]); - expected.assert_eq(&sys.to_string()); - } - - // Tests for signed stock-flow diagrams. These all use the negative_backwards_link() - // model, which has a single flow x==f=>y and a single negative link y->f. - - // N.B. These tests are currently disabled, because they require a theory of *rational*, - // not merely polynomial, ODE systems. - - // #[test] - // fn balanced_signed_stock_flow() { - // let th = Rc::new(th_category_signed_links()); - // let model = negative_backward_link(th); - // let sys = StockFlowMassActionAnalysis::default() - // .build_system(&model, analyses::ode::MassConservationType::Balanced); - // let expected = expect!([r#" - // dx = -f x y^{-1} - // dy = f x y^{-1} - // "#]); - // expected.assert_eq(&sys.to_string()); - // } - - // #[test] - // fn unbalanced_signed_stock_flow() { - // let th = Rc::new(th_category_signed_links()); - // let model = negative_backward_link(th); - // let sys = StockFlowMassActionAnalysis::default().build_system( - // &model, - // analyses::ode::MassConservationType::Unbalanced( - // analyses::ode::RateGranularity::PerFlow, - // ), - // ); - // let expected = expect!([r#" - // dx = -Outgoing(f) x y^{-1} - // dy = Incoming(f) x y^{-1} - // "#]); - // expected.assert_eq(&sys.to_string()); - // } - - // Tests for Petri nets. These all use the catalyzed_reaction() model, which - // has a single transition [x,c]-->f-->[y,c]. - - #[test] - fn balanced_petri() { - let th = Rc::new(th_sym_monoidal_category()); - let model = catalyzed_reaction(th); - let sys = PetriNetMassActionAnalysis::default().build_system(&model); - let expected = expect!([r#" - dx = -f c x - dy = f c x - dc = 0 - "#]); - expected.assert_eq(&sys.to_string()); - } - - #[test] - fn unbalanced_petri_per_transition() { - let th = Rc::new(th_sym_monoidal_category()); - let model = catalyzed_reaction(th); - let sys = PetriNetMassActionAnalysis { - mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerFlow, - ), - ..PetriNetMassActionAnalysis::default() - } - .build_system(&model); - let expected = expect!([r#" - dx = -Outgoing(f) c x - dy = Incoming(f) c x - dc = (Incoming(f) - Outgoing(f)) c x - "#]); - expected.assert_eq(&sys.to_string()); - } - - #[test] - fn unbalanced_petri_per_place() { - let th = Rc::new(th_sym_monoidal_category()); - let model = catalyzed_reaction(th); - let sys = PetriNetMassActionAnalysis { - mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerStock, - ), - ..PetriNetMassActionAnalysis::default() - } - .build_system(&model); - let expected = expect!([r#" - dx = -(x->[f]) c x - dy = ([f]->y) c x - dc = (([f]->c) - (c->[f])) c x - "#]); - expected.assert_eq(&sys.to_string()); - } - - // Test for LaTeX. - - #[test] - fn to_latex() { - let th = Rc::new(th_category_links()); - let model = backward_link(th); - let sys = StockFlowMassActionAnalysis { - mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerFlow, - ), - ..StockFlowMassActionAnalysis::default() - } - .build_system(&model); - let expected = vec![ - LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), - rhs: "-Outgoing(f) \\cdot x \\cdot y".to_string(), - }, - LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), - rhs: "Incoming(f) \\cdot x \\cdot y".to_string(), - }, - ]; - assert_eq!(expected, sys.to_latex_equations()); - } -} +// #[cfg(test)] +// mod tests { +// use expect_test::expect; +// use std::rc::Rc; + +// use super::*; +// use crate::simulate::ode::LatexEquation; +// use crate::stdlib::{analyses, models::*, theories::*}; + +// // Tests for stock-flow diagrams. These all use the backward_link() model, +// // which has a single flow x==f==>y and a single link y->f. + +// #[test] +// fn balanced_stock_flow() { +// let th = Rc::new(th_category_links()); +// let model = backward_link(th); +// let sys = StockFlowMassActionAnalysis::default().build_system(&model); +// let expected = expect!([r#" +// dx = -f x y +// dy = f x y +// "#]); +// expected.assert_eq(&sys.to_string()); +// } + +// #[test] +// fn unbalanced_stock_flow() { +// let th = Rc::new(th_category_links()); +// let model = backward_link(th); +// let sys = StockFlowMassActionAnalysis { +// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( +// analyses::ode::RateGranularity::PerFlow, +// ), +// ..StockFlowMassActionAnalysis::default() +// } +// .build_system(&model); +// let expected = expect!([r#" +// dx = -Outgoing(f) x y +// dy = Incoming(f) x y +// "#]); +// expected.assert_eq(&sys.to_string()); +// } + +// // Tests for signed stock-flow diagrams. These all use the negative_backwards_link() +// // model, which has a single flow x==f=>y and a single negative link y->f. + +// // N.B. These tests are currently disabled, because they require a theory of *rational*, +// // not merely polynomial, ODE systems. + +// // #[test] +// // fn balanced_signed_stock_flow() { +// // let th = Rc::new(th_category_signed_links()); +// // let model = negative_backward_link(th); +// // let sys = StockFlowMassActionAnalysis::default() +// // .build_system(&model, analyses::ode::MassConservationType::Balanced); +// // let expected = expect!([r#" +// // dx = -f x y^{-1} +// // dy = f x y^{-1} +// // "#]); +// // expected.assert_eq(&sys.to_string()); +// // } + +// // #[test] +// // fn unbalanced_signed_stock_flow() { +// // let th = Rc::new(th_category_signed_links()); +// // let model = negative_backward_link(th); +// // let sys = StockFlowMassActionAnalysis::default().build_system( +// // &model, +// // analyses::ode::MassConservationType::Unbalanced( +// // analyses::ode::RateGranularity::PerFlow, +// // ), +// // ); +// // let expected = expect!([r#" +// // dx = -Outgoing(f) x y^{-1} +// // dy = Incoming(f) x y^{-1} +// // "#]); +// // expected.assert_eq(&sys.to_string()); +// // } + +// // Tests for Petri nets. These all use the catalyzed_reaction() model, which +// // has a single transition [x,c]-->f-->[y,c]. + +// #[test] +// fn balanced_petri() { +// let th = Rc::new(th_sym_monoidal_category()); +// let model = catalyzed_reaction(th); +// let sys = PetriNetMassActionAnalysis::default().build_system(&model); +// let expected = expect!([r#" +// dx = -f c x +// dy = f c x +// dc = 0 +// "#]); +// expected.assert_eq(&sys.to_string()); +// } + +// #[test] +// fn unbalanced_petri_per_transition() { +// let th = Rc::new(th_sym_monoidal_category()); +// let model = catalyzed_reaction(th); +// let sys = PetriNetMassActionAnalysis { +// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( +// analyses::ode::RateGranularity::PerFlow, +// ), +// ..PetriNetMassActionAnalysis::default() +// } +// .build_system(&model); +// let expected = expect!([r#" +// dx = -Outgoing(f) c x +// dy = Incoming(f) c x +// dc = (Incoming(f) - Outgoing(f)) c x +// "#]); +// expected.assert_eq(&sys.to_string()); +// } + +// #[test] +// fn unbalanced_petri_per_place() { +// let th = Rc::new(th_sym_monoidal_category()); +// let model = catalyzed_reaction(th); +// let sys = PetriNetMassActionAnalysis { +// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( +// analyses::ode::RateGranularity::PerStock, +// ), +// ..PetriNetMassActionAnalysis::default() +// } +// .build_system(&model); +// let expected = expect!([r#" +// dx = -(x->[f]) c x +// dy = ([f]->y) c x +// dc = (([f]->c) - (c->[f])) c x +// "#]); +// expected.assert_eq(&sys.to_string()); +// } + +// // Test for LaTeX. + +// #[test] +// fn to_latex() { +// let th = Rc::new(th_category_links()); +// let model = backward_link(th); +// let sys = StockFlowMassActionAnalysis { +// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( +// analyses::ode::RateGranularity::PerFlow, +// ), +// ..StockFlowMassActionAnalysis::default() +// } +// .build_system(&model); +// let expected = vec![ +// LatexEquation { +// lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), +// rhs: "-Outgoing(f) \\cdot x \\cdot y".to_string(), +// }, +// LatexEquation { +// lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), +// rhs: "Incoming(f) \\cdot x \\cdot y".to_string(), +// }, +// ]; +// assert_eq!(expected, sys.to_latex_equations()); +// } +// } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 46c3f37ff..e6d5cc359 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -87,11 +87,6 @@ impl ODEParameterType for QualifiedName {} /// language to define ODE semantics for models of other theories. However, the /// idea is that it should be used in a style that can mechanically translated /// to a future declarative language for model migration. -/// -/// Since an ODE semantics often has contributions of several types, a useful -/// pattern is to use qualified names with an initial segment indicating the -/// type of contribution. This corresponds to a model migration in which the -/// contributions arise as a coproduct of several queries. #[derive(Clone)] pub struct PolynomialODESystemBuilder { // TODO: should this struct also have types ????? @@ -117,6 +112,7 @@ impl PolynomialODESystemBuilder

{ self.model } + /// TODO: documentation. pub fn associated_parameters(self) -> HashMap { self.associated_parameters } From b061893f49ed2c3e898eaf01e61b4bf1d18af6dd Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 16:45:10 +0100 Subject: [PATCH 06/38] WIP: More documentation [skip-ci] --- .../src/stdlib/analyses/ode/linear_ode.rs | 17 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 20 +- .../src/stdlib/analyses/ode/mass_action.rs | 361 ++++++++++-------- 3 files changed, 221 insertions(+), 177 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index e17badbd3..0994b8a34 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -21,7 +21,7 @@ use crate::stdlib::analyses::ode::ode_semantics::{ ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, PolynomialODESystemBuilder, }; -use crate::zero::{name, name_seg}; +use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; /// Implementing LCC as an ODE semantics for models of type `DiscreteDblModel`. @@ -93,19 +93,19 @@ impl let mut builder = PolynomialODESystemBuilder::new(); for var in model.ob_generators_with_type(&self.var_ob_type) { - // TODO: variables + // For each object, we create a variable. builder.add_variable(var.clone()); } - // Links in the CLD give contributions to the ODEs governing their *codomain*, in an amount - // proportionate to their *domain*, i.e. x -> y gives (d/dt)y += x. Each positive link - // in the CLD gives a positive contribution and each negative link a negative contribution. for mor in model.mor_generators_with_type(&self.pos_link_type) { let (Some(dom), Some(cod)) = (model.get_dom(&mor), model.get_cod(&mor)) else { continue; }; - // f: x -> y becomes the contribution \dot{y} += Parameter_x x + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} += Parameter_f x builder.add_contribution( mor.clone(), cod.clone(), @@ -120,7 +120,10 @@ impl continue; }; - // f: x -> y becomes the contribution \dot{y} -= Parameter_f \cdot xy + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} -= Parameter_f x builder.add_contribution( mor.clone(), cod.clone(), diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 2c5b7d28a..f335ca12d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -21,7 +21,7 @@ use crate::stdlib::analyses::ode::ode_semantics::{ ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, PolynomialODESystemBuilder, }; -use crate::zero::{name, name_seg}; +use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; /// Implementing Lotka-Volterra as an ODE semantics for models of type `DiscreteDblModel`. @@ -105,11 +105,13 @@ impl let mut builder = PolynomialODESystemBuilder::new(); for var in model.ob_generators_with_type(&self.var_ob_type) { - // TODO: variables + // For each object, we create a variable. builder.add_variable(var.clone()); - // TODO: contributions - // x becomes the contribution \dot{x} += Growth_x \cdot x + // The object + // x + // becomes the contribution + // \dot{x} += Growth_x \cdot x builder.add_contribution( var.clone(), var.clone(), @@ -124,7 +126,10 @@ impl continue; }; - // f: x -> y becomes the contribution \dot{y} += Interaction_f \cdot xy + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} += Interaction_f \cdot xy builder.add_contribution( mor.clone(), cod.clone(), @@ -139,7 +144,10 @@ impl continue; }; - // f: x -> y becomes the contribution \dot{y} -= Interaction_f \cdot xy + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} -= Interaction_f \cdot xy builder.add_contribution( mor.clone(), cod.clone(), diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index a2782a41a..9d7cca8e1 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -13,16 +13,18 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use super::Parameter; -use crate::dbl::{ - model::{DiscreteTabModel, FpDblModel, ModalDblModel}, - theory::{ModalMorType, ModalObType, TabMorType, TabObType, Unital}, -}; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::*; use crate::stdlib::analyses::petri::transition_interface; use crate::stdlib::analyses::stock_flow::flow_interface; -use crate::zero::name_seg; use crate::zero::{QualifiedName, name}; +use crate::{ + dbl::{ + model::{DiscreteTabModel, FpDblModel, ModalDblModel}, + theory::{ModalMorType, ModalObType, TabMorType, TabObType, Unital}, + }, + zero::name_seg, +}; /// Mass-action semantics for Petri nets. pub struct PetriNetMassActionSemantics; @@ -198,10 +200,61 @@ impl let mut builder = PolynomialODESystemBuilder::new(); for place in model.ob_generators_with_type(&self.place_ob_type) { - // TODO: variables + // For each place, we create a variable. builder.add_variable(place.clone()); } + for transition in model.mor_generators_with_type(&self.transition_mor_type) { + match self.mass_conservation_type { + MassConservationType::Balanced => { + let interface = transition_interface(&model, &transition); + let (inputs, outputs) = (interface.input_places.clone(), interface.output_places.clone()); + + for output in outputs.clone() { + let id = output + .cons(name_seg("ToOutput")) + .cons(transition.only().unwrap().clone()); + // The transition + // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // becomes the contributions + // \dot{y_i} += Balanced_T \cdot x_1...x_n + builder.add_contribution( + id, + output, + ContributionSign::Positive, + MassActionParameter::Balanced { flow: transition.clone() }, + inputs.clone(), + ); + } + + for input in inputs.clone() { + let id = input + .cons(name_seg("ToInput")) + .cons(transition.only().unwrap().clone()); + // The transition + // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // becomes the contributions + // \dot{x_i} -= Balanced_T \cdot x_1...x_n + builder.add_contribution( + id, + input, + ContributionSign::Negative, + MassActionParameter::Balanced { flow: transition.clone() }, + inputs.clone(), + ); + } + } + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerFlow => { + todo!() + } + RateGranularity::PerStock => { + todo!() + } + }, + } + } + builder } // fn build_semantics( @@ -473,6 +526,22 @@ impl builder.add_variable(stock.clone()); } + for flow in model.mor_generators_with_type(&self.flow_mor_type) { + match self.mass_conservation_type { + MassConservationType::Balanced => { + todo!() + } + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerFlow => { + todo!() + } + RateGranularity::PerStock => { + todo!() + } + }, + } + } + builder } // fn build_semantics( @@ -713,161 +782,125 @@ impl ODESemanticsProblemData for MassActionProblemData { } } -// #[cfg(test)] -// mod tests { -// use expect_test::expect; -// use std::rc::Rc; - -// use super::*; -// use crate::simulate::ode::LatexEquation; -// use crate::stdlib::{analyses, models::*, theories::*}; - -// // Tests for stock-flow diagrams. These all use the backward_link() model, -// // which has a single flow x==f==>y and a single link y->f. - -// #[test] -// fn balanced_stock_flow() { -// let th = Rc::new(th_category_links()); -// let model = backward_link(th); -// let sys = StockFlowMassActionAnalysis::default().build_system(&model); -// let expected = expect!([r#" -// dx = -f x y -// dy = f x y -// "#]); -// expected.assert_eq(&sys.to_string()); -// } - -// #[test] -// fn unbalanced_stock_flow() { -// let th = Rc::new(th_category_links()); -// let model = backward_link(th); -// let sys = StockFlowMassActionAnalysis { -// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( -// analyses::ode::RateGranularity::PerFlow, -// ), -// ..StockFlowMassActionAnalysis::default() -// } -// .build_system(&model); -// let expected = expect!([r#" -// dx = -Outgoing(f) x y -// dy = Incoming(f) x y -// "#]); -// expected.assert_eq(&sys.to_string()); -// } - -// // Tests for signed stock-flow diagrams. These all use the negative_backwards_link() -// // model, which has a single flow x==f=>y and a single negative link y->f. - -// // N.B. These tests are currently disabled, because they require a theory of *rational*, -// // not merely polynomial, ODE systems. - -// // #[test] -// // fn balanced_signed_stock_flow() { -// // let th = Rc::new(th_category_signed_links()); -// // let model = negative_backward_link(th); -// // let sys = StockFlowMassActionAnalysis::default() -// // .build_system(&model, analyses::ode::MassConservationType::Balanced); -// // let expected = expect!([r#" -// // dx = -f x y^{-1} -// // dy = f x y^{-1} -// // "#]); -// // expected.assert_eq(&sys.to_string()); -// // } - -// // #[test] -// // fn unbalanced_signed_stock_flow() { -// // let th = Rc::new(th_category_signed_links()); -// // let model = negative_backward_link(th); -// // let sys = StockFlowMassActionAnalysis::default().build_system( -// // &model, -// // analyses::ode::MassConservationType::Unbalanced( -// // analyses::ode::RateGranularity::PerFlow, -// // ), -// // ); -// // let expected = expect!([r#" -// // dx = -Outgoing(f) x y^{-1} -// // dy = Incoming(f) x y^{-1} -// // "#]); -// // expected.assert_eq(&sys.to_string()); -// // } - -// // Tests for Petri nets. These all use the catalyzed_reaction() model, which -// // has a single transition [x,c]-->f-->[y,c]. - -// #[test] -// fn balanced_petri() { -// let th = Rc::new(th_sym_monoidal_category()); -// let model = catalyzed_reaction(th); -// let sys = PetriNetMassActionAnalysis::default().build_system(&model); -// let expected = expect!([r#" -// dx = -f c x -// dy = f c x -// dc = 0 -// "#]); -// expected.assert_eq(&sys.to_string()); -// } - -// #[test] -// fn unbalanced_petri_per_transition() { -// let th = Rc::new(th_sym_monoidal_category()); -// let model = catalyzed_reaction(th); -// let sys = PetriNetMassActionAnalysis { -// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( -// analyses::ode::RateGranularity::PerFlow, -// ), -// ..PetriNetMassActionAnalysis::default() -// } -// .build_system(&model); -// let expected = expect!([r#" -// dx = -Outgoing(f) c x -// dy = Incoming(f) c x -// dc = (Incoming(f) - Outgoing(f)) c x -// "#]); -// expected.assert_eq(&sys.to_string()); -// } - -// #[test] -// fn unbalanced_petri_per_place() { -// let th = Rc::new(th_sym_monoidal_category()); -// let model = catalyzed_reaction(th); -// let sys = PetriNetMassActionAnalysis { -// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( -// analyses::ode::RateGranularity::PerStock, -// ), -// ..PetriNetMassActionAnalysis::default() -// } -// .build_system(&model); -// let expected = expect!([r#" -// dx = -(x->[f]) c x -// dy = ([f]->y) c x -// dc = (([f]->c) - (c->[f])) c x -// "#]); -// expected.assert_eq(&sys.to_string()); -// } - -// // Test for LaTeX. - -// #[test] -// fn to_latex() { -// let th = Rc::new(th_category_links()); -// let model = backward_link(th); -// let sys = StockFlowMassActionAnalysis { -// mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( -// analyses::ode::RateGranularity::PerFlow, -// ), -// ..StockFlowMassActionAnalysis::default() -// } -// .build_system(&model); -// let expected = vec![ -// LatexEquation { -// lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), -// rhs: "-Outgoing(f) \\cdot x \\cdot y".to_string(), -// }, -// LatexEquation { -// lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), -// rhs: "Incoming(f) \\cdot x \\cdot y".to_string(), -// }, -// ]; -// assert_eq!(expected, sys.to_latex_equations()); -// } -// } +#[cfg(test)] +mod tests { + use expect_test::expect; + use std::rc::Rc; + + use super::*; + use crate::simulate::ode::LatexEquation; + use crate::stdlib::{analyses, models::*, theories::*}; + + // Tests for stock-flow diagrams. These all use the backward_link() model, + // which has a single flow x==f==>y and a single link y->f. + + #[test] + fn balanced_stock_flow() { + let th = Rc::new(th_category_links()); + let model = backward_link(th); + let sys = StockFlowMassActionAnalysis::default().build_system(&model); + let expected = expect!([r#" + dx = -f x y + dy = f x y + "#]); + expected.assert_eq(&sys.to_string()); + } + + #[test] + fn unbalanced_stock_flow() { + let th = Rc::new(th_category_links()); + let model = backward_link(th); + let sys = StockFlowMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerFlow, + ), + ..StockFlowMassActionAnalysis::default() + } + .build_system(&model); + let expected = expect!([r#" + dx = -Outgoing(f) x y + dy = Incoming(f) x y + "#]); + expected.assert_eq(&sys.to_string()); + } + + // Tests for Petri nets. These all use the catalyzed_reaction() model, which + // has a single transition [x,c]-->f-->[y,c]. + + #[test] + fn balanced_petri() { + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + let sys = PetriNetMassActionAnalysis::default().build_system(&model); + let expected = expect!([r#" + dx = -f c x + dy = f c x + dc = 0 + "#]); + expected.assert_eq(&sys.to_string()); + } + + #[test] + fn unbalanced_petri_per_transition() { + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + let sys = PetriNetMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerFlow, + ), + ..PetriNetMassActionAnalysis::default() + } + .build_system(&model); + let expected = expect!([r#" + dx = -Outgoing(f) c x + dy = Incoming(f) c x + dc = (Incoming(f) - Outgoing(f)) c x + "#]); + expected.assert_eq(&sys.to_string()); + } + + #[test] + fn unbalanced_petri_per_place() { + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + let sys = PetriNetMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerStock, + ), + ..PetriNetMassActionAnalysis::default() + } + .build_system(&model); + let expected = expect!([r#" + dx = -(x->[f]) c x + dy = ([f]->y) c x + dc = (([f]->c) - (c->[f])) c x + "#]); + expected.assert_eq(&sys.to_string()); + } + + // Test for LaTeX. + + #[test] + fn to_latex() { + let th = Rc::new(th_category_links()); + let model = backward_link(th); + let sys = StockFlowMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( + analyses::ode::RateGranularity::PerFlow, + ), + ..StockFlowMassActionAnalysis::default() + } + .build_system(&model); + let expected = vec![ + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), + rhs: "-Outgoing(f) \\cdot x \\cdot y".to_string(), + }, + LatexEquation { + lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), + rhs: "Incoming(f) \\cdot x \\cdot y".to_string(), + }, + ]; + assert_eq!(expected, sys.to_latex_equations()); + } +} From c46dfa18c3efe2d897782da32b86647b5567fa43 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 16:54:42 +0100 Subject: [PATCH 07/38] WIP: mass-action for Petri nets [skip-ci] --- .../src/stdlib/analyses/ode/mass_action.rs | 348 +++++------------- 1 file changed, 82 insertions(+), 266 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 9d7cca8e1..06def84da 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -5,6 +5,7 @@ //! where we do not require that mass be preserved. This allows the construction //! of systems of arbitrary polynomial (first-order) ODEs. +use std::num::IntErrorKind; use std::{collections::HashMap, fmt}; #[cfg(feature = "serde")] @@ -205,280 +206,95 @@ impl } for transition in model.mor_generators_with_type(&self.transition_mor_type) { - match self.mass_conservation_type { - MassConservationType::Balanced => { - let interface = transition_interface(&model, &transition); - let (inputs, outputs) = (interface.input_places.clone(), interface.output_places.clone()); - - for output in outputs.clone() { - let id = output - .cons(name_seg("ToOutput")) - .cons(transition.only().unwrap().clone()); - // The transition - // T: [x_1, ..., x_n] -> [y_1, ..., y_n] - // becomes the contributions - // \dot{y_i} += Balanced_T \cdot x_1...x_n - builder.add_contribution( - id, - output, - ContributionSign::Positive, - MassActionParameter::Balanced { flow: transition.clone() }, - inputs.clone(), - ); + let interface = transition_interface(&model, &transition); + let (inputs, outputs) = + (interface.input_places.clone(), interface.output_places.clone()); + + // Each transition gives a positive contribution to each term corresponding to + // one of its outputs, and a negative contribution to each term corresponding to + // one of its inputs. For example, a single transition T: [a,b] -> [x,y] will give + // four contributions, namely two positive contributions (ab -> x , ab -> y) + // and two negative (ab -> a , ab -> b). + + for output in outputs.clone() { + let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap().clone()); + // The transition + // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // becomes the contributions + // \dot{y_i} += Parameter_! \cdot x_1...x_n + // where Parameter_! depends on `mass_conservation_type`: + // Balanced => Parameter_T + // Unbalanced::PerTransition => Parameter_T^inflow + // Unbalanced::PerPlace => Parameter_{T,y_i}^inflow + let parameter = match self.mass_conservation_type { + MassConservationType::Balanced => { + MassActionParameter::Balanced { flow: transition.clone() } } + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerFlow => MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerFlow { flow: transition.clone() }, + }, + RateGranularity::PerStock => MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerStock { + flow: transition.clone(), + stock: output.clone(), + }, + }, + }, + }; + + builder.add_contribution( + id, + output, + ContributionSign::Positive, + parameter, + inputs.clone(), + ); + } - for input in inputs.clone() { - let id = input - .cons(name_seg("ToInput")) - .cons(transition.only().unwrap().clone()); - // The transition - // T: [x_1, ..., x_n] -> [y_1, ..., y_n] - // becomes the contributions - // \dot{x_i} -= Balanced_T \cdot x_1...x_n - builder.add_contribution( - id, - input, - ContributionSign::Negative, - MassActionParameter::Balanced { flow: transition.clone() }, - inputs.clone(), - ); + for input in inputs.clone() { + let id = input.cons(name_seg("ToInput")).cons(transition.only().unwrap().clone()); + // The transition + // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // becomes the contributions + // \dot{x_i} -= Parameter_! \cdot x_1...x_n + // where Parameter_! depends on `mass_conservation_type`: + // Balanced => Parameter_T + // Unbalanced::PerTransition => Parameter_T^outflow + // Unbalanced::PerPlace => Parameter_{T,x_i}^outflow + let parameter = match self.mass_conservation_type { + MassConservationType::Balanced => { + MassActionParameter::Balanced { flow: transition.clone() } } - } - MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerFlow => { - todo!() - } - RateGranularity::PerStock => { - todo!() - } - }, + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerFlow => MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerFlow { flow: transition.clone() }, + }, + RateGranularity::PerStock => MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerStock { + flow: transition.clone(), + stock: input.clone(), + }, + }, + }, + }; + + builder.add_contribution( + id, + input, + ContributionSign::Negative, + parameter, + inputs.clone(), + ); } } builder } - // fn build_semantics( - // &self, - // ) -> ODESemanticsBuilder< - // ::ModelType, - // ::ParameterType, - // > { - // let variable_builders = vec![ODEVariableBuilder::Object { - // ob_type: PetriNetMassActionAnalysis::default().place_ob_type, - // }]; - - // // REQUEST | The following code is horrible, with so much duplication that it makes - // // FOR | editing (and inspecting) it really difficult. This is all because we store - // // FEEDBACK | `mass_conservation_type` in `PetriNetMassActionAnalysis`, and we can't use - // // _________/ `self.mass_conservation_type` in any of the closures constructed for - // // `mor_contributions` (otherwise it'd try to coerce some captured values or something). - // // - // // I can see a few possible fixes here: - // // - // // 1. Use some Rust magic to just refactor everything and make it work without any - // // substantial design changes to code elsewhere (both here and in `ode_semantics`). - // // - // // 2. Move `mass_conservation_type` elsewhere, into a different struct, or pass it as an - // // argument into `build_semantics()` (which will require quite a reshuffle in other place). - // // - // // 3. Actually create three separate structs here: one `PetriNetMassActionAnalysis` for each - // // mass-conservation type. - // // - // // 4. Do some Rust wizardry that allows you to essentially fake a dependent type - // // `PetriNetMassActionAnalysis(MassConservationType)`. - - // // Note that a single morphism in a Petri net gives rise to multiple morphisms in the - // // derived model of signed polynomial ODE systems, according to its interface. For example, - // // a single transition T: [a,b] -> [x,y] in `model` will give four morphisms in `ode_model`, - // // namely two positive contributions (ab -> x , ab -> y) and two negative (ab -> a , ab -> b). - // // - // // First we look at all the *negative* contributions coming from a transition, to its input places. - // let transition_inputs = ODEContributionBuilder::< - // ::ModelType, - // ::ParameterType, - // >::Morphism { - // mor_types_and_signs: vec![( - // PetriNetMassActionAnalysis::default().transition_mor_type, - // ContributionSign::Negative, - // )], - // mor_contributions: match self.mass_conservation_type { - // MassConservationType::Balanced => { - // vec![{ - // |transition, model| { - // let inputs = - // transition_interface(model, transition).input_places.clone(); - - // inputs - // .iter() - // .map(|input| Contribution { - // name: transition - // .clone() - // .snoc(name_seg("ToInput")) - // .snoc(input.clone().only().unwrap()), - // monomial: inputs.clone(), - // parameter: MassActionParameter::Balanced { - // flow: transition.clone(), - // }, - // target: input.clone(), - // }) - // .collect() - // } - // }] - // } - // MassConservationType::Unbalanced(granularity) => match granularity { - // RateGranularity::PerFlow => { - // vec![{ - // |transition, model| { - // let inputs = - // transition_interface(model, transition).input_places.clone(); - - // inputs - // .iter() - // .map(|input| Contribution { - // name: transition - // .clone() - // .snoc(name_seg("ToInput")) - // .snoc(input.clone().only().unwrap()), - // monomial: inputs.clone(), - // parameter: MassActionParameter::Unbalanced { - // direction: Direction::OutgoingFlow, - // parameter: RateParameter::PerFlow { - // flow: transition.clone(), - // }, - // }, - // target: input.clone(), - // }) - // .collect() - // } - // }] - // } - // RateGranularity::PerStock => { - // vec![{ - // |transition, model| { - // let inputs = - // transition_interface(model, transition).input_places.clone(); - - // inputs - // .iter() - // .map(|input| Contribution { - // name: transition - // .clone() - // .snoc(name_seg("ToInput")) - // .snoc(input.clone().only().unwrap()), - // monomial: inputs.clone(), - // parameter: MassActionParameter::Unbalanced { - // direction: Direction::OutgoingFlow, - // parameter: RateParameter::PerStock { - // flow: transition.clone(), - // stock: input.clone(), - // }, - // }, - // target: input.clone(), - // }) - // .collect() - // } - // }] - // } - // }, - // }, - // }; - - // // Now we look at all the *positive* contributions coming from a transition, to its output places. - // let transition_outputs = ODEContributionBuilder::< - // ::ModelType, - // ::ParameterType, - // >::Morphism { - // mor_types_and_signs: vec![( - // PetriNetMassActionAnalysis::default().transition_mor_type, - // ContributionSign::Positive, - // )], - // mor_contributions: match self.mass_conservation_type { - // MassConservationType::Balanced => { - // vec![{ - // |transition, model| { - // let inputs = transition_interface(model, transition).input_places; - // let outputs = transition_interface(model, transition).output_places; - - // outputs - // .iter() - // .map(|output| Contribution { - // name: transition - // .clone() - // .snoc(name_seg("ToOutPut")) - // .snoc(output.clone().only().unwrap()), - // monomial: inputs.clone(), - // parameter: MassActionParameter::Balanced { - // flow: transition.clone(), - // }, - // target: output.clone(), - // }) - // .collect() - // } - // }] - // } - // MassConservationType::Unbalanced(granularity) => match granularity { - // RateGranularity::PerFlow => { - // vec![{ - // |transition, model| { - // let inputs = transition_interface(model, transition).input_places; - // let outputs = transition_interface(model, transition).output_places; - - // outputs - // .iter() - // .map(|output| Contribution { - // name: transition - // .clone() - // .snoc(name_seg("ToOutput")) - // .snoc(output.clone().only().unwrap()), - // monomial: inputs.clone(), - // parameter: MassActionParameter::Unbalanced { - // direction: Direction::IncomingFlow, - // parameter: RateParameter::PerFlow { - // flow: transition.clone(), - // }, - // }, - // target: output.clone(), - // }) - // .collect() - // } - // }] - // } - // RateGranularity::PerStock => { - // vec![{ - // |transition, model| { - // let inputs = transition_interface(model, transition).input_places; - // let outputs = transition_interface(model, transition).output_places; - - // outputs - // .iter() - // .map(|output| Contribution { - // name: transition - // .clone() - // .snoc(name_seg("ToOutput")) - // .snoc(output.clone().only().unwrap()), - // monomial: inputs.clone(), - // parameter: MassActionParameter::Unbalanced { - // direction: Direction::IncomingFlow, - // parameter: RateParameter::PerStock { - // flow: transition.clone(), - // stock: output.clone(), - // }, - // }, - // target: output.clone(), - // }) - // .collect() - // } - // }] - // } - // }, - // }, - // }; - - // ODESemanticsBuilder { - // variable_builders, - // contribution_builders: vec![transition_inputs, transition_outputs], - // } - // } } /// Mass-action ODE analysis for stock-flow models. From 684cde6f22cf69c0b1596b0acf63ff0dafe96add Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 17:20:47 +0100 Subject: [PATCH 08/38] WIP: Fix all tests! [no-ci] --- .../src/stdlib/analyses/ode/mass_action.rs | 212 +++++------------- .../src/stdlib/analyses/ode/ode_semantics.rs | 20 +- 2 files changed, 74 insertions(+), 158 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 06def84da..ae3fb425a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -5,7 +5,6 @@ //! where we do not require that mass be preserved. This allows the construction //! of systems of arbitrary polynomial (first-order) ODEs. -use std::num::IntErrorKind; use std::{collections::HashMap, fmt}; #[cfg(feature = "serde")] @@ -206,7 +205,7 @@ impl } for transition in model.mor_generators_with_type(&self.transition_mor_type) { - let interface = transition_interface(&model, &transition); + let interface = transition_interface(model, &transition); let (inputs, outputs) = (interface.input_places.clone(), interface.output_places.clone()); @@ -217,7 +216,7 @@ impl // and two negative (ab -> a , ab -> b). for output in outputs.clone() { - let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap().clone()); + let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap()); // The transition // T: [x_1, ..., x_n] -> [y_1, ..., y_n] // becomes the contributions @@ -255,15 +254,15 @@ impl } for input in inputs.clone() { - let id = input.cons(name_seg("ToInput")).cons(transition.only().unwrap().clone()); + let id = input.cons(name_seg("ToInput")).cons(transition.only().unwrap()); // The transition // T: [x_1, ..., x_n] -> [y_1, ..., y_n] // becomes the contributions // \dot{x_i} -= Parameter_! \cdot x_1...x_n // where Parameter_! depends on `mass_conservation_type`: - // Balanced => Parameter_T - // Unbalanced::PerTransition => Parameter_T^outflow - // Unbalanced::PerPlace => Parameter_{T,x_i}^outflow + // Balanced => Parameter_T + // Unbalanced::PerFlow => Parameter_T^outflow + // Unbalanced::PerStock => Parameter_{T,x_i}^outflow let parameter = match self.mass_conservation_type { MassConservationType::Balanced => { MassActionParameter::Balanced { flow: transition.clone() } @@ -282,7 +281,7 @@ impl }, }, }; - + builder.add_contribution( id, input, @@ -338,159 +337,72 @@ impl let mut builder = PolynomialODESystemBuilder::new(); for stock in model.ob_generators_with_type(&self.stock_ob_type) { - // TODO: variables + // For each stock, we create a variable. builder.add_variable(stock.clone()); } for flow in model.mor_generators_with_type(&self.flow_mor_type) { - match self.mass_conservation_type { + let interface = flow_interface(model, &flow); + let (input, output) = (interface.input_stock, interface.output_stock); + + // TODO: explain this monomial + let monomial = [interface.input_pos_link_doms, vec![input.clone()]].concat(); + + // TODO: fix this comment + // Each transition gives a positive contribution to each term corresponding to + // one of its outputs, and a negative contribution to each term corresponding to + // one of its inputs. For example, a single transition T: [a,b] -> [x,y] will give + // four contributions, namely two positive contributions (ab -> x , ab -> y) + // and two negative (ab -> a , ab -> b). + + // TODO: fix this comment too + // The transition + // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // becomes the contributions + // \dot{x_i} -= Parameter_! \cdot x_1...x_n + // where Parameter_! depends on `mass_conservation_type`: + // Balanced => Parameter_T + // Unbalanced::PerFlow => Parameter_T^outflow + + let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); + let output_parameter = match self.mass_conservation_type { MassConservationType::Balanced => { - todo!() + MassActionParameter::Balanced { flow: flow.clone() } } - MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerFlow => { - todo!() - } - RateGranularity::PerStock => { - todo!() - } + MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerFlow { flow: flow.clone() }, }, - } + }; + builder.add_contribution( + output_id, + output.clone(), + ContributionSign::Positive, + output_parameter, + monomial.clone(), + ); + + let input_id = input.cons(name_seg("ToInput")).cons(flow.only().unwrap()); + let input_parameter = match self.mass_conservation_type { + MassConservationType::Balanced => { + MassActionParameter::Balanced { flow: flow.clone() } + } + MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerFlow { flow: flow.clone() }, + }, + }; + builder.add_contribution( + input_id, + input.clone(), + ContributionSign::Negative, + input_parameter, + monomial, + ); } builder } - // fn build_semantics( - // &self, - // ) -> ODESemanticsBuilder< - // ::ModelType, - // ::ParameterType, - // > { - // let variable_builders = vec![ODEVariableBuilder::Object { - // ob_type: StockFlowMassActionAnalysis::default().stock_ob_type, - // }]; - - // let flow_input = ODEContributionBuilder::< - // ::ModelType, - // ::ParameterType, - // >::Morphism { - // mor_types_and_signs: vec![( - // StockFlowMassActionAnalysis::default().flow_mor_type, - // ContributionSign::Negative, - // )], - // mor_contributions: match self.mass_conservation_type { - // MassConservationType::Balanced => { - // vec![{ - // |flow, model| { - // let flow_interface = flow_interface(model, flow); - // let dom = flow_interface.input_stock; - // // N.B. We completely ignore negative links. - // let mut term = flow_interface.input_pos_link_doms; - // term.push(dom.clone()); - - // vec![Contribution { - // name: flow - // .clone() - // .snoc(name_seg("ToInput")) - // .snoc(dom.clone().only().unwrap()), - // monomial: term, - // parameter: MassActionParameter::Balanced { flow: flow.clone() }, - // target: dom.clone(), - // }] - // } - // }] - // } - // MassConservationType::Unbalanced(_) => { - // vec![{ - // |flow, model| { - // let flow_interface = flow_interface(model, flow); - // let dom = flow_interface.input_stock; - // // N.B. We completely ignore negative links. - // let mut term = flow_interface.input_pos_link_doms; - // term.push(dom.clone()); - - // vec![Contribution { - // name: flow - // .clone() - // .snoc(name_seg("ToInput")) - // .snoc(dom.clone().only().unwrap()), - // monomial: term, - // parameter: MassActionParameter::Unbalanced { - // direction: Direction::OutgoingFlow, - // parameter: RateParameter::PerFlow { flow: flow.clone() }, - // }, - // target: dom.clone(), - // }] - // } - // }] - // } - // }, - // }; - - // let flow_output = ODEContributionBuilder::< - // ::ModelType, - // ::ParameterType, - // >::Morphism { - // mor_types_and_signs: vec![( - // StockFlowMassActionAnalysis::default().flow_mor_type, - // ContributionSign::Positive, - // )], - // mor_contributions: match self.mass_conservation_type { - // MassConservationType::Balanced => { - // vec![{ - // |flow, model| { - // let flow_interface = flow_interface(model, flow); - // let dom = flow_interface.input_stock; - // let cod = flow_interface.output_stock; - // // N.B. We completely ignore negative links. - // let mut term = flow_interface.input_pos_link_doms; - // term.push(dom.clone()); - - // vec![Contribution { - // name: flow - // .clone() - // .snoc(name_seg("ToOutput")) - // .snoc(cod.clone().only().unwrap()), - // monomial: term, - // parameter: MassActionParameter::Balanced { flow: flow.clone() }, - // target: cod.clone(), - // }] - // } - // }] - // } - // MassConservationType::Unbalanced(_) => { - // vec![{ - // |flow, model| { - // let flow_interface = flow_interface(model, flow); - // let dom = flow_interface.input_stock; - // let cod = flow_interface.output_stock; - // // N.B. We completely ignore negative links. - // let mut term = flow_interface.input_pos_link_doms; - // term.push(dom.clone()); - - // vec![Contribution { - // name: flow - // .clone() - // .snoc(name_seg("ToOutput")) - // .snoc(cod.clone().only().unwrap()), - // monomial: term, - // parameter: MassActionParameter::Unbalanced { - // direction: Direction::IncomingFlow, - // parameter: RateParameter::PerFlow { flow: flow.clone() }, - // }, - // target: cod.clone(), - // }] - // } - // }] - // } - // }, - // }; - - // ODESemanticsBuilder { - // variable_builders, - // contribution_builders: vec![flow_input, flow_output], - // } - // } } /// Data defining an unbalanced mass-action ODE problem for a model. diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index e6d5cc359..4371b2382 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -148,6 +148,7 @@ impl PolynomialODESystemBuilder

{ } } +// TODO: fix documentation /// This trait is where we give the actual functions for building the data that /// `build_system_from_ode_semantics()` needs in order to construct /// the multicategory. The implementation of `build_semantics()` is where the actual @@ -162,9 +163,10 @@ impl PolynomialODESystemBuilder

{ /// some `MassConservationType`, whose value is fundamental in constructing the semantics). /// However, this is left to the user: the type checker will not enforce any of these extras. pub trait ODESemanticsAnalysis: Default { - // TODO: change the return type from a tuple to something better + /// TODO: documentation. fn build_system_builder(&self, model: &T) -> PolynomialODESystemBuilder

; + /// TODO: documentation. fn build_system(&self, model: &T) -> PolynomialSystem, i8> { let builder = self.build_system_builder(model); PolynomialODEAnalysis::default() @@ -177,18 +179,20 @@ pub trait ODESemanticsAnalysis: #[derive(Clone)] pub struct Contribution { /// The name of the multimorphism. - pub name: QualifiedName, - /// The source of the multimorphism (a list of objects), to be interpreted - /// as the monomial given by the product of all the list elements. - pub monomial: Vec, - /// The parameter (coefficient) to be associated with this contribution. - pub parameter: P, + pub id: QualifiedName, /// The target of the multimorphism, to be interpreted as the variable whose /// first derivative is affected by the monomial. pub target: QualifiedName, + /// The sign of a contribution. + pub sign: ContributionSign, + /// The parameter (coefficient) to be associated with this contribution. + pub parameter: P, + /// The source of the multimorphism (a list of objects), to be interpreted + /// as the monomial given by the product of all the list elements. + pub monomial: Vec, } -/// The sign of the contribution, since we work in *signed* multicategories. +/// The sign of a contribution, since we work in *signed* multicategories. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] pub enum ContributionSign { /// Positive contribution: (d/dt)y -= x. From 0ac6aae8eba307293d8267de201cff29f3281022 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 15 Jun 2026 12:36:30 +0100 Subject: [PATCH 09/38] ENH: Documentation --- .../src/stdlib/analyses/ode/linear_ode.rs | 4 +- .../src/stdlib/analyses/ode/mass_action.rs | 33 +++-- .../src/stdlib/analyses/ode/ode_semantics.rs | 118 +++++++++--------- 3 files changed, 78 insertions(+), 77 deletions(-) diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 0994b8a34..2ebf441c7 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -1,7 +1,7 @@ //! Linear constant-coefficient (LCC) first-order ODE analysis of models. //! -//! This follows the structure of [`ode::ode_semantics`], implementing `ODESemantics` for -//! the struct `LCCSemantics`. For heritage reasons, "LCC" is sometimes referred to as "LinearODE". +//! This follows the structure of [`ode::ode_semantics`], implementing `ODESemantics` for the struct +//! `LCCSemantics`. For heritage reasons, "LCC" is sometimes referred to as "LinearODE". //! //! [`ode::ode_semantics`]: crate::stdlib::analyses::ode::ode_semantics diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index ae3fb425a..f5371a1d5 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -218,7 +218,7 @@ impl for output in outputs.clone() { let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap()); // The transition - // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // T : [x_1, ..., x_n] -> [y_1, ..., y_n] // becomes the contributions // \dot{y_i} += Parameter_! \cdot x_1...x_n // where Parameter_! depends on `mass_conservation_type`: @@ -256,7 +256,7 @@ impl for input in inputs.clone() { let id = input.cons(name_seg("ToInput")).cons(transition.only().unwrap()); // The transition - // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // T : [x_1, ..., x_n] -> [y_1, ..., y_n] // becomes the contributions // \dot{x_i} -= Parameter_! \cdot x_1...x_n // where Parameter_! depends on `mass_conservation_type`: @@ -345,24 +345,23 @@ impl let interface = flow_interface(model, &flow); let (input, output) = (interface.input_stock, interface.output_stock); - // TODO: explain this monomial + // Each flow gives a positive contribution to the term corresponding to its output, and + // a negative contribution to the term corresponding to its input; the term is given by + // the product of the input with the sources of all incoming links. let monomial = [interface.input_pos_link_doms, vec![input.clone()]].concat(); - // TODO: fix this comment - // Each transition gives a positive contribution to each term corresponding to - // one of its outputs, and a negative contribution to each term corresponding to - // one of its inputs. For example, a single transition T: [a,b] -> [x,y] will give - // four contributions, namely two positive contributions (ab -> x , ab -> y) - // and two negative (ab -> a , ab -> b). - - // TODO: fix this comment too - // The transition - // T: [x_1, ..., x_n] -> [y_1, ..., y_n] + // The flow + // F : a -> b + // with links + // l_i : x_i -> F // becomes the contributions - // \dot{x_i} -= Parameter_! \cdot x_1...x_n - // where Parameter_! depends on `mass_conservation_type`: - // Balanced => Parameter_T - // Unbalanced::PerFlow => Parameter_T^outflow + // \dot{b} += Parameter_! \cdot a x_1.. x_n + // \dot{a} -= Parameter_? \cdot a x_1.. x_n + // where Parameter_! and Parameter_? depend on `mass_conservation_type`: + // Balanced => Parameter_! = Parameter_F + // Parameter_? = Parameter_F + // Unbalanced::PerFlow => Parameter_! = Parameter_F^inflow + // Parameter_? = Parameter_F^outflow let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); let output_parameter = match self.mass_conservation_type { diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 4371b2382..b23a4a67a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -1,25 +1,25 @@ //! Analyses for different ODE semantics on models. //! -//! Following inspiration from schema migration, we define the data of an ODE semantics on -//! models in a theory to be a migration into the theory of multicategories (more specifically, -//! [`th_polynomial_ode_system()`]). We then simply use the "canonical" interpretation of -//! multicategories as systems of polynomial ODEs as implemented in [`ode::polynomial_ode`] -//! (and see there also for documentation on this interpretation of models as systems of ODEs). +//! Inspired by schema migration, we define the data of an ODE semantics on models in a theory to +//! consist of (in particular) a `PolynomialODESystemBuilder`, which contains all the data needed +//! for [`ode::polynomial_ode::PolynomialODEAnalysis`] to do the following: //! -//! That is, we take some `model: T` where `T: DblModelForODESemantics`, and from this use -//! `ODESemanticsAnalysis::build_semantics()` to build `ode_model: ModalDblModel` (to be -//! understood as a model for [`th_polynomial_ode_system()`]), and finally use -//! [`ode::polynomial_ode`] to build `system: PolynomialSystem, i8>` -//! where `P: ODEParameterType`. Finally, for an actual front-end analysis, we use -//! `ODESemanticsProblemData::extend_scalars()` and `ODESemanticsProblemData::build_analysis()` -//! to construct `analysis: ODEAnalysis>`, which we can feed into -//! the ODE solver. +//! 1. Build the system as a model of the theory of polynomial ODE systems (i.e. multicategories) +//! with abstract coefficients, using `build_system_custom_parameters()`. +//! 2. Substitute in numerical coefficients, using `extend_polynomial_ode_scalars()`. +//! 3. Build an `ODEAnalysis>` that can be fed into an ODE solver, +//! using `polynomial_ode_analysis()`. //! -//! To implement a new ODE semantics for models in some theory, one essentially needs to create -//! an empty struct and implement `ODESemantics`, and then follow the compiler. +//! In short, this module constructs multicategories from models, and [`ode::polynomial_ode`] then +//! constructs `PolynomialSystem` from multicategories. +//! +//! To implement a new ODE semantics for models in some theory, one essentially needs to create an +//! empty struct and implement `ODESemantics`, and then follow the compiler. For more documentation, +//! see [`ode::polynomial_ode`]; for an example implementation, see [`ode::mass_action`]. //! -//! [`th_polynomial_ode_system()`]: crate::stdlib::theories //! [`ode::polynomial_ode`]: crate::stdlib::analyses::ode::polynomial_ode +//! [`ode::polynomial_ode::PolynomialODEAnalysis`]: crate::stdlib::analyses::ode::polynomial_ode::PolynomialODEAnalysis +//! [`ode::mass_action`]: crate::stdlib::analyses::ode::mass_action use indexmap::IndexMap; use nalgebra::DVector; @@ -44,19 +44,18 @@ use crate::{ pub trait ODESemantics { /// The type of the model for which these ODE semantics are intended. type ModelType: DblModelForODESemantics; - /// The type of the parameters associated to each contribution in the multicategory - /// built from the model. The "default" value for this would be `QualifiedName`, but - /// it can be useful to have a more descriptive type. For example, we might wish for - /// certain parameters to be identified with one another, or to be rendered differently - /// in debug/LaTeX output. An instructive example of this is `LotkaVolterraParameter`; - /// a more complicated example is `MassActionParameter`. + /// The type of the parameters associated to each contribution in the multicategory built from + /// the model. The "default" value for this would be `QualifiedName`, but it can be useful to + /// have a more descriptive type. For example, we might wish for certain parameters to be + /// identified with one another, or to be rendered differently in debug/LaTeX output. For an + /// instructive example, see `MassActionParameter` in `ode::mass_action`. type ParameterType: ODEParameterType; - /// The data describing the things that the ODE semantics "cares about". (See the - /// documentation for `ODESemanticsAnalysis`). + /// The data describing the things that the ODE semantics "cares about". (See the documentation + /// for `ODESemanticsAnalysis`). type AnalysisType: ODESemanticsAnalysis; /// The data describing how to turn the algebraic system of equations into a simulation, - /// including e.g. which values that appear in the front-end analysis correspond to - /// which parameters within the equations. + /// including e.g. which values that appear in the front-end analysis correspond to which + /// parameters within the equations. type ProblemDataType: ODESemanticsProblemData; } @@ -76,32 +75,33 @@ impl DblModelForODESemantics for ModalDblModel {} /// (again) these bounds are not particularly restrictive. pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} -// TODO: this is the bare minimum +/// The simplest type for parameters is `QualifiedName`. impl ODEParameterType for QualifiedName {} /// Builder for polynomial ODE systems. /// -/// This struct is just a convenient interface to construct a model of the -/// [theory of polynomial ODE systems](th_polynomial_ode_system). Being an -/// ordinary mutable Rust struct, it does *not* constitute a declarative -/// language to define ODE semantics for models of other theories. However, the -/// idea is that it should be used in a style that can mechanically translated -/// to a future declarative language for model migration. +/// This struct is just a convenient interface to construct a model of the theory of polynomial ODE +/// systems. Being an ordinary mutable Rust struct, it does *not* constitute a declarative language +/// to define ODE semantics for models of other theories. However, the idea is that it should be +/// used in a style that can mechanically translated to a future declarative language for model +/// migration. #[derive(Clone)] pub struct PolynomialODESystemBuilder { - // TODO: should this struct also have types ????? model: ModalDblModel, - associated_parameters: HashMap + associated_parameters: HashMap, } -impl Default for PolynomialODESystemBuilder

{ +impl Default for PolynomialODESystemBuilder

{ fn default() -> Self { let th = th_signed_polynomial_ode_system(); - Self { model: ModalDblModel::new(th.into()), associated_parameters: HashMap::new() } + Self { + model: ModalDblModel::new(th.into()), + associated_parameters: HashMap::new(), + } } } -impl PolynomialODESystemBuilder

{ +impl PolynomialODESystemBuilder

{ /// Constructs an empty ODE system. pub fn new() -> Self { Self::default() @@ -112,7 +112,8 @@ impl PolynomialODESystemBuilder

{ self.model } - /// TODO: documentation. + /// Returns the HashMap of associated parameters, giving the term of type `P: ODEParameterType` + /// corresponding to each monomial. pub fn associated_parameters(self) -> HashMap { self.associated_parameters } @@ -148,29 +149,30 @@ impl PolynomialODESystemBuilder

{ } } -// TODO: fix documentation -/// This trait is where we give the actual functions for building the data that -/// `build_system_from_ode_semantics()` needs in order to construct -/// the multicategory. The implementation of `build_semantics()` is where the actual -/// migration (i.e. the actual ODE semantics) is specified, but `build_system()` can -/// essentially always use the default implementation given below. +/// This trait is where we define the actual ODE semantics, in the implementation of +/// `build_system_builder()`; `build_system()` will almost certainly always use the default +/// implementation given below. /// -/// Note that the type that implements this trait is also where you are expected to state -/// everything that your semantics "cares about". For example, the expected minimum is to -/// give the values of `ObType` and `MorType` that you want to distinguish between and -/// iterate over. It can also hold any extra data upon which your semantics can depend -/// (see e.g. `ode::mass_action::PetriNetMassActionAnalysis`, which contains the data of -/// some `MassConservationType`, whose value is fundamental in constructing the semantics). -/// However, this is left to the user: the type checker will not enforce any of these extras. +/// Note that the type that implements this trait is also where you are expected to state everything +/// that your semantics "cares about". For example, the default minimum is to give the values of +/// `ObType` and `MorType` that you want to distinguish between and iterate over. It can also hold +/// any extra data upon which your semantics can depend (see e.g. +/// `ode::mass_action::PetriNetMassActionAnalysis`, which contains the data of some +/// `MassConservationType`, whose value is fundamental in constructing the semantics). However, +/// this is left to the user: the type checker will *not* enforce any of these extras. pub trait ODESemanticsAnalysis: Default { - /// TODO: documentation. + /// The implementation of this function is what contains the actual data of the ODE semantics, + /// in the form of a `PolynomialODESystemBuilder`. fn build_system_builder(&self, model: &T) -> PolynomialODESystemBuilder

; - /// TODO: documentation. + /// We simply feed the `PolynomialODESystemBuilder` constructed by the above function into + /// `PolynomialODEAnalysis::build_system_custom_parameters`. fn build_system(&self, model: &T) -> PolynomialSystem, i8> { let builder = self.build_system_builder(model); - PolynomialODEAnalysis::default() - .build_system_custom_parameters(&builder.clone().model(), builder.associated_parameters()) + PolynomialODEAnalysis::default().build_system_custom_parameters( + &builder.clone().model(), + builder.associated_parameters(), + ) } } @@ -203,8 +205,8 @@ pub enum ContributionSign { /// The trait describing how to turn the formal system of ODEs into a numerical problem, to be /// solved by an ODE solver and presented to the front-end. At minimum, such data must contain -/// initial values for variables and the intended duration of simulation, as well as the method -/// for converting the parameters (which are of type `ODEParameterType`) into floats. +/// initial values for variables and the intended duration of simulation, as well as the method for +/// converting the parameters (which are of type `ODEParameterType`) into floats. // REQUEST | If you look at a struct that implements this trait (such as `LotkaVolterraProblemData`), // FOR | there are a lot of serde statements going on. Should I be able to just move them // FEEDBACK | (that is, those that come *before* the struct) here and have things all work? I'm still From 18aa6a29d1f4493a85bcac2cf3805a9e8bc5c30e Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 15 Jun 2026 20:34:59 +0100 Subject: [PATCH 10/38] WIP: LaTeX traits --- packages/catlog-wasm/src/latex.rs | 137 +----------------- packages/catlog/src/latex.rs | 59 ++++++++ packages/catlog/src/lib.rs | 1 + .../catlog/src/simulate/ode/polynomial.rs | 46 +++--- .../src/stdlib/analyses/ode/linear_ode.rs | 21 ++- .../src/stdlib/analyses/ode/lotka_volterra.rs | 24 ++- .../src/stdlib/analyses/ode/mass_action.rs | 29 ++++ .../src/stdlib/analyses/ode/ode_semantics.rs | 21 ++- .../src/stdlib/analyses/ode/polynomial_ode.rs | 10 +- packages/catlog/src/zero/alg.rs | 12 +- packages/catlog/src/zero/rig.rs | 10 +- 11 files changed, 184 insertions(+), 186 deletions(-) create mode 100644 packages/catlog/src/latex.rs diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 4234fef0b..165d21927 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -1,19 +1,10 @@ //! Auxiliary structs and glue code for any LaTeX code being passed through analyses. -use serde::{Deserialize, Serialize}; -use tsify::Tsify; - -use catlog::simulate::ode::LatexEquation; use catlog::stdlib::analyses::ode; use catlog::zero::QualifiedName; use super::model::DblModel; -/// Symbolic equations in LaTeX format. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct LatexEquations(pub Vec); - /// Creates a closure that formats object names for LaTeX output. pub(crate) fn latex_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { |id: &QualifiedName| { @@ -26,8 +17,6 @@ pub(crate) fn latex_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> Str } } -/// Creates a closure that formats morphism names for mass-action LaTeX output. -/// /// When a morphism has a label, it is used directly. When unnamed, the label /// falls back to the domain→codomain format (e.g., `X \to Y`). pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { @@ -51,135 +40,11 @@ pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> St } } -/// Creates a closure that formats morphism names for mass-action LaTeX output. -/// -/// When a morphism has a label, it is used directly. When unnamed, the label -/// falls back to the domain→codomain format (e.g., `X \to Y`). -pub(crate) fn latex_mor_names_mass_action( - model: &DblModel, -) -> impl Fn(&ode::MassActionParameter) -> String { - // Returns a LaTeX fragment for a transition, suitable for use as a subscript. - // Named morphisms produce `\text{name}`, unnamed ones produce - // `\text{dom} \to \text{cod}` so that `\to` is in math mode. - let transition_subscript = |transition: &QualifiedName| -> String { - if let Some(label) = model.mor_namespace.label(transition) { - format!("\\text{{{label}}}") - } else { - let (dom, cod) = model - .mor_generator_dom_cod_label_strings(transition) - .expect("Morphism in equation system should have domain and codomain"); - format!("\\text{{{dom}}} \\to \\text{{{cod}}}") - } - }; - - move |id: &ode::MassActionParameter| match id { - ode::MassActionParameter::Balanced { flow: transition } => { - let sub = transition_subscript(transition); - format!("r_{{{sub}}}") - } - ode::MassActionParameter::Unbalanced { direction, parameter } => { - match (direction, parameter) { - ( - ode::Direction::IncomingFlow, - ode::RateParameter::PerFlow { flow: transition }, - ) => { - let sub = transition_subscript(transition); - format!("\\rho_{{{sub}}}") - } - ( - ode::Direction::OutgoingFlow, - ode::RateParameter::PerFlow { flow: transition }, - ) => { - let sub = transition_subscript(transition); - format!("\\kappa_{{{sub}}}") - } - ( - ode::Direction::IncomingFlow, - ode::RateParameter::PerStock { flow: transition, stock: place }, - ) => { - let sub = transition_subscript(transition); - let output_place_label = model.ob_namespace.label_string(place); - format!("\\rho_{{{sub}}}^{{\\text{{{output_place_label}}}}}") - } - ( - ode::Direction::OutgoingFlow, - ode::RateParameter::PerStock { flow: transition, stock: place }, - ) => { - let sub = transition_subscript(transition); - let input_place_label = model.ob_namespace.label_string(place); - format!("\\kappa_{{{sub}}}^{{\\text{{{input_place_label}}}}}") - } - } - } - } -} - -/// Creates a closure that formats morphism names for Lotka-Volterra LaTeX output. -/// -/// When a morphism has a label, it is used directly. When unnamed, the label -/// falls back to the domain→codomain format (e.g., `X \to Y`). -pub(crate) fn latex_mor_names_lotka_volterra( - model: &DblModel, -) -> impl Fn(&ode::LotkaVolterraParameter) -> String { - // Returns a LaTeX fragment for a transition, suitable for use as a subscript. - // Named morphisms produce `\text{name}`, unnamed ones produce - // `\text{dom} \to \text{cod}` so that `\to` is in math mode. - let transition_subscript = |transition: &QualifiedName| -> String { - if let Some(label) = model.mor_namespace.label(transition) { - format!("\\text{{{label}}}") - } else { - let (dom, cod) = model - .mor_generator_dom_cod_label_strings(transition) - .expect("Morphism in equation system should have domain and codomain"); - format!("\\text{{{dom}}} \\to \\text{{{cod}}}") - } - }; - - move |id: &ode::LotkaVolterraParameter| match id { - ode::LotkaVolterraParameter::Growth { variable } => { - format!("g_{{{variable}}}") - } - ode::LotkaVolterraParameter::Interaction { link } => { - let sub = transition_subscript(link); - format!("k_{{{sub}}}") - } - } -} - -/// Creates a closure that formats morphism names for mass-action LaTeX output. -/// -/// When a morphism has a label, it is used directly. When unnamed, the label -/// falls back to the domain→codomain format (e.g., `X \to Y`). -pub(crate) fn latex_mor_names_linear_ode( - model: &DblModel, -) -> impl Fn(&ode::LCCParameter) -> String { - // Returns a LaTeX fragment for a transition, suitable for use as a subscript. - // Named morphisms produce `\text{name}`, unnamed ones produce - // `\text{dom} \to \text{cod}` so that `\to` is in math mode. - let transition_subscript = |transition: &QualifiedName| -> String { - if let Some(label) = model.mor_namespace.label(transition) { - format!("\\text{{{label}}}") - } else { - let (dom, cod) = model - .mor_generator_dom_cod_label_strings(transition) - .expect("Morphism in equation system should have domain and codomain"); - format!("\\text{{{dom}}} \\to \\text{{{cod}}}") - } - }; - - move |id: &ode::LCCParameter| match id { - ode::LCCParameter::Parameter { morphism } => { - let sub = transition_subscript(morphism); - format!("\\lambda_{{{sub}}}") - } - } -} - #[cfg(test)] mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; - use catlog::simulate::ode::LatexEquation; + use catlog::latex::LatexEquation; use catlog::stdlib::analyses::ode::{StockFlowMassActionAnalysis, ode_semantics::*}; use catlog::stdlib::{analyses::ode, theories}; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs new file mode 100644 index 000000000..e82625924 --- /dev/null +++ b/packages/catlog/src/latex.rs @@ -0,0 +1,59 @@ +//! Code for passing around LaTeX representations of data. +//! +//! We reserve the `std::Display` trait for unicode-style display of mathematical +//! objects, so here we provide structure for passing around LaTeX code for such. +//! +//! N.B. Although the software is called "LaTeX" we will consistently ignore the +//! correct capitalisation and simply write latex or Latex in our code. + +use std::fmt; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + +/// We should mark which strings are to be parsed as LaTeX. +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Latex(pub String); + +/// Implement `Display` for Latex by simply printing out the string it contains. +impl fmt::Display for Latex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// An object that can be rendered to LaTeX. +pub trait ToLatex: fmt::Display { + /// Convert the object to its LaTeX representation. Here the default + /// implementation simply falls back to `Display`. + fn to_latex(&self) -> Latex { + Latex(self.to_string()) + } +} + +/// An equation in LaTeX format with a left-hand side and a right-hand side. +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct LatexEquation { + /// The left-hand side of the equation. + pub lhs: Latex, + /// The right-hand side of the equation. + pub rhs: Latex, +} + +/// Symbolic equations in LaTeX format. +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct LatexEquations(pub Vec); + +/// An object that can be rendered to a collection of LaTeX equations (of the form +/// "lhs = rhs"). +pub trait ToLatexEquations { + /// Convert the object to the LaTeX equations. + fn to_latex_equations(&self) -> LatexEquations; +} diff --git a/packages/catlog/src/lib.rs b/packages/catlog/src/lib.rs index a7fcbd387..da82a958a 100644 --- a/packages/catlog/src/lib.rs +++ b/packages/catlog/src/lib.rs @@ -20,6 +20,7 @@ pub mod refs; pub mod egglog_util; +pub mod latex; pub mod validate; pub mod dbl; diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 5e162ffed..d417d9e9e 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -9,14 +9,10 @@ use indexmap::IndexMap; use nalgebra::DVector; use num_traits::{One, Pow, Zero}; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde-wasm")] -use tsify::Tsify; - #[cfg(test)] use super::ODEProblem; use super::ODESystem; +use crate::latex::{Latex, LatexEquation, LatexEquations, ToLatex, ToLatexEquations}; use crate::zero::{alg::Polynomial, rig::DisplayCoef}; /// A system of polynomial differential equations. @@ -104,25 +100,13 @@ where self.components .iter() .map(|(var, poly)| LatexEquation { - lhs: format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}"), + lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), rhs: poly.to_latex(), }) .collect() } } -#[derive(Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] -/// An equation in LaTeX format with a left-hand side and a right-hand side. -pub struct LatexEquation { - /// The left-hand side of the equation. - pub lhs: String, - /// The right-hand side of the equation. - pub rhs: String, -} - impl PolynomialSystem where Var: Clone + Hash + Ord, @@ -173,6 +157,32 @@ where } } +impl ToLatexEquations for PolynomialSystem +where + Var: Display, + Coef: Display + PartialEq + One + DisplayCoef + Clone + Neg, + Exp: Display + PartialEq + One, +{ + + /// Converts to equations as LaTeX strings. + fn to_latex_equations(&self) -> LatexEquations + where + Var: Display, + Coef: Display + PartialEq + One + Neg, + Exp: Display + PartialEq + One, + { + LatexEquations( + self.components + .iter() + .map(|(var, poly)| LatexEquation { + lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), + rhs: poly.to_latex(), + }) + .collect() + ) + } +} + /// A numerical system of polynomial differential equations. /// /// Such a system is ready for use in numerical solvers: the coefficients are diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 2ebf441c7..0b822e15c 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -15,6 +15,7 @@ use tsify::Tsify; use super::Parameter; use crate::dbl::model::{FpDblModel, MutDblModel}; +use crate::latex::{Latex, ToLatex}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::{ @@ -54,6 +55,16 @@ impl fmt::Display for LCCParameter { } } +impl ToLatex for LCCParameter { + fn to_latex(&self) -> Latex { + match self { + Self::Parameter { morphism } => { + Latex(format!("\\lambda_{{{morphism}}}")) + } + } + } +} + impl ODEParameterType for LCCParameter {} /// Linear ODE analysis for causal loop diagrams (CLDs). @@ -194,7 +205,7 @@ mod test { use super::*; use crate::{ dbl::model::MutDblModel, - simulate::ode::LatexEquation, + latex::LatexEquation, stdlib::{models::*, theories::*}, }; @@ -245,12 +256,12 @@ mod test { let sys = LCCAnalysis::default().build_system(&model); let expected = vec![ LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), - rhs: "-Parameter(negative) \\cdot y".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex("-Parameter(negative) \\cdot y".to_string()), }, LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), - rhs: "Parameter(positive) \\cdot x".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("Parameter(positive) \\cdot x".to_string()), }, ]; assert_eq!(expected, sys.to_latex_equations()); diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index f335ca12d..17a3872f8 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -15,6 +15,7 @@ use tsify::Tsify; use super::Parameter; use crate::dbl::model::{FpDblModel, MutDblModel}; +use crate::latex::{Latex, ToLatex}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::{ @@ -63,6 +64,19 @@ impl fmt::Display for LotkaVolterraParameter { } } +impl ToLatex for LotkaVolterraParameter { + fn to_latex(&self) -> Latex { + match self { + Self::Growth { variable } => { + Latex(format!("\\g_{{{variable}}}")) + }, + Self::Interaction { link } => { + Latex(format!("\\k_{{{link}}}")) + }, + } + } +} + impl ODEParameterType for LotkaVolterraParameter {} /// This Lotka-Volterra ODE analysis is intended for application to CLDs. @@ -228,7 +242,7 @@ mod test { use super::*; use crate::{ dbl::model::MutDblModel, - simulate::ode::LatexEquation, + latex::LatexEquation, stdlib::{models::*, theories::*}, }; @@ -279,12 +293,12 @@ mod test { let sys = LotkaVolterraAnalysis::default().build_system(&model); let expected = vec![ LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), - rhs: "Growth(x) \\cdot x - Interaction(negative) \\cdot x \\cdot y".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex("Growth(x) \\cdot x - Interaction(negative) \\cdot x \\cdot y".to_string()), }, LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), - rhs: "Interaction(positive) \\cdot x \\cdot y + Growth(y) \\cdot y".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("Interaction(positive) \\cdot x \\cdot y + Growth(y) \\cdot y".to_string()), }, ]; assert_eq!(expected, sys.to_latex_equations()); diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index f5371a1d5..1c733f702 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use super::Parameter; +use crate::latex::{Latex, ToLatex}; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::*; use crate::stdlib::analyses::petri::transition_interface; @@ -160,6 +161,34 @@ impl fmt::Display for MassActionParameter { } } +impl ToLatex for MassActionParameter { + fn to_latex(&self) -> Latex { + match self { + Self::Balanced { flow: transition } => Latex(format!("r_{{{transition}}}")), + Self::Unbalanced { direction, parameter } => match (direction, parameter) { + (Direction::IncomingFlow, RateParameter::PerFlow { flow: transition }) => { + Latex(format!("\\rho_{{{transition}}}")) + } + (Direction::OutgoingFlow, RateParameter::PerFlow { flow: transition }) => { + Latex(format!("\\kappa_{{{transition}}}")) + } + ( + Direction::IncomingFlow, + RateParameter::PerStock { flow: transition, stock: place }, + ) => { + Latex(format!("\\rho_{{{transition}}}^{{\\text{{{place}}}}}")) + } + ( + Direction::OutgoingFlow, + RateParameter::PerStock { flow: transition, stock: place }, + ) => { + Latex(format!("\\kappa_{{{transition}}}^{{\\text{{{place}}}}}")) + } + }, + } + } +} + impl ODEParameterType for MassActionParameter {} /// Mass-action ODE analysis for Petri nets. diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index b23a4a67a..633cf6ef6 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -30,14 +30,10 @@ use crate::{ modal::{List, ModeApp}, model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, theory::{NonUnital, Unital}, - }, - one::FgCategory, - simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, - stdlib::{ + }, latex::{Latex, ToLatex}, one::FgCategory, simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, stdlib::{ analyses::ode::{ODEAnalysis, Parameter, PolynomialODEAnalysis}, th_signed_polynomial_ode_system, - }, - zero::{QualifiedName, name}, + }, zero::{QualifiedName, name} }; /// The trait for an ODE semantics on models. @@ -72,10 +68,19 @@ impl DblModelForODESemantics for ModalDblModel {} impl DblModelForODESemantics for ModalDblModel {} /// The type of the parameters in the ODE system need to be sufficiently nice, though -/// (again) these bounds are not particularly restrictive. -pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} +/// (again) these bounds are not particularly restrictive. The two that will need the most +/// manual effort for implementation are `Display` and `ToLatex`, which govern how these +/// coefficients should be rendered. The `Display` trait is used for debugging whereas the +/// `ToLatex` trait is used for user-facing display. +pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display + ToLatex {} /// The simplest type for parameters is `QualifiedName`. +impl ToLatex for QualifiedName { + fn to_latex(&self) -> Latex { + Latex(self.to_string()) + } +} + impl ODEParameterType for QualifiedName {} /// Builder for polynomial ODE systems. diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index d1fcfa08e..c4568ff7e 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -199,7 +199,7 @@ mod tests { use super::*; use crate::{ - simulate::ode::LatexEquation, + latex::{Latex, LatexEquation}, stdlib::{models::*, theories::*}, tt, }; @@ -226,12 +226,12 @@ mod tests { let sys = PolynomialODEAnalysis::default().build_system(&model); let expected = vec![ LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} A".to_string(), - rhs: "A_growth \\cdot A - BA_interaction \\cdot A \\cdot B".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} A".to_string()), + rhs: Latex("A_growth \\cdot A - BA_interaction \\cdot A \\cdot B".to_string()), }, LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} B".to_string(), - rhs: "AB_interaction \\cdot A \\cdot B + B_growth \\cdot B".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} B".to_string()), + rhs: Latex("AB_interaction \\cdot A \\cdot B + B_growth \\cdot B".to_string()), }, ]; assert_eq!(expected, sys.to_latex_equations()); diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 6899222f9..01e786108 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -9,6 +9,8 @@ use std::ops::{Add, AddAssign, Mul, Neg}; use derivative::Derivative; +use crate::latex::{Latex, ToLatex}; + use super::rig::*; /// A commutative algebra over a commutative ring. @@ -147,16 +149,16 @@ where } } -impl Polynomial +impl ToLatex for Polynomial where Var: Display, Coef: Display + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + PartialEq + One, { /// Convert to a LaTeX string, formatting each monomial via [`Monomial::to_latex`]. - pub fn to_latex(&self) -> String { + fn to_latex(&self) -> Latex { let fmt_term = |coef: &Coef, monomial: &Monomial| -> String { - let monomial = monomial.to_latex(); + let Latex(monomial) = monomial.to_latex(); if coef.is_one() { monomial } else if *coef == Coef::one().neg() { @@ -170,7 +172,7 @@ where let mut terms = (&self.0).into_iter(); let Some((coef, monomial)) = terms.next() else { - return "0".to_string(); + return Latex("0".to_string()); }; let mut output = fmt_term(coef, monomial); for (coef, monomial) in terms { @@ -182,7 +184,7 @@ where output.push_str(&fmt_term(coef, monomial)); } } - output + Latex(output) } } diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index 800330b34..c1dceddcd 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -22,6 +22,8 @@ use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use derivative::Derivative; use duplicate::duplicate_item; +use crate::latex::{Latex, ToLatex}; + /// A commutative monoid, written additively. pub trait AdditiveMonoid: Add + Zero {} @@ -586,13 +588,13 @@ where } } -impl Monomial +impl ToLatex for Monomial where Var: Display, Exp: Display + PartialEq + One, { /// Convert to a LaTeX string, separating variables with `\cdot`. - pub fn to_latex(&self) -> String { + fn to_latex(&self) -> Latex { let fmt_power = |var: &Var, exp: &Exp| { if exp.is_one() { format!("{var}") @@ -607,14 +609,14 @@ where }; let mut pairs = self.0.iter(); let Some((var, exp)) = pairs.next() else { - return "1".to_string(); + return Latex("1".to_string()); }; let mut output = fmt_power(var, exp); for (var, exp) in pairs { output.push_str(" \\cdot "); output.push_str(&fmt_power(var, exp)); } - output + Latex(output) } } From 441cb5ae72c3516e3abdfbfb80dfa54d6c05a2b9 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 15 Jun 2026 12:36:30 +0100 Subject: [PATCH 11/38] ENH: Documentation --- .../stdlib/analyses/ode/#ode_semantics.rs# | 256 ------------------ 1 file changed, 256 deletions(-) delete mode 100644 packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# diff --git a/packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# b/packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# deleted file mode 100644 index 9eb61f817..000000000 --- a/packages/catlog/src/stdlib/analyses/ode/#ode_semantics.rs# +++ /dev/null @@ -1,256 +0,0 @@ -//! Analyses for different ODE semantics on models. -//! -//! Following inspiration from schema migration, we define the data of an ODE semantics on -//! models in a theory to be a migration into the theory of multicategories (more specifically, -//! [`th_polynomial_ode_system()`]). We then simply use the "canonical" interpretation of -//! multicategories as systems of polynomial ODEs as implemented in [`ode::polynomial_ode`] -//! (and see there also for documentation on this interpretation of models as systems of ODEs). -//! -//! That is, we take some `model: T` where `T: DblModelForODESemantics`, and from this use -//! `ODESemanticsAnalysis::build_semantics()` to build `ode_model: ModalDblModel` (to be -//! understood as a model for [`th_polynomial_ode_system()`]), and finally use -//! [`ode::polynomial_ode`] to build `system: PolynomialSystem, i8>` -//! where `P: ODEParameterType`. Finally, for an actual front-end analysis, we use -//! `ODESemanticsProblemData::extend_scalars()` and `ODESemanticsProblemData::build_analysis()` -//! to construct `analysis: ODEAnalysis>`, which we can feed into -//! the ODE solver. -//! -//! To implement a new ODE semantics for models in some theory, one essentially needs to create -//! an empty struct and implement `ODESemantics`, and then follow the compiler. -//! -//! [`th_polynomial_ode_system()`]: crate::stdlib::theories -//! [`ode::polynomial_ode`]: crate::stdlib::analyses::ode::polynomial_ode - -use indexmap::IndexMap; -use nalgebra::DVector; -use std::{collections::HashMap, fmt}; - -use crate::{ - dbl::{ - modal::{List, ModeApp}, - model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, - theory::{NonUnital, Unital}, - }, - one::FgCategory, - simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, - stdlib::{ - analyses::ode::{ODEAnalysis, Parameter, PolynomialODEAnalysis}, - th_signed_polynomial_ode_system, - }, - zero::{QualifiedName, name}, -}; - -/// The trait for an ODE semantics on models. -pub trait ODESemantics { - /// The type of the model for which these ODE semantics are intended. - type ModelType: DblModelForODESemantics; - /// The type of the parameters associated to each contribution in the multicategory - /// built from the model. The "default" value for this would be `QualifiedName`, but - /// it can be useful to have a more descriptive type. For example, we might wish for - /// certain parameters to be identified with one another, or to be rendered differently - /// in debug/LaTeX output. An instructive example of this is `LotkaVolterraParameter`; - /// a more complicated example is `MassActionParameter`. - type ParameterType: ODEParameterType; - /// The data describing the things that the ODE semantics "cares about". (See the - /// documentation for `ODESemanticsAnalysis`). - type AnalysisType: ODESemanticsAnalysis; - /// The data describing how to turn the algebraic system of equations into a simulation, - /// including e.g. which values that appear in the front-end analysis correspond to - /// which parameters within the equations. - type ProblemDataType: ODESemanticsProblemData; -} - -/// The models for which we support ODE semantics need to be sufficiently nice, though -/// these bounds are not particularly restrictive. -pub trait DblModelForODESemantics: - FgCategory + MutDblModel + Clone -{ -} - -impl DblModelForODESemantics for DiscreteDblModel {} -impl DblModelForODESemantics for DiscreteTabModel {} -impl DblModelForODESemantics for ModalDblModel {} -impl DblModelForODESemantics for ModalDblModel {} - -/// The type of the parameters in the ODE system need to be sufficiently nice, though -/// (again) these bounds are not particularly restrictive. -pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display {} - -// TODO: this is the bare minimum -impl ODEParameterType for QualifiedName {} - -/// Builder for polynomial ODE systems. -/// -/// This struct is just a convenient interface to construct a model of the -/// [theory of polynomial ODE systems](th_polynomial_ode_system). Being an -/// ordinary mutable Rust struct, it does *not* constitute a declarative -/// language to define ODE semantics for models of other theories. However, the -/// idea is that it should be used in a style that can mechanically translated -/// to a future declarative language for model migration. -/// -/// Since an ODE semantics often has contributions of several types, a useful -/// pattern is to use qualified names with an initial segment indicating the -/// type of contribution. This corresponds to a model migration in which the -/// contributions arise as a coproduct of several queries. -#[derive(Clone)] -pub struct PolynomialODESystemBuilder { - // TODO: should this struct also have types ????? - model: ModalDblModel, - associated_parameters: HashMap -} - -impl Default for PolynomialODESystemBuilder

{ - fn default() -> Self { - let th = th_signed_polynomial_ode_system(); - Self { model: ModalDblModel::new(th.into()), associated_parameters: HashMap::new() } - } -} - -impl PolynomialODESystemBuilder

{ - /// Constructs an empty ODE system. - pub fn new() -> Self { - Self::default() - } - - /// Returns a model of the theory of polynomial ODE systems. - pub fn model(self) -> ModalDblModel { - self.model - } - - pub fn associated_parameters(self) -> HashMap { - self.associated_parameters - } - - /// Adds a state variable to the ODE system. - pub fn add_variable(&mut self, var: QualifiedName) { - self.model.add_ob(var, ModeApp::new(name("State"))); - } - - /// Adds a contribution to the ODE system. - pub fn add_contribution( - &mut self, - id: QualifiedName, - target: QualifiedName, - sign: ContributionSign, - parameter: P, - monomial: impl IntoIterator, - ) { - let monomial = monomial.into_iter().map(ModalOb::Generator).collect(); - let sign = match sign { - ContributionSign::Positive => ModeApp::new(name("Contribution")).into(), - ContributionSign::Negative => ModeApp::new(name("NegativeContribution")).into(), - }; - - self.model.add_mor( - id.clone(), - ModalOb::List(List::Symmetric, monomial), - ModalOb::Generator(target), - sign, - ); - - self.associated_parameters.insert(id, parameter); - } -} - -/// This trait is where we give the actual functions for building the data that -/// `build_system_from_ode_semantics()` needs in order to construct -/// the multicategory. The implementation of `build_semantics()` is where the actual -/// migration (i.e. the actual ODE semantics) is specified, but `build_system()` can -/// essentially always use the default implementation given below. -/// -/// Note that the type that implements this trait is also where you are expected to state -/// everything that your semantics "cares about". For example, the expected minimum is to -/// give the values of `ObType` and `MorType` that you want to distinguish between and -/// iterate over. It can also hold any extra data upon which your semantics can depend -/// (see e.g. `ode::mass_action::PetriNetMassActionAnalysis`, which contains the data of -/// some `MassConservationType`, whose value is fundamental in constructing the semantics). -/// However, this is left to the user: the type checker will not enforce any of these extras. -pub trait ODESemanticsAnalysis: Default { - // TODO: change the return type from a tuple to something better - fn build_system_builder(&self, model: &T) -> PolynomialODESystemBuilder

; - - fn build_system(&self, model: &T) -> PolynomialSystem, i8> { - let builder = self.build_system_builder(model); - PolynomialODEAnalysis::default() - .build_system_custom_parameters(&builder.model(), builder.associated_parameters()) - } -} - -/// A contribution to the ODE system consists of all the data that `ModalDblModel::add_mor()` -/// requires to create a multimorphism. -#[derive(Clone)] -pub struct Contribution { - /// The name of the multimorphism. - pub name: QualifiedName, - /// The source of the multimorphism (a list of objects), to be interpreted - /// as the monomial given by the product of all the list elements. - pub monomial: Vec, - /// The parameter (coefficient) to be associated with this contribution. - pub parameter: P, - /// The target of the multimorphism, to be interpreted as the variable whose - /// first derivative is affected by the monomial. - pub target: QualifiedName, -} - -/// The sign of the contribution, since we work in *signed* multicategories. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum ContributionSign { - /// Positive contribution: (d/dt)y -= x. - Positive, - /// Negative contribution: (d/dt)y += x. - Negative, -} - -/// The trait describing how to turn the formal system of ODEs into a numerical problem, to be -/// solved by an ODE solver and presented to the front-end. At minimum, such data must contain -/// initial values for variables and the intended duration of simulation, as well as the method -/// for converting the parameters (which are of type `ODEParameterType`) into floats. -// REQUEST | If you look at a struct that implements this trait (such as `LotkaVolterraProblemData`), -// FOR | there are a lot of serde statements going on. Should I be able to just move them -// FEEDBACK | (that is, those that come *before* the struct) here and have things all work? I'm still -// _________/ a bit intimidated by all these `crg_attr(feature = "serde")` bits. -// -// #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -// #[cfg_attr(feature = "serde-wasm", derive(Tsify))] -// #[cfg_attr( -// feature = "serde-wasm", -// tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) -// )] -pub trait ODESemanticsProblemData { - // REQUEST | The two getters (`initial_values()` and `duration()`) are annoying boilerplate to - // FOR | ask to be implemented. Is there a nice way to get rid of them here? Without them, - // FEEDBACK | the call to `self.initial_values` in `build_analysis()` fails because there is no - // _________/ way of knowing whether a struct implementing this trait actually has those fields. - /// Map from object IDs to initial values (nonnegative reals). - fn initial_values(&self) -> HashMap; - /// Duration of simulation. - fn duration(&self) -> f32; - - /// How to convert the formal parameters of type `ODEParameterType` into floats using values that - /// will eventually be filled in by the user from the front-end. - fn extend_scalars( - &self, - sys: PolynomialSystem, i8>, - ) -> PolynomialSystem; - - /// Converting the polynomial system into a system ready for use in numerical solvers. The default - /// implementation here should essentially always be the desired one. - fn build_analysis( - &self, - sys: PolynomialSystem, - ) -> ODEAnalysis> { - let ob_index: IndexMap<_, _> = - sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); - let n = ob_index.len(); - - let initial_values = ob_index - .keys() - .map(|ob| self.initial_values().get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); - - let num_sys = sys.to_numerical(); - let problem = ODEProblem::new(num_sys, x0).end_time(self.duration()); - - ODEAnalysis::new(problem, ob_index) - } -} From aeb26a680723fd2d92595d8caa73639f7d5109ea Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 16 Jun 2026 15:29:07 +0100 Subject: [PATCH 12/38] WIP: Failing tests (but in a good way, I promise) --- packages/catlog-wasm/src/analyses.rs | 29 ++++++------ packages/catlog-wasm/src/latex.rs | 40 +++++++++------- packages/catlog/src/latex.rs | 9 +--- .../catlog/src/simulate/ode/polynomial.rs | 46 +++++-------------- .../src/stdlib/analyses/ode/linear_ode.rs | 14 +++--- .../src/stdlib/analyses/ode/lotka_volterra.rs | 22 ++++----- .../src/stdlib/analyses/ode/mass_action.rs | 15 +++--- .../src/stdlib/analyses/ode/polynomial_ode.rs | 6 +-- packages/catlog/src/zero/rig.rs | 4 +- 9 files changed, 81 insertions(+), 104 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 394abd693..67d253720 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -1,5 +1,6 @@ //! Auxiliary structs and glue code for data passed to/from analyses. +use catlog::latex::LatexEquations; use serde::{Deserialize, Serialize}; use tsify::Tsify; @@ -7,9 +8,8 @@ use catlog::simulate::ode::PolynomialSystem; use catlog::stdlib::analyses::ode::{self, ODESemanticsAnalysis, ODESemanticsProblemData}; use catlog::zero::QualifiedName; -use crate::latex::{latex_mor_names_linear_ode, latex_mor_names_lotka_volterra}; +use crate::latex::{latex_mor_names, latex_ob_names}; -use super::latex::{LatexEquations, latex_mor_names, latex_mor_names_mass_action, latex_ob_names}; use super::model::DblModel; use super::result::JsResult; @@ -56,7 +56,7 @@ pub(crate) fn polynomial_ode_equations( .map_variables(latex_ob_names(model)) .extend_scalars(|param| param.map_variables(latex_mor_names(model))) .to_latex_equations(); - Ok(LatexEquations(equations)) + Ok(equations) } /// Simulates mass-action ODEs. @@ -72,7 +72,7 @@ pub(crate) fn polynomial_ode_simulation( let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), - latex_equations: LatexEquations(latex_equations), + latex_equations: latex_equations, }) } @@ -129,9 +129,10 @@ pub(crate) fn mass_action_equations( let sys = mass_action_system(model, data.mass_conservation_type, logic); let equations = sys? .map_variables(latex_ob_names(model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(model))) + //TODO: FIX THIS + // .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(model))) .to_latex_equations(); - Ok(LatexEquations(equations)) + Ok(equations) } /// Simulates mass-action ODEs. @@ -148,7 +149,7 @@ pub(crate) fn mass_action_simulation( let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), - latex_equations: LatexEquations(latex_equations), + latex_equations: latex_equations, }) } @@ -175,9 +176,10 @@ pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result Result impl Fn(&QualifiedName) -> St mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; - use catlog::latex::LatexEquation; + use catlog::latex::{Latex, LatexEquation, LatexEquations}; use catlog::stdlib::analyses::ode::{StockFlowMassActionAnalysis, ode_semantics::*}; use catlog::stdlib::{analyses::ode, theories}; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; @@ -67,19 +66,22 @@ mod tests { let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) + //TODO: FIX THIS + // .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) .to_latex_equations(); - let expected = vec![ + let expected = LatexEquations(vec![ LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string(), - rhs: "-\\kappa_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), + rhs: Latex( + "-\\kappa_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), + ), }, LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string(), - rhs: "\\rho_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), + rhs: Latex("\\rho_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), }, - ]; + ]); assert_eq!(equations, expected); } @@ -96,22 +98,26 @@ mod tests { let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) + //TODO: FIX THIS + // .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) .to_latex_equations(); - let expected = vec![ + let expected = LatexEquations(vec![ LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string(), - rhs: + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), + rhs: Latex( "-\\kappa_{\\text{xxx} \\to \\text{yyy}} \\cdot \\text{xxx} \\cdot \\text{yyy}" .to_string(), + ), }, LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string(), - rhs: "\\rho_{\\text{xxx} \\to \\text{yyy}} \\cdot \\text{xxx} \\cdot \\text{yyy}" - .to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), + rhs: Latex( + "\\rho_{\\text{xxx} \\to \\text{yyy}} \\cdot \\text{xxx} \\cdot \\text{yyy}" + .to_string(), + ), }, - ]; + ]); assert_eq!(equations, expected); } diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index e82625924..439bd9464 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -49,11 +49,6 @@ pub struct LatexEquation { /// Symbolic equations in LaTeX format. #[derive(Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] pub struct LatexEquations(pub Vec); - -/// An object that can be rendered to a collection of LaTeX equations (of the form -/// "lhs = rhs"). -pub trait ToLatexEquations { - /// Convert the object to the LaTeX equations. - fn to_latex_equations(&self) -> LatexEquations; -} diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index d417d9e9e..6d965c4d2 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -12,7 +12,7 @@ use num_traits::{One, Pow, Zero}; #[cfg(test)] use super::ODEProblem; use super::ODESystem; -use crate::latex::{Latex, LatexEquation, LatexEquations, ToLatex, ToLatexEquations}; +use crate::latex::{Latex, LatexEquation, LatexEquations, ToLatex}; use crate::zero::{alg::Polynomial, rig::DisplayCoef}; /// A system of polynomial differential equations. @@ -91,19 +91,21 @@ where } /// Converts to equations as LaTeX strings. - pub fn to_latex_equations(&self) -> Vec + pub fn to_latex_equations(&self) -> LatexEquations where Var: Display, Coef: Display + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + PartialEq + One, { - self.components - .iter() - .map(|(var, poly)| LatexEquation { - lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), - rhs: poly.to_latex(), - }) - .collect() + LatexEquations( + self.components + .iter() + .map(|(var, poly)| LatexEquation { + lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), + rhs: poly.to_latex(), + }) + .collect(), + ) } } @@ -157,32 +159,6 @@ where } } -impl ToLatexEquations for PolynomialSystem -where - Var: Display, - Coef: Display + PartialEq + One + DisplayCoef + Clone + Neg, - Exp: Display + PartialEq + One, -{ - - /// Converts to equations as LaTeX strings. - fn to_latex_equations(&self) -> LatexEquations - where - Var: Display, - Coef: Display + PartialEq + One + Neg, - Exp: Display + PartialEq + One, - { - LatexEquations( - self.components - .iter() - .map(|(var, poly)| LatexEquation { - lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), - rhs: poly.to_latex(), - }) - .collect() - ) - } -} - /// A numerical system of polynomial differential equations. /// /// Such a system is ready for use in numerical solvers: the coefficients are diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 0b822e15c..e8777399d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -58,9 +58,7 @@ impl fmt::Display for LCCParameter { impl ToLatex for LCCParameter { fn to_latex(&self) -> Latex { match self { - Self::Parameter { morphism } => { - Latex(format!("\\lambda_{{{morphism}}}")) - } + Self::Parameter { morphism } => Latex(format!("\\lambda_{{{morphism}}}")), } } } @@ -205,7 +203,7 @@ mod test { use super::*; use crate::{ dbl::model::MutDblModel, - latex::LatexEquation, + latex::{LatexEquation, LatexEquations}, stdlib::{models::*, theories::*}, }; @@ -254,16 +252,16 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); let sys = LCCAnalysis::default().build_system(&model); - let expected = vec![ + let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex("-Parameter(negative) \\cdot y".to_string()), + rhs: Latex("-\\lambda_{negative} \\cdot y".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex("Parameter(positive) \\cdot x".to_string()), + rhs: Latex("\\labmda{positive} \\cdot x".to_string()), }, - ]; + ]); assert_eq!(expected, sys.to_latex_equations()); } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 17a3872f8..b5a3d6e82 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -67,12 +67,8 @@ impl fmt::Display for LotkaVolterraParameter { impl ToLatex for LotkaVolterraParameter { fn to_latex(&self) -> Latex { match self { - Self::Growth { variable } => { - Latex(format!("\\g_{{{variable}}}")) - }, - Self::Interaction { link } => { - Latex(format!("\\k_{{{link}}}")) - }, + Self::Growth { variable } => Latex(format!("\\g_{{{variable}}}")), + Self::Interaction { link } => Latex(format!("\\k_{{{link}}}")), } } } @@ -242,7 +238,7 @@ mod test { use super::*; use crate::{ dbl::model::MutDblModel, - latex::LatexEquation, + latex::{LatexEquation, LatexEquations}, stdlib::{models::*, theories::*}, }; @@ -291,16 +287,20 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); let sys = LotkaVolterraAnalysis::default().build_system(&model); - let expected = vec![ + let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex("Growth(x) \\cdot x - Interaction(negative) \\cdot x \\cdot y".to_string()), + rhs: Latex( + "g_{x} \\cdot x - k_{negative} \\cdot x \\cdot y".to_string(), + ), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex("Interaction(positive) \\cdot x \\cdot y + Growth(y) \\cdot y".to_string()), + rhs: Latex( + "k_{positive} \\cdot x \\cdot y + g_{y} \\cdot y".to_string(), + ), }, - ]; + ]); assert_eq!(expected, sys.to_latex_equations()); } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 1c733f702..b4eb2b623 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -544,8 +544,7 @@ mod tests { use std::rc::Rc; use super::*; - use crate::simulate::ode::LatexEquation; - use crate::stdlib::{analyses, models::*, theories::*}; + use crate::{latex::{LatexEquation, LatexEquations}, stdlib::{analyses, models::*, theories::*}}; // Tests for stock-flow diagrams. These all use the backward_link() model, // which has a single flow x==f==>y and a single link y->f. @@ -647,16 +646,16 @@ mod tests { ..StockFlowMassActionAnalysis::default() } .build_system(&model); - let expected = vec![ + let expected = LatexEquations(vec![ LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string(), - rhs: "-Outgoing(f) \\cdot x \\cdot y".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex("-\\kappa_{f} \\cdot x \\cdot y".to_string()), }, LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string(), - rhs: "Incoming(f) \\cdot x \\cdot y".to_string(), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("\\rho_{f} \\cdot x \\cdot y".to_string()), }, - ]; + ]); assert_eq!(expected, sys.to_latex_equations()); } } diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index c4568ff7e..26b5e28de 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -199,7 +199,7 @@ mod tests { use super::*; use crate::{ - latex::{Latex, LatexEquation}, + latex::{Latex, LatexEquation, LatexEquations}, stdlib::{models::*, theories::*}, tt, }; @@ -224,7 +224,7 @@ mod tests { let th = Rc::new(th_signed_polynomial_ode_system()); let model = signed_lotka_volterra_dynamics(th); let sys = PolynomialODEAnalysis::default().build_system(&model); - let expected = vec![ + let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} A".to_string()), rhs: Latex("A_growth \\cdot A - BA_interaction \\cdot A \\cdot B".to_string()), @@ -233,7 +233,7 @@ mod tests { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} B".to_string()), rhs: Latex("AB_interaction \\cdot A \\cdot B + B_growth \\cdot B".to_string()), }, - ]; + ]); assert_eq!(expected, sys.to_latex_equations()); } diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index c1dceddcd..518d11557 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -745,7 +745,7 @@ mod tests { let monomial: Monomial = Monomial::one(); assert_eq!(monomial.to_string(), "1"); - assert_eq!(monomial.to_latex(), "1"); + assert_eq!(monomial.to_latex(), Latex("1".to_string())); let monomial: Monomial<_, u32> = [('x', 1), ('y', 0), ('x', 2)].into_iter().collect(); assert_eq!(monomial.normalize().to_string(), "x^3"); @@ -754,6 +754,6 @@ mod tests { assert_eq!(monomial.normalize().to_string(), "x y^{-2}"); let monomial: Monomial<_, i32> = [('x', 1), ('y', 2), ('z', -1)].into_iter().collect(); - assert_eq!(monomial.to_latex(), "x \\cdot y^2 \\cdot z^{-1}"); + assert_eq!(monomial.to_latex(), Latex("x \\cdot y^2 \\cdot z^{-1}".to_string())); } } From 0b983fae96341eb72336e5feed59142488382b99 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 16 Jun 2026 17:47:47 +0100 Subject: [PATCH 13/38] WIP: Passing catlog tests; failing catlog-wasm tests (expected behaviour) --- packages/catlog-wasm/src/latex.rs | 3 +-- packages/catlog/src/latex.rs | 21 ++++++++++++------- .../catlog/src/simulate/ode/polynomial.rs | 6 +++--- .../src/stdlib/analyses/ode/linear_ode.rs | 3 ++- .../src/stdlib/analyses/ode/lotka_volterra.rs | 4 ++-- .../src/stdlib/analyses/ode/mass_action.rs | 13 ++++++------ .../src/stdlib/analyses/ode/ode_semantics.rs | 15 ++++++++----- packages/catlog/src/zero/alg.rs | 11 +++++----- packages/catlog/src/zero/rig.rs | 11 +++++----- 9 files changed, 49 insertions(+), 38 deletions(-) diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 1a8a4852d..8eedb476f 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -20,8 +20,7 @@ pub(crate) fn latex_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> Str /// falls back to the domain→codomain format (e.g., `X \to Y`). pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { // Returns a LaTeX fragment for a morphism, suitable for use as a subscript. - // Named morphisms produce `\text{name}`, unnamed ones produce - // `\text{dom} \to \text{cod}` so that `\to` is in math mode. + // Named morphisms produce `\text{name}`, unnamed ones produce `\text{dom} \to \text{cod}`. let morphism_subscript = |morphism: &QualifiedName| -> String { if let Some(label) = model.mor_namespace.label(morphism) { format!("\\text{{{label}}}") diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 439bd9464..5fdf15dfc 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -6,6 +6,7 @@ //! N.B. Although the software is called "LaTeX" we will consistently ignore the //! correct capitalisation and simply write latex or Latex in our code. +use duplicate::duplicate_item; use std::fmt; #[cfg(feature = "serde")] @@ -13,7 +14,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -/// We should mark which strings are to be parsed as LaTeX. +/// We should mark which strings are to be parsed as Latex. #[derive(Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Latex(pub String); @@ -25,16 +26,20 @@ impl fmt::Display for Latex { } } -/// An object that can be rendered to LaTeX. -pub trait ToLatex: fmt::Display { - /// Convert the object to its LaTeX representation. Here the default - /// implementation simply falls back to `Display`. +/// An object that can be rendered to Latex. +pub trait ToLatex { + /// Convert the object to its Latex representation. + fn to_latex(&self) -> Latex; +} + +#[duplicate_item(T; [f32]; [f64]; [i8]; [i32]; [i64]; [u32]; [u64]; [usize]; [char]; [String])] +impl ToLatex for T { fn to_latex(&self) -> Latex { - Latex(self.to_string()) + Latex(self.to_string()) } } -/// An equation in LaTeX format with a left-hand side and a right-hand side. +/// An equation in Latex format with a left-hand side and a right-hand side. #[derive(Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -46,7 +51,7 @@ pub struct LatexEquation { pub rhs: Latex, } -/// Symbolic equations in LaTeX format. +/// Symbolic equations in Latex format. #[derive(Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 6d965c4d2..4a57374aa 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -93,9 +93,9 @@ where /// Converts to equations as LaTeX strings. pub fn to_latex_equations(&self) -> LatexEquations where - Var: Display, - Coef: Display + DisplayCoef + Clone + PartialEq + One + Neg, - Exp: Display + PartialEq + One, + Var: Display + ToLatex, + Coef: Display + ToLatex + DisplayCoef + Clone + PartialEq + One + Neg, + Exp: Display + ToLatex + PartialEq + One, { LatexEquations( self.components diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index e8777399d..9e50c34a4 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -252,6 +252,7 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); let sys = LCCAnalysis::default().build_system(&model); + // .extend_scalars(|param| param.map_variables(to_latex)) let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), @@ -259,7 +260,7 @@ mod test { }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex("\\labmda{positive} \\cdot x".to_string()), + rhs: Latex("\\lambda_{positive} \\cdot x".to_string()), }, ]); assert_eq!(expected, sys.to_latex_equations()); diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index b5a3d6e82..8548d4929 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -67,8 +67,8 @@ impl fmt::Display for LotkaVolterraParameter { impl ToLatex for LotkaVolterraParameter { fn to_latex(&self) -> Latex { match self { - Self::Growth { variable } => Latex(format!("\\g_{{{variable}}}")), - Self::Interaction { link } => Latex(format!("\\k_{{{link}}}")), + Self::Growth { variable } => Latex(format!("g_{{{variable}}}")), + Self::Interaction { link } => Latex(format!("k_{{{link}}}")), } } } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index b4eb2b623..469999257 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -175,15 +175,11 @@ impl ToLatex for MassActionParameter { ( Direction::IncomingFlow, RateParameter::PerStock { flow: transition, stock: place }, - ) => { - Latex(format!("\\rho_{{{transition}}}^{{\\text{{{place}}}}}")) - } + ) => Latex(format!("\\rho_{{{transition}}}^{{\\text{{{place}}}}}")), ( Direction::OutgoingFlow, RateParameter::PerStock { flow: transition, stock: place }, - ) => { - Latex(format!("\\kappa_{{{transition}}}^{{\\text{{{place}}}}}")) - } + ) => Latex(format!("\\kappa_{{{transition}}}^{{\\text{{{place}}}}}")), }, } } @@ -544,7 +540,10 @@ mod tests { use std::rc::Rc; use super::*; - use crate::{latex::{LatexEquation, LatexEquations}, stdlib::{analyses, models::*, theories::*}}; + use crate::{ + latex::{LatexEquation, LatexEquations}, + stdlib::{analyses, models::*, theories::*}, + }; // Tests for stock-flow diagrams. These all use the backward_link() model, // which has a single flow x==f==>y and a single link y->f. diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 633cf6ef6..b359f57bf 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -30,10 +30,15 @@ use crate::{ modal::{List, ModeApp}, model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, theory::{NonUnital, Unital}, - }, latex::{Latex, ToLatex}, one::FgCategory, simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, stdlib::{ + }, + latex::{Latex, ToLatex}, + one::FgCategory, + simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, + stdlib::{ analyses::ode::{ODEAnalysis, Parameter, PolynomialODEAnalysis}, th_signed_polynomial_ode_system, - }, zero::{QualifiedName, name} + }, + zero::{QualifiedName, name}, }; /// The trait for an ODE semantics on models. @@ -76,9 +81,9 @@ pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display + ToLatex {} /// The simplest type for parameters is `QualifiedName`. impl ToLatex for QualifiedName { - fn to_latex(&self) -> Latex { - Latex(self.to_string()) - } + fn to_latex(&self) -> Latex { + Latex(self.to_string()) + } } impl ODEParameterType for QualifiedName {} diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 01e786108..00ee0ff34 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -151,22 +151,23 @@ where impl ToLatex for Polynomial where - Var: Display, - Coef: Display + DisplayCoef + Clone + PartialEq + One + Neg, - Exp: Display + PartialEq + One, + Var: Display + ToLatex, + Coef: DisplayCoef + Clone + PartialEq + One + Neg + ToLatex, + Exp: Display + ToLatex + PartialEq + One, { /// Convert to a LaTeX string, formatting each monomial via [`Monomial::to_latex`]. fn to_latex(&self) -> Latex { let fmt_term = |coef: &Coef, monomial: &Monomial| -> String { let Latex(monomial) = monomial.to_latex(); + let Latex(coef_latex) = coef.to_latex(); if coef.is_one() { monomial } else if *coef == Coef::one().neg() { format!("-{monomial}") } else if coef.needs_parentheses() { - format!("({coef}) \\cdot {monomial}") + format!("({coef_latex}) \\cdot {monomial}") } else { - format!("{coef} \\cdot {monomial}") + format!("{coef_latex} \\cdot {monomial}") } }; diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index 518d11557..0baf24b74 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -590,20 +590,21 @@ where impl ToLatex for Monomial where - Var: Display, - Exp: Display + PartialEq + One, + Var: Display + ToLatex, + Exp: Display + ToLatex + PartialEq + One, { /// Convert to a LaTeX string, separating variables with `\cdot`. fn to_latex(&self) -> Latex { let fmt_power = |var: &Var, exp: &Exp| { + let Latex(var_latex) = var.to_latex(); if exp.is_one() { - format!("{var}") + format!("{var_latex}") } else { let exp = exp.to_string(); if exp.len() == 1 { - format!("{var}^{exp}") + format!("{var_latex}^{exp}") } else { - format!("{var}^{{{exp}}}") + format!("{var_latex}^{{{exp}}}") } } }; From 3e680733a46e6a27d34a67237310e4b506565352 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 16 Jun 2026 19:39:15 +0100 Subject: [PATCH 14/38] WIP: Thoughts [skip-ci] --- packages/catlog-wasm/src/latex.rs | 80 ++++++++++++++++--- packages/catlog/src/latex.rs | 4 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 4 +- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 8eedb476f..b5f9ac27d 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -1,6 +1,6 @@ //! Auxiliary structs and glue code for any LaTeX code being passed through analyses. -use catlog::zero::QualifiedName; +use catlog::{stdlib::analyses::ode, zero::QualifiedName}; use super::model::DblModel; @@ -21,20 +21,80 @@ pub(crate) fn latex_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> Str pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { // Returns a LaTeX fragment for a morphism, suitable for use as a subscript. // Named morphisms produce `\text{name}`, unnamed ones produce `\text{dom} \to \text{cod}`. - let morphism_subscript = |morphism: &QualifiedName| -> String { - if let Some(label) = model.mor_namespace.label(morphism) { + |id: &QualifiedName| { + if let Some(label) = model.mor_namespace.label(id) { format!("\\text{{{label}}}") } else { let (dom, cod) = model - .mor_generator_dom_cod_label_strings(morphism) + .mor_generator_dom_cod_label_strings(id) + .expect("Morphism in equation system should have domain and codomain"); + format!("\\text{{{dom}}} \\to \\text{{{cod}}}") + } + } +} + + +// TODO: THIS SHOULD NOT BE A WHOLE NEW CLOSURE +/// Creates a closure that formats morphism names for mass-action LaTeX output. +/// +/// When a morphism has a label, it is used directly. When unnamed, the label +/// falls back to the domain→codomain format (e.g., `X \to Y`). +pub(crate) fn latex_mor_names_mass_action( + model: &DblModel, +) -> impl Fn(&ode::MassActionParameter) -> String { + // Returns a LaTeX fragment for a transition, suitable for use as a subscript. + // Named morphisms produce `\text{name}`, unnamed ones produce + // `\text{dom} \to \text{cod}` so that `\to` is in math mode. + let transition_subscript = |transition: &QualifiedName| -> String { + if let Some(label) = model.mor_namespace.label(transition) { + format!("\\text{{{label}}}") + } else { + let (dom, cod) = model + .mor_generator_dom_cod_label_strings(transition) .expect("Morphism in equation system should have domain and codomain"); format!("\\text{{{dom}}} \\to \\text{{{cod}}}") } }; - move |id: &QualifiedName| { - let sub = morphism_subscript(id); - format!("\\lambda_{{{sub}}}") + move |id: &ode::MassActionParameter| match id { + ode::MassActionParameter::Balanced { flow: transition } => { + let sub = transition_subscript(transition); + format!("r_{{{sub}}}") + } + ode::MassActionParameter::Unbalanced { direction, parameter } => { + match (direction, parameter) { + ( + ode::Direction::IncomingFlow, + ode::RateParameter::PerFlow { flow: transition }, + ) => { + let sub = transition_subscript(transition); + format!("\\rho_{{{sub}}}") + } + ( + ode::Direction::OutgoingFlow, + ode::RateParameter::PerFlow { flow: transition }, + ) => { + let sub = transition_subscript(transition); + format!("\\kappa_{{{sub}}}") + } + ( + ode::Direction::IncomingFlow, + ode::RateParameter::PerStock { flow: transition, stock: place }, + ) => { + let sub = transition_subscript(transition); + let output_place_label = model.ob_namespace.label_string(place); + format!("\\rho_{{{sub}}}^{{\\text{{{output_place_label}}}}}") + } + ( + ode::Direction::OutgoingFlow, + ode::RateParameter::PerStock { flow: transition, stock: place }, + ) => { + let sub = transition_subscript(transition); + let input_place_label = model.ob_namespace.label_string(place); + format!("\\kappa_{{{sub}}}^{{\\text{{{input_place_label}}}}}") + } + } + } } } @@ -65,8 +125,7 @@ mod tests { let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) - //TODO: FIX THIS - // .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) + .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) .to_latex_equations(); let expected = LatexEquations(vec![ @@ -97,8 +156,7 @@ mod tests { let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) - //TODO: FIX THIS - // .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) + .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) .to_latex_equations(); let expected = LatexEquations(vec![ diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 5fdf15dfc..59fcd0c8c 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; /// We should mark which strings are to be parsed as Latex. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Latex(pub String); @@ -34,6 +34,8 @@ pub trait ToLatex { #[duplicate_item(T; [f32]; [f64]; [i8]; [i32]; [i64]; [u32]; [u64]; [usize]; [char]; [String])] impl ToLatex for T { + // TODO: this should be generic over `P -> String` where `P: ODEParameterType` (or some subset thereof) + // and the default implementation just uses `to_string()` ??? fn to_latex(&self) -> Latex { Latex(self.to_string()) } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index b359f57bf..a1fd2a998 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -28,7 +28,7 @@ use std::{collections::HashMap, fmt}; use crate::{ dbl::{ modal::{List, ModeApp}, - model::{DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, + model::{DblModel, DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, theory::{NonUnital, Unital}, }, latex::{Latex, ToLatex}, @@ -63,7 +63,7 @@ pub trait ODESemantics { /// The models for which we support ODE semantics need to be sufficiently nice, though /// these bounds are not particularly restrictive. pub trait DblModelForODESemantics: - FgCategory + MutDblModel + Clone + FgCategory + DblModel + MutDblModel + Clone { } From bf360f63b6bcb6fca38395c35621305fcc10abf8 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 17 Jun 2026 19:30:43 +0100 Subject: [PATCH 15/38] WIP: ToLatexWithMap (all tests passing!) --- packages/catlog-wasm/src/analyses.rs | 8 +- packages/catlog-wasm/src/latex.rs | 75 ++----------------- packages/catlog/src/latex.rs | 24 +++++- .../catlog/src/simulate/ode/polynomial.rs | 24 ++++-- .../src/stdlib/analyses/ode/linear_ode.rs | 8 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 10 +-- .../src/stdlib/analyses/ode/mass_action.rs | 38 +++++----- .../src/stdlib/analyses/ode/ode_semantics.rs | 10 +-- packages/catlog/src/zero/alg.rs | 23 +++--- packages/catlog/src/zero/rig.rs | 13 ++-- 10 files changed, 99 insertions(+), 134 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 67d253720..691b5185c 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -72,7 +72,7 @@ pub(crate) fn polynomial_ode_simulation( let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), - latex_equations: latex_equations, + latex_equations, }) } @@ -149,7 +149,7 @@ pub(crate) fn mass_action_simulation( let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), - latex_equations: latex_equations, + latex_equations, }) } @@ -195,7 +195,7 @@ pub(crate) fn lotka_volterra_simulation( let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), - latex_equations: latex_equations, + latex_equations, }) } @@ -240,6 +240,6 @@ pub(crate) fn linear_ode_simulation( let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), - latex_equations: latex_equations, + latex_equations, }) } diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index b5f9ac27d..f6c9cb586 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -1,6 +1,6 @@ //! Auxiliary structs and glue code for any LaTeX code being passed through analyses. -use catlog::{stdlib::analyses::ode, zero::QualifiedName}; +use catlog::zero::QualifiedName; use super::model::DblModel; @@ -33,71 +33,6 @@ pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> St } } - -// TODO: THIS SHOULD NOT BE A WHOLE NEW CLOSURE -/// Creates a closure that formats morphism names for mass-action LaTeX output. -/// -/// When a morphism has a label, it is used directly. When unnamed, the label -/// falls back to the domain→codomain format (e.g., `X \to Y`). -pub(crate) fn latex_mor_names_mass_action( - model: &DblModel, -) -> impl Fn(&ode::MassActionParameter) -> String { - // Returns a LaTeX fragment for a transition, suitable for use as a subscript. - // Named morphisms produce `\text{name}`, unnamed ones produce - // `\text{dom} \to \text{cod}` so that `\to` is in math mode. - let transition_subscript = |transition: &QualifiedName| -> String { - if let Some(label) = model.mor_namespace.label(transition) { - format!("\\text{{{label}}}") - } else { - let (dom, cod) = model - .mor_generator_dom_cod_label_strings(transition) - .expect("Morphism in equation system should have domain and codomain"); - format!("\\text{{{dom}}} \\to \\text{{{cod}}}") - } - }; - - move |id: &ode::MassActionParameter| match id { - ode::MassActionParameter::Balanced { flow: transition } => { - let sub = transition_subscript(transition); - format!("r_{{{sub}}}") - } - ode::MassActionParameter::Unbalanced { direction, parameter } => { - match (direction, parameter) { - ( - ode::Direction::IncomingFlow, - ode::RateParameter::PerFlow { flow: transition }, - ) => { - let sub = transition_subscript(transition); - format!("\\rho_{{{sub}}}") - } - ( - ode::Direction::OutgoingFlow, - ode::RateParameter::PerFlow { flow: transition }, - ) => { - let sub = transition_subscript(transition); - format!("\\kappa_{{{sub}}}") - } - ( - ode::Direction::IncomingFlow, - ode::RateParameter::PerStock { flow: transition, stock: place }, - ) => { - let sub = transition_subscript(transition); - let output_place_label = model.ob_namespace.label_string(place); - format!("\\rho_{{{sub}}}^{{\\text{{{output_place_label}}}}}") - } - ( - ode::Direction::OutgoingFlow, - ode::RateParameter::PerStock { flow: transition, stock: place }, - ) => { - let sub = transition_subscript(transition); - let input_place_label = model.ob_namespace.label_string(place); - format!("\\kappa_{{{sub}}}^{{\\text{{{input_place_label}}}}}") - } - } - } - } -} - #[cfg(test)] mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; @@ -125,8 +60,7 @@ mod tests { let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) - .to_latex_equations(); + .to_latex_equations_with_map(|param| latex_mor_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { @@ -143,6 +77,8 @@ mod tests { assert_eq!(equations, expected); } + // TODO: add more tests here for the other ODE semantics + #[test] fn unnamed_mor_uses_dom_cod_in_equations() { let model = backward_link("xxx", "yyy", ""); @@ -156,8 +92,7 @@ mod tests { let sys = analysis.build_system(tab_model); let equations = sys .map_variables(latex_ob_names(&model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) - .to_latex_equations(); + .to_latex_equations_with_map(|param| latex_mor_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 59fcd0c8c..cc6d34552 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -14,6 +14,8 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; +use crate::zero::QualifiedName; + /// We should mark which strings are to be parsed as Latex. #[derive(Debug, PartialEq, Eq, Clone)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -32,11 +34,25 @@ pub trait ToLatex { fn to_latex(&self) -> Latex; } -#[duplicate_item(T; [f32]; [f64]; [i8]; [i32]; [i64]; [u32]; [u64]; [usize]; [char]; [String])] -impl ToLatex for T { - // TODO: this should be generic over `P -> String` where `P: ODEParameterType` (or some subset thereof) - // and the default implementation just uses `to_string()` ??? +/// TODO: documentation +pub trait ToLatexWithMap { + /// TODO: documentation + fn to_latex_with_map String>(&self, f: F) -> Latex; +} + +impl ToLatex for T +where + T: ToLatexWithMap, +{ fn to_latex(&self) -> Latex { + let name = |id: &QualifiedName| {id.to_string()}; + self.to_latex_with_map(name) + } +} + +#[duplicate_item(T; [f32]; [f64]; [i8]; [i32]; [i64]; [u32]; [u64]; [usize]; [char]; [String])] +impl ToLatexWithMap for T { + fn to_latex_with_map String>(&self, _f: F) -> Latex { Latex(self.to_string()) } } diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 4a57374aa..2c7588850 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -12,7 +12,8 @@ use num_traits::{One, Pow, Zero}; #[cfg(test)] use super::ODEProblem; use super::ODESystem; -use crate::latex::{Latex, LatexEquation, LatexEquations, ToLatex}; +use crate::latex::{Latex, LatexEquation, LatexEquations, ToLatex, ToLatexWithMap}; +use crate::zero::QualifiedName; use crate::zero::{alg::Polynomial, rig::DisplayCoef}; /// A system of polynomial differential equations. @@ -90,11 +91,11 @@ where PolynomialSystem { components } } - /// Converts to equations as LaTeX strings. - pub fn to_latex_equations(&self) -> LatexEquations + /// TODO: documentation + pub fn to_latex_equations_with_map String>(&self, f: F) -> LatexEquations where - Var: Display + ToLatex, - Coef: Display + ToLatex + DisplayCoef + Clone + PartialEq + One + Neg, + Var: Display + ToLatexWithMap, + Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + ToLatex + PartialEq + One, { LatexEquations( @@ -102,11 +103,22 @@ where .iter() .map(|(var, poly)| LatexEquation { lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), - rhs: poly.to_latex(), + rhs: poly.to_latex_with_map(|p| f(p)), }) .collect(), ) } + + /// Converts to equations as LaTeX strings. + pub fn to_latex_equations(&self) -> LatexEquations + where + Var: Display + ToLatexWithMap, + Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, + Exp: Display + ToLatex + PartialEq + One, + { + let name = |id: &QualifiedName| {id.to_string()}; + self.to_latex_equations_with_map(name) + } } impl PolynomialSystem diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 9e50c34a4..f3767c6fc 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -15,7 +15,7 @@ use tsify::Tsify; use super::Parameter; use crate::dbl::model::{FpDblModel, MutDblModel}; -use crate::latex::{Latex, ToLatex}; +use crate::latex::{Latex, ToLatexWithMap}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::{ @@ -55,10 +55,10 @@ impl fmt::Display for LCCParameter { } } -impl ToLatex for LCCParameter { - fn to_latex(&self) -> Latex { +impl ToLatexWithMap for LCCParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - Self::Parameter { morphism } => Latex(format!("\\lambda_{{{morphism}}}")), + Self::Parameter { morphism } => Latex(format!("\\lambda_{{{}}}", f(morphism))), } } } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 8548d4929..a346bfcc9 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -15,7 +15,7 @@ use tsify::Tsify; use super::Parameter; use crate::dbl::model::{FpDblModel, MutDblModel}; -use crate::latex::{Latex, ToLatex}; +use crate::latex::{Latex, ToLatexWithMap}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::{ @@ -64,11 +64,11 @@ impl fmt::Display for LotkaVolterraParameter { } } -impl ToLatex for LotkaVolterraParameter { - fn to_latex(&self) -> Latex { +impl ToLatexWithMap for LotkaVolterraParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - Self::Growth { variable } => Latex(format!("g_{{{variable}}}")), - Self::Interaction { link } => Latex(format!("k_{{{link}}}")), + Self::Growth { variable } => Latex(format!("g_{{{}}}", f(variable))), + Self::Interaction { link } => Latex(format!("k_{{{}}}", f(link))), } } } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 469999257..99114e629 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use super::Parameter; -use crate::latex::{Latex, ToLatex}; +use crate::latex::{Latex, ToLatexWithMap}; use crate::simulate::ode::PolynomialSystem; use crate::stdlib::analyses::ode::ode_semantics::*; use crate::stdlib::analyses::petri::transition_interface; @@ -161,26 +161,26 @@ impl fmt::Display for MassActionParameter { } } -impl ToLatex for MassActionParameter { - fn to_latex(&self) -> Latex { +impl ToLatexWithMap for MassActionParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - Self::Balanced { flow: transition } => Latex(format!("r_{{{transition}}}")), - Self::Unbalanced { direction, parameter } => match (direction, parameter) { - (Direction::IncomingFlow, RateParameter::PerFlow { flow: transition }) => { - Latex(format!("\\rho_{{{transition}}}")) - } - (Direction::OutgoingFlow, RateParameter::PerFlow { flow: transition }) => { - Latex(format!("\\kappa_{{{transition}}}")) + MassActionParameter::Balanced { flow } => Latex(format!("r_{{{}}}", f(flow))), + MassActionParameter::Unbalanced { direction, parameter } => { + match (direction, parameter) { + (Direction::IncomingFlow, RateParameter::PerFlow { flow }) => { + Latex(format!("\\rho_{{{}}}", f(flow))) + } + (Direction::OutgoingFlow, RateParameter::PerFlow { flow }) => { + Latex(format!("\\kappa_{{{}}}", f(flow))) + } + (Direction::IncomingFlow, RateParameter::PerStock { flow, stock }) => { + Latex(format!("\\rho_{{{}}}^{{{}}}", f(flow), stock)) + } + (Direction::OutgoingFlow, RateParameter::PerStock { flow, stock }) => { + Latex(format!("\\kappa_{{{}}}^{{{}}}", f(flow), stock)) + } } - ( - Direction::IncomingFlow, - RateParameter::PerStock { flow: transition, stock: place }, - ) => Latex(format!("\\rho_{{{transition}}}^{{\\text{{{place}}}}}")), - ( - Direction::OutgoingFlow, - RateParameter::PerStock { flow: transition, stock: place }, - ) => Latex(format!("\\kappa_{{{transition}}}^{{\\text{{{place}}}}}")), - }, + } } } } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index a1fd2a998..2b0abb7c1 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -31,7 +31,7 @@ use crate::{ model::{DblModel, DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, theory::{NonUnital, Unital}, }, - latex::{Latex, ToLatex}, + latex::{Latex, ToLatexWithMap}, one::FgCategory, simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, stdlib::{ @@ -77,12 +77,12 @@ impl DblModelForODESemantics for ModalDblModel {} /// manual effort for implementation are `Display` and `ToLatex`, which govern how these /// coefficients should be rendered. The `Display` trait is used for debugging whereas the /// `ToLatex` trait is used for user-facing display. -pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display + ToLatex {} +pub trait ODEParameterType: Eq + Ord + Clone + fmt::Display + ToLatexWithMap {} /// The simplest type for parameters is `QualifiedName`. -impl ToLatex for QualifiedName { - fn to_latex(&self) -> Latex { - Latex(self.to_string()) +impl ToLatexWithMap for QualifiedName { + fn to_latex_with_map String>(&self, f: T) -> Latex { + Latex(f(self)) } } diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 00ee0ff34..12d5f3346 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -9,7 +9,8 @@ use std::ops::{Add, AddAssign, Mul, Neg}; use derivative::Derivative; -use crate::latex::{Latex, ToLatex}; +use crate::latex::{Latex, ToLatex, ToLatexWithMap}; +use crate::zero::QualifiedName; use super::rig::*; @@ -149,25 +150,25 @@ where } } -impl ToLatex for Polynomial +impl ToLatexWithMap for Polynomial where - Var: Display + ToLatex, - Coef: DisplayCoef + Clone + PartialEq + One + Neg + ToLatex, + Var: Display + ToLatexWithMap, + Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + ToLatex + PartialEq + One, { /// Convert to a LaTeX string, formatting each monomial via [`Monomial::to_latex`]. - fn to_latex(&self) -> Latex { + fn to_latex_with_map String>(&self, f: F) -> Latex { let fmt_term = |coef: &Coef, monomial: &Monomial| -> String { - let Latex(monomial) = monomial.to_latex(); - let Latex(coef_latex) = coef.to_latex(); + let Latex(monomial_latex) = monomial.to_latex_with_map(|m| f(m)); + let Latex(coef_latex) = coef.to_latex_with_map(|c| f(c)); if coef.is_one() { - monomial + monomial_latex } else if *coef == Coef::one().neg() { - format!("-{monomial}") + format!("-{monomial_latex}") } else if coef.needs_parentheses() { - format!("({coef_latex}) \\cdot {monomial}") + format!("({coef_latex}) \\cdot {monomial_latex}") } else { - format!("{coef_latex} \\cdot {monomial}") + format!("{coef_latex} \\cdot {monomial_latex}") } }; diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index 0baf24b74..05a6dc16e 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -22,7 +22,8 @@ use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use derivative::Derivative; use duplicate::duplicate_item; -use crate::latex::{Latex, ToLatex}; +use crate::latex::{Latex, ToLatex, ToLatexWithMap}; +use crate::zero::QualifiedName; /// A commutative monoid, written additively. pub trait AdditiveMonoid: Add + Zero {} @@ -588,17 +589,17 @@ where } } -impl ToLatex for Monomial +impl ToLatexWithMap for Monomial where - Var: Display + ToLatex, + Var: Display + ToLatexWithMap, Exp: Display + ToLatex + PartialEq + One, { /// Convert to a LaTeX string, separating variables with `\cdot`. - fn to_latex(&self) -> Latex { + fn to_latex_with_map String>(&self, f: F) -> Latex { let fmt_power = |var: &Var, exp: &Exp| { - let Latex(var_latex) = var.to_latex(); + let Latex(var_latex) = var.to_latex_with_map(|v| f(v)); if exp.is_one() { - format!("{var_latex}") + var_latex.to_string() } else { let exp = exp.to_string(); if exp.len() == 1 { From 25e4adeeebd5b63d35c58e79be510dde2e3a39a0 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 13:14:12 +0100 Subject: [PATCH 16/38] ENH: Combined latex_ob_names and latex_mor_names --- packages/catlog-wasm/src/analyses.rs | 29 +++---- packages/catlog-wasm/src/latex.rs | 45 +++++------ packages/catlog/src/latex.rs | 40 +++++----- .../catlog/src/simulate/ode/polynomial.rs | 4 +- .../src/stdlib/analyses/ode/mass_action.rs | 76 +++++++++---------- packages/catlog/src/zero/alg.rs | 6 +- packages/catlog/src/zero/rig.rs | 2 +- .../src/stdlib/analyses/mass_action.tsx | 8 +- .../analyses/mass_action_config_form.tsx | 8 +- 9 files changed, 102 insertions(+), 116 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 691b5185c..9367c9c66 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -8,7 +8,7 @@ use catlog::simulate::ode::PolynomialSystem; use catlog::stdlib::analyses::ode::{self, ODESemanticsAnalysis, ODESemanticsProblemData}; use catlog::zero::QualifiedName; -use crate::latex::{latex_mor_names, latex_ob_names}; +use crate::latex::latex_names; use super::model::DblModel; use super::result::JsResult; @@ -53,9 +53,7 @@ pub(crate) fn polynomial_ode_equations( ) -> Result { let sys = polynomial_ode_system(model); let equations = sys? - .map_variables(latex_ob_names(model)) - .extend_scalars(|param| param.map_variables(latex_mor_names(model))) - .to_latex_equations(); + .to_latex_equations_with_map(|param| latex_names(&model)(param)); Ok(equations) } @@ -67,7 +65,7 @@ pub(crate) fn polynomial_ode_simulation( let sys = polynomial_ode_system(model); let sys_extended_scalars = ode::extend_polynomial_ode_scalars(sys?, &data); let latex_equations = - sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, data); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { @@ -128,10 +126,7 @@ pub(crate) fn mass_action_equations( ) -> Result { let sys = mass_action_system(model, data.mass_conservation_type, logic); let equations = sys? - .map_variables(latex_ob_names(model)) - //TODO: FIX THIS - // .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(model))) - .to_latex_equations(); + .to_latex_equations_with_map(|param| latex_names(&model)(param)); Ok(equations) } @@ -144,7 +139,7 @@ pub(crate) fn mass_action_simulation( let sys = mass_action_system(model, data.mass_conservation_type, logic); let sys_extended_scalars = data.extend_scalars(sys?); let latex_equations = - sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); let analysis = data.build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { @@ -175,10 +170,7 @@ pub struct LotkaVolterraEquationsData { pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { let sys = lotka_volterra_system(model); let equations = sys? - .map_variables(latex_ob_names(model)) - //TODO: FIX THIS - // .extend_scalars(|param| param.map_variables(latex_mor_names_lotka_volterra(model))) - .to_latex_equations(); + .to_latex_equations_with_map(|param| latex_names(&model)(param)); Ok(equations) } @@ -190,7 +182,7 @@ pub(crate) fn lotka_volterra_simulation( let sys = lotka_volterra_system(model); let sys_extended_scalars = data.extend_scalars(sys?); let latex_equations = - sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); let analysis = data.build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { @@ -220,10 +212,7 @@ pub struct LCCEquationsData { pub(crate) fn linear_ode_equations(model: &DblModel) -> Result { let sys = linear_ode_system(model); let equations = sys? - .map_variables(latex_ob_names(model)) - //TODO: FIX THIS - // .extend_scalars(|param| param.map_variables(latex_mor_names_linear_ode(model))) - .to_latex_equations(); + .to_latex_equations_with_map(|param| latex_names(&model)(param)); Ok(equations) } @@ -235,7 +224,7 @@ pub(crate) fn linear_ode_simulation( let sys = linear_ode_system(model); let sys_extended_scalars = data.extend_scalars(sys?); let latex_equations = - sys_extended_scalars.map_variables(latex_ob_names(model)).to_latex_equations(); + sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); let analysis = data.build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index f6c9cb586..ecb00c695 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -4,26 +4,23 @@ use catlog::zero::QualifiedName; use super::model::DblModel; -/// Creates a closure that formats object names for LaTeX output. -pub(crate) fn latex_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { +/// Creates a closure that formats object and morphism names for LaTeX output. When a morphism has a +/// name (and thus label), it is used directly; when unnamed, the label falls back to the format +/// `domain→codomain` (e.g., `X \to Y`). +pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { |id: &QualifiedName| { - let name = model.ob_namespace.label_string(id); - if name.chars().count() > 1 { - format!("\\text{{{name}}}") - } else { - name - } - } -} - -/// When a morphism has a label, it is used directly. When unnamed, the label -/// falls back to the domain→codomain format (e.g., `X \to Y`). -pub(crate) fn latex_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { - // Returns a LaTeX fragment for a morphism, suitable for use as a subscript. - // Named morphisms produce `\text{name}`, unnamed ones produce `\text{dom} \to \text{cod}`. - |id: &QualifiedName| { - if let Some(label) = model.mor_namespace.label(id) { - format!("\\text{{{label}}}") + if let Some(ob_label) = model.ob_namespace.label(id) { + if ob_label.to_string().chars().count() > 1 { + format!("\\text{{{ob_label}}}") + } else { + format!("{ob_label}") + } + } else if let Some(mor_label) = model.mor_namespace.label(id) { + if mor_label.to_string().chars().count() > 1 { + format!("\\text{{{mor_label}}}") + } else { + format!("{mor_label}") + } } else { let (dom, cod) = model .mor_generator_dom_cod_label_strings(id) @@ -53,14 +50,13 @@ mod tests { let tab_model = model.discrete_tab().unwrap(); let analysis = StockFlowMassActionAnalysis { mass_conservation_type: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerFlow, + ode::RateGranularity::PerTransition, ), ..StockFlowMassActionAnalysis::default() }; let sys = analysis.build_system(tab_model); let equations = sys - .map_variables(latex_ob_names(&model)) - .to_latex_equations_with_map(|param| latex_mor_names(&model)(param)); + .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { @@ -85,14 +81,13 @@ mod tests { let tab_model = model.discrete_tab().unwrap(); let analysis = StockFlowMassActionAnalysis { mass_conservation_type: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerFlow, + ode::RateGranularity::PerTransition, ), ..StockFlowMassActionAnalysis::default() }; let sys = analysis.build_system(tab_model); let equations = sys - .map_variables(latex_ob_names(&model)) - .to_latex_equations_with_map(|param| latex_mor_names(&model)(param)); + .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index cc6d34552..503d2a5bb 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -28,6 +28,25 @@ impl fmt::Display for Latex { } } +/// An equation in Latex format with a left-hand side and a right-hand side. +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct LatexEquation { + /// The left-hand side of the equation. + pub lhs: Latex, + /// The right-hand side of the equation. + pub rhs: Latex, +} + +/// Symbolic equations in Latex format. +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub struct LatexEquations(pub Vec); + /// An object that can be rendered to Latex. pub trait ToLatex { /// Convert the object to its Latex representation. @@ -40,6 +59,7 @@ pub trait ToLatexWithMap { fn to_latex_with_map String>(&self, f: F) -> Latex; } +// TODO: documentation impl ToLatex for T where T: ToLatexWithMap, @@ -50,28 +70,10 @@ where } } +// TODO: documentation #[duplicate_item(T; [f32]; [f64]; [i8]; [i32]; [i64]; [u32]; [u64]; [usize]; [char]; [String])] impl ToLatexWithMap for T { fn to_latex_with_map String>(&self, _f: F) -> Latex { Latex(self.to_string()) } } - -/// An equation in Latex format with a left-hand side and a right-hand side. -#[derive(Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct LatexEquation { - /// The left-hand side of the equation. - pub lhs: Latex, - /// The right-hand side of the equation. - pub rhs: Latex, -} - -/// Symbolic equations in Latex format. -#[derive(Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct LatexEquations(pub Vec); diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 2c7588850..877c4d2f5 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -102,8 +102,8 @@ where self.components .iter() .map(|(var, poly)| LatexEquation { - lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}")), - rhs: poly.to_latex_with_map(|p| f(p)), + lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {}", var.to_latex_with_map(|var| f(var)))), + rhs: poly.to_latex_with_map(|term| f(term)), }) .collect(), ) diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 99114e629..9fd8d75c3 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -69,12 +69,12 @@ pub enum MassConservationType { #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] pub enum RateGranularity { - /// Each flow gets assigned a single consumption and single production rate. - PerFlow, + /// Each flow (transition) gets assigned a single consumption and single production rate. + PerTransition, - /// Each flow gets assigned a consumption rate for each input stock and - /// a production rate for each output stock. - PerStock, + /// Each flow (transition) gets assigned a consumption rate for each input stock (place) and + /// a production rate for each output stock (place). + PerPlace, } /// Now, corresponding to each term of `MassConvervationType`, we have different @@ -100,14 +100,14 @@ pub enum MassActionParameter { #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum RateParameter { /// For per flow rates, we simply need to know the associated flow. - PerFlow { + PerTransition { /// The flow to which we associate the rate parameter. flow: QualifiedName, }, /// For per stock rates, we need to know both the transition and the corresponding /// input/output stock. - PerStock { + PerPlace { /// The flow whose input/output objects we wish to associate rate parameters. flow: QualifiedName, /// The input/output stock to which we associate the rate parameter. @@ -135,25 +135,25 @@ impl fmt::Display for MassActionParameter { } Self::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerFlow { flow: trans }, + parameter: RateParameter::PerTransition { flow: trans }, } => { write!(f, "Incoming({})", trans) } Self::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerStock { flow: trans, stock: output }, + parameter: RateParameter::PerPlace { flow: trans, stock: output }, } => { write!(f, "([{}]->{})", trans, output) } Self::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerFlow { flow: trans }, + parameter: RateParameter::PerTransition { flow: trans }, } => { write!(f, "Outgoing({})", trans) } Self::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerStock { flow: trans, stock: input }, + parameter: RateParameter::PerPlace { flow: trans, stock: input }, } => { write!(f, "({}->[{}])", input, trans) } @@ -167,17 +167,17 @@ impl ToLatexWithMap for MassActionParameter { MassActionParameter::Balanced { flow } => Latex(format!("r_{{{}}}", f(flow))), MassActionParameter::Unbalanced { direction, parameter } => { match (direction, parameter) { - (Direction::IncomingFlow, RateParameter::PerFlow { flow }) => { + (Direction::IncomingFlow, RateParameter::PerTransition { flow }) => { Latex(format!("\\rho_{{{}}}", f(flow))) } - (Direction::OutgoingFlow, RateParameter::PerFlow { flow }) => { + (Direction::OutgoingFlow, RateParameter::PerTransition { flow }) => { Latex(format!("\\kappa_{{{}}}", f(flow))) } - (Direction::IncomingFlow, RateParameter::PerStock { flow, stock }) => { - Latex(format!("\\rho_{{{}}}^{{{}}}", f(flow), stock)) + (Direction::IncomingFlow, RateParameter::PerPlace { flow, stock }) => { + Latex(format!("\\rho_{{{}}}^{{{}}}", f(flow), f(stock))) } - (Direction::OutgoingFlow, RateParameter::PerStock { flow, stock }) => { - Latex(format!("\\kappa_{{{}}}^{{{}}}", f(flow), stock)) + (Direction::OutgoingFlow, RateParameter::PerPlace { flow, stock }) => { + Latex(format!("\\kappa_{{{}}}^{{{}}}", f(flow), f(stock))) } } } @@ -255,13 +255,13 @@ impl MassActionParameter::Balanced { flow: transition.clone() } } MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerFlow => MassActionParameter::Unbalanced { + RateGranularity::PerTransition => MassActionParameter::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerFlow { flow: transition.clone() }, + parameter: RateParameter::PerTransition { flow: transition.clone() }, }, - RateGranularity::PerStock => MassActionParameter::Unbalanced { + RateGranularity::PerPlace => MassActionParameter::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerStock { + parameter: RateParameter::PerPlace { flow: transition.clone(), stock: output.clone(), }, @@ -286,20 +286,20 @@ impl // \dot{x_i} -= Parameter_! \cdot x_1...x_n // where Parameter_! depends on `mass_conservation_type`: // Balanced => Parameter_T - // Unbalanced::PerFlow => Parameter_T^outflow - // Unbalanced::PerStock => Parameter_{T,x_i}^outflow + // Unbalanced::PerTransition => Parameter_T^outflow + // Unbalanced::PerPlace => Parameter_{T,x_i}^outflow let parameter = match self.mass_conservation_type { MassConservationType::Balanced => { MassActionParameter::Balanced { flow: transition.clone() } } MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerFlow => MassActionParameter::Unbalanced { + RateGranularity::PerTransition => MassActionParameter::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerFlow { flow: transition.clone() }, + parameter: RateParameter::PerTransition { flow: transition.clone() }, }, - RateGranularity::PerStock => MassActionParameter::Unbalanced { + RateGranularity::PerPlace => MassActionParameter::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerStock { + parameter: RateParameter::PerPlace { flow: transition.clone(), stock: input.clone(), }, @@ -385,7 +385,7 @@ impl // where Parameter_! and Parameter_? depend on `mass_conservation_type`: // Balanced => Parameter_! = Parameter_F // Parameter_? = Parameter_F - // Unbalanced::PerFlow => Parameter_! = Parameter_F^inflow + // Unbalanced::PerTransition => Parameter_! = Parameter_F^inflow // Parameter_? = Parameter_F^outflow let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); @@ -395,7 +395,7 @@ impl } MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerFlow { flow: flow.clone() }, + parameter: RateParameter::PerTransition { flow: flow.clone() }, }, }; builder.add_contribution( @@ -413,7 +413,7 @@ impl } MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerFlow { flow: flow.clone() }, + parameter: RateParameter::PerTransition { flow: flow.clone() }, }, }; builder.add_contribution( @@ -495,13 +495,13 @@ impl ODESemanticsProblemData for MassActionProblemData { } MassActionParameter::Unbalanced { direction, parameter } => { match (direction, parameter) { - (Direction::IncomingFlow, RateParameter::PerFlow { flow: transition }) => { + (Direction::IncomingFlow, RateParameter::PerTransition { flow: transition }) => { self.transition_production_rates .get(transition) .cloned() .unwrap_or_default() } - (Direction::OutgoingFlow, RateParameter::PerFlow { flow: transition }) => { + (Direction::OutgoingFlow, RateParameter::PerTransition { flow: transition }) => { self.transition_consumption_rates .get(transition) .cloned() @@ -509,7 +509,7 @@ impl ODESemanticsProblemData for MassActionProblemData { } ( Direction::IncomingFlow, - RateParameter::PerStock { flow: transition, stock: place }, + RateParameter::PerPlace { flow: transition, stock: place }, ) => self .place_production_rates .get(transition) @@ -518,7 +518,7 @@ impl ODESemanticsProblemData for MassActionProblemData { .unwrap_or_default(), ( Direction::OutgoingFlow, - RateParameter::PerStock { flow: transition, stock: place }, + RateParameter::PerPlace { flow: transition, stock: place }, ) => self .place_consumption_rates .get(transition) @@ -566,7 +566,7 @@ mod tests { let model = backward_link(th); let sys = StockFlowMassActionAnalysis { mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerFlow, + analyses::ode::RateGranularity::PerTransition, ), ..StockFlowMassActionAnalysis::default() } @@ -600,7 +600,7 @@ mod tests { let model = catalyzed_reaction(th); let sys = PetriNetMassActionAnalysis { mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerFlow, + analyses::ode::RateGranularity::PerTransition, ), ..PetriNetMassActionAnalysis::default() } @@ -619,7 +619,7 @@ mod tests { let model = catalyzed_reaction(th); let sys = PetriNetMassActionAnalysis { mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerStock, + analyses::ode::RateGranularity::PerPlace, ), ..PetriNetMassActionAnalysis::default() } @@ -640,7 +640,7 @@ mod tests { let model = backward_link(th); let sys = StockFlowMassActionAnalysis { mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( - analyses::ode::RateGranularity::PerFlow, + analyses::ode::RateGranularity::PerTransition, ), ..StockFlowMassActionAnalysis::default() } diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 12d5f3346..b17257629 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -156,11 +156,11 @@ where Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + ToLatex + PartialEq + One, { - /// Convert to a LaTeX string, formatting each monomial via [`Monomial::to_latex`]. + /// Convert to a LaTeX string, formatting each monomial via [`Monomial::to_latex_with_map`]. fn to_latex_with_map String>(&self, f: F) -> Latex { let fmt_term = |coef: &Coef, monomial: &Monomial| -> String { - let Latex(monomial_latex) = monomial.to_latex_with_map(|m| f(m)); - let Latex(coef_latex) = coef.to_latex_with_map(|c| f(c)); + let Latex(monomial_latex) = monomial.to_latex_with_map(|mon| f(mon)); + let Latex(coef_latex) = coef.to_latex_with_map(|param| f(param)); if coef.is_one() { monomial_latex } else if *coef == Coef::one().neg() { diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index 05a6dc16e..0a77012ca 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -597,7 +597,7 @@ where /// Convert to a LaTeX string, separating variables with `\cdot`. fn to_latex_with_map String>(&self, f: F) -> Latex { let fmt_power = |var: &Var, exp: &Exp| { - let Latex(var_latex) = var.to_latex_with_map(|v| f(v)); + let Latex(var_latex) = var.to_latex_with_map(|variable| f(variable)); if exp.is_one() { var_latex.to_string() } else { diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index e7c5c72d8..6cfa1fe43 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -160,7 +160,7 @@ export default function MassAction( }), ]; - // Secondly, the case MassConservationType = Unbalanced(PerFlow) + // Secondly, the case MassConservationType = Unbalanced(PerTransition) const morInputSchema: ColumnSchema[] = [ { contentType: "string", @@ -196,7 +196,7 @@ export default function MassAction( }), ]; - // Finally, the case MassConservationType = Unbalanced(PerStock) + // Finally, the case MassConservationType = Unbalanced(PerPlace) const morInputsSchema: ColumnSchema<[QualifiedName, QualifiedName]>[] = [ { contentType: "string", @@ -259,7 +259,7 @@ export default function MassAction( @@ -268,7 +268,7 @@ export default function MassAction( diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index 0d9a04aac..6c0f5e282 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -32,7 +32,7 @@ export function MassActionConfigForm(props: { } else { content.massConservationType = { type: "Unbalanced", - granularity: "PerFlow", + granularity: "PerPlace", }; } }); @@ -41,7 +41,7 @@ export function MassActionConfigForm(props: { { props.changeConfig((content) => { if (content.massConservationType.type === "Unbalanced") { @@ -51,8 +51,8 @@ export function MassActionConfigForm(props: { }); }} > - - + + From 05d8dbfbec2313d18df4841b6dc34967ffae3bee Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 18:09:21 +0100 Subject: [PATCH 17/38] Documentation --- packages/catlog/src/latex.rs | 16 ++++++++--- .../catlog/src/simulate/ode/polynomial.rs | 28 +++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 503d2a5bb..f538561db 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -53,13 +53,20 @@ pub trait ToLatex { fn to_latex(&self) -> Latex; } -/// TODO: documentation +/// An object that can be rendered to Latex, with some function that can be applied to selected +/// appearances of a `QualifiedName` within the object. The main purpose of this trait is for rendering +/// the equations derived from an ODE semantics analysis, where we do not want to show UUIDs directly +/// to the frontend. For an example implementation see e.g. `catlog::src::stdlib::analyses::ode::mass_action` +/// where this is implemented for `MassActionParameter`. pub trait ToLatexWithMap { - /// TODO: documentation + /// Convert the object to its Latex representation, after applying the provided function `f` to + /// selected `QualifiedName`. See `PolynomialSystem::to_latex_equations_with_map` for the main + /// use of this function. fn to_latex_with_map String>(&self, f: F) -> Latex; } -// TODO: documentation +/// We can recover the intended behaviour of `to_latex` by simply passing the "identity function" +/// to `to_latex_with_map`. impl ToLatex for T where T: ToLatexWithMap, @@ -70,7 +77,8 @@ where } } -// TODO: documentation +/// We only want to apply the `f : &QualifiedName -> String` to something of type `QualifiedName`; +/// we leave any numerical or string-literal values unchanged. #[duplicate_item(T; [f32]; [f64]; [i8]; [i32]; [i64]; [u32]; [u64]; [usize]; [char]; [String])] impl ToLatexWithMap for T { fn to_latex_with_map String>(&self, _f: F) -> Latex { diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 877c4d2f5..6774c5025 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -91,7 +91,22 @@ where PolynomialSystem { components } } - /// TODO: documentation + /// Converts to equations as Latex strings. + pub fn to_latex_equations(&self) -> LatexEquations + where + Var: Display + ToLatexWithMap, + Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, + Exp: Display + ToLatex + PartialEq + One, + { + let name = |id: &QualifiedName| {id.to_string()}; + self.to_latex_equations_with_map(name) + } + + /// Converts to equations as Latex string, after applying the function `f : &QualifiedName -> String` + /// to each of the variables and coefficients. This is intended for frontend functionality, where we + /// do not want to display UUIDs directly but instead look them up in the model namespace. For more + /// details, see `catlog-wasm::src::latex` where we use `to_latex_equations_with_map` and pass in + /// the function `catlog-wasm::src::latex_names`. pub fn to_latex_equations_with_map String>(&self, f: F) -> LatexEquations where Var: Display + ToLatexWithMap, @@ -108,17 +123,6 @@ where .collect(), ) } - - /// Converts to equations as LaTeX strings. - pub fn to_latex_equations(&self) -> LatexEquations - where - Var: Display + ToLatexWithMap, - Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, - Exp: Display + ToLatex + PartialEq + One, - { - let name = |id: &QualifiedName| {id.to_string()}; - self.to_latex_equations_with_map(name) - } } impl PolynomialSystem From 2f40de0f1fb10d20ea997650d6b7b4e8948e8dbd Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 18:12:48 +0100 Subject: [PATCH 18/38] WIP: More latex frontend tests --- packages/catlog-wasm/src/latex.rs | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index ecb00c695..b20f68684 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -45,7 +45,31 @@ mod tests { use crate::model::{DblModel, tests::backward_link}; #[test] - fn unbalanced_mass_action_latex_equations() { + fn stock_flow_balanced_mass_action_latex_equations() { + let model = backward_link("xxx", "yyy", "fff"); + let tab_model = model.discrete_tab().unwrap(); + let analysis = StockFlowMassActionAnalysis::default(); + let sys = analysis.build_system(tab_model); + let equations = sys + .to_latex_equations_with_map(|param| latex_names(&model)(param)); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), + rhs: Latex( + "-r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), + rhs: Latex("r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + #[test] + fn stock_flow_unbalanced_mass_action_latex_equations() { let model = backward_link("xxx", "yyy", "fff"); let tab_model = model.discrete_tab().unwrap(); let analysis = StockFlowMassActionAnalysis { @@ -73,7 +97,17 @@ mod tests { assert_eq!(equations, expected); } - // TODO: add more tests here for the other ODE semantics + #[test] + fn cld_lotka_volterra_latex_equations() {} + + #[test] + fn cld_lcc_latex_equations() {} + + #[test] + fn petri_net_unbalanced_pp_mass_action_latex_equations() {} + + #[test] + fn petri_net_unbalanced_pt_mass_action_latex_equations() {} #[test] fn unnamed_mor_uses_dom_cod_in_equations() { From cccc3159008595e2bd380ef0eb5490402012298b Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 18:21:47 +0100 Subject: [PATCH 19/38] WIP: Refactor catlog-wasm/src/analyses --- packages/catlog-wasm/src/analyses.rs | 183 +++++++++--------- packages/catlog-wasm/src/latex.rs | 29 +-- packages/catlog-wasm/src/theories.rs | 1 + packages/catlog/src/latex.rs | 2 +- .../catlog/src/simulate/ode/polynomial.rs | 12 +- .../src/stdlib/analyses/ode/lotka_volterra.rs | 8 +- .../src/stdlib/analyses/ode/mass_action.rs | 28 +-- .../src/stdlib/analyses/ode/ode_semantics.rs | 4 +- 8 files changed, 142 insertions(+), 125 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 9367c9c66..2a2d24c78 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -29,13 +29,9 @@ pub struct ODEResultWithEquations { pub latex_equations: LatexEquations, } -/// The analysis data for polynomial ODE equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct PolynomialODEEquationsData { - #[serde(rename = "trivialData")] - trivial_data: bool, -} + + + /// Generates the PolynomialSystem for the systems of polynomial ODEs. fn polynomial_ode_system( @@ -46,34 +42,26 @@ fn polynomial_ode_system( Ok(analysis.build_system(realised_model)) } -/// Generates equations for the system of polynomial ODEs. -pub(crate) fn polynomial_ode_equations( +/// Generates the PolynomialSystem for Lotka-Volterra dynamics. +fn lotka_volterra_system( model: &DblModel, - _data: PolynomialODEEquationsData, -) -> Result { - let sys = polynomial_ode_system(model); - let equations = sys? - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - Ok(equations) +) -> Result, i8>, String> +{ + let realised_model = model.discrete()?; + let analysis = ode::LotkaVolterraAnalysis::default(); + Ok(analysis.build_system(realised_model)) } -/// Simulates mass-action ODEs. -pub(crate) fn polynomial_ode_simulation( +/// Generates the PolynomialSystem for LCC dynamics. +fn linear_ode_system( model: &DblModel, - data: ode::PolynomialODEProblemData, -) -> Result { - let sys = polynomial_ode_system(model); - let sys_extended_scalars = ode::extend_polynomial_ode_scalars(sys?, &data); - let latex_equations = - sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, data); - let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); - Ok(ODEResultWithEquations { - solution: ODEResult(solution.into()), - latex_equations, - }) +) -> Result, i8>, String> { + let realised_model = model.discrete()?; + let analysis = ode::LCCAnalysis::default(); + Ok(analysis.build_system(realised_model)) } +// TODO: you should be able to REMOVE this enum (or EXTEND it to also contain e.g. Lotka-Volterra) /// Mass-action analysis is currently implemented for Petri nets and stock-flow diagrams /// and we can avoid some code reduplication by making this explicit. pub enum MassActionAnalysisLogic { @@ -109,6 +97,55 @@ fn mass_action_system( } } + + + + +/// The analysis data for polynomial ODE equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct PolynomialODEEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} +/// Generates equations for the system of polynomial ODEs. +pub(crate) fn polynomial_ode_equations( + model: &DblModel, + _data: PolynomialODEEquationsData, +) -> Result { + let sys = polynomial_ode_system(model); + let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); + Ok(equations) +} + +/// The analysis data for Lotka-Volterra equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct LotkaVolterraEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} +/// Generates Lotka-Volterra equations for the system. +pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { + let sys = lotka_volterra_system(model); + let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); + Ok(equations) +} + +/// The analysis data for LCC equations. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct LCCEquationsData { + #[serde(rename = "trivialData")] + trivial_data: bool, +} +/// Generates LCC equations for the system. +pub(crate) fn linear_ode_equations(model: &DblModel) -> Result { + let sys = linear_ode_system(model); + let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); + Ok(equations) +} + /// The analysis data for mass-action equations. #[derive(Serialize, Deserialize, Tsify)] #[tsify(into_wasm_abi, from_wasm_abi)] @@ -117,7 +154,6 @@ pub struct MassActionEquationsData { #[serde(rename = "massConservationType")] pub mass_conservation_type: ode::MassConservationType, } - /// Generates mass-action equations for the system. pub(crate) fn mass_action_equations( model: &DblModel, @@ -125,22 +161,24 @@ pub(crate) fn mass_action_equations( logic: MassActionAnalysisLogic, ) -> Result { let sys = mass_action_system(model, data.mass_conservation_type, logic); - let equations = sys? - .to_latex_equations_with_map(|param| latex_names(&model)(param)); + let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); Ok(equations) } + + + + /// Simulates mass-action ODEs. -pub(crate) fn mass_action_simulation( +pub(crate) fn polynomial_ode_simulation( model: &DblModel, - data: ode::MassActionProblemData, - logic: MassActionAnalysisLogic, + data: ode::PolynomialODEProblemData, ) -> Result { - let sys = mass_action_system(model, data.mass_conservation_type, logic); - let sys_extended_scalars = data.extend_scalars(sys?); + let sys = polynomial_ode_system(model); + let sys_extended_scalars = ode::extend_polynomial_ode_scalars(sys?, &data); let latex_equations = sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = data.build_analysis(sys_extended_scalars); + let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, data); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), @@ -148,32 +186,6 @@ pub(crate) fn mass_action_simulation( }) } -/// Generates the PolynomialSystem for Lotka-Volterra dynamics. -fn lotka_volterra_system( - model: &DblModel, -) -> Result, i8>, String> -{ - let realised_model = model.discrete()?; - let analysis = ode::LotkaVolterraAnalysis::default(); - Ok(analysis.build_system(realised_model)) -} - -/// The analysis data for polynomial ODE equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct LotkaVolterraEquationsData { - #[serde(rename = "trivialData")] - trivial_data: bool, -} - -/// Generates Lotka-Volterra equations for the system. -pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { - let sys = lotka_volterra_system(model); - let equations = sys? - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - Ok(equations) -} - /// Simulates Lotka-Volterra ODEs. pub(crate) fn lotka_volterra_simulation( model: &DblModel, @@ -191,37 +203,30 @@ pub(crate) fn lotka_volterra_simulation( }) } -/// Generates the PolynomialSystem for linear ODE dynamics. -fn linear_ode_system( +/// Simulates LCC equations. +pub(crate) fn linear_ode_simulation( model: &DblModel, -) -> Result, i8>, String> { - let realised_model = model.discrete()?; - let analysis = ode::LCCAnalysis::default(); - Ok(analysis.build_system(realised_model)) -} - -/// The analysis data for polynomial ODE equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct LCCEquationsData { - #[serde(rename = "trivialData")] - trivial_data: bool, -} - -/// Generates linear ODE equations for the system. -pub(crate) fn linear_ode_equations(model: &DblModel) -> Result { + data: ode::LCCProblemData, +) -> Result { let sys = linear_ode_system(model); - let equations = sys? - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - Ok(equations) + let sys_extended_scalars = data.extend_scalars(sys?); + let latex_equations = + sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); + let analysis = data.build_analysis(sys_extended_scalars); + let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); + Ok(ODEResultWithEquations { + solution: ODEResult(solution.into()), + latex_equations, + }) } -/// Simulates linear ODE equations. -pub(crate) fn linear_ode_simulation( +/// Simulates mass-action ODEs. +pub(crate) fn mass_action_simulation( model: &DblModel, - data: ode::LCCProblemData, + data: ode::MassActionProblemData, + logic: MassActionAnalysisLogic, ) -> Result { - let sys = linear_ode_system(model); + let sys = mass_action_system(model, data.mass_conservation_type, logic); let sys_extended_scalars = data.extend_scalars(sys?); let latex_equations = sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index b20f68684..b518d91f5 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -50,15 +50,12 @@ mod tests { let tab_model = model.discrete_tab().unwrap(); let analysis = StockFlowMassActionAnalysis::default(); let sys = analysis.build_system(tab_model); - let equations = sys - .to_latex_equations_with_map(|param| latex_names(&model)(param)); + let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), - rhs: Latex( - "-r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), - ), + rhs: Latex("-r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), @@ -79,8 +76,7 @@ mod tests { ..StockFlowMassActionAnalysis::default() }; let sys = analysis.build_system(tab_model); - let equations = sys - .to_latex_equations_with_map(|param| latex_names(&model)(param)); + let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { @@ -98,16 +94,24 @@ mod tests { } #[test] - fn cld_lotka_volterra_latex_equations() {} + fn cld_lotka_volterra_latex_equations() { + todo!() + } #[test] - fn cld_lcc_latex_equations() {} + fn cld_lcc_latex_equations() { + todo!() + } #[test] - fn petri_net_unbalanced_pp_mass_action_latex_equations() {} + fn petri_net_unbalanced_pp_mass_action_latex_equations() { + todo!() + } #[test] - fn petri_net_unbalanced_pt_mass_action_latex_equations() {} + fn petri_net_unbalanced_pt_mass_action_latex_equations() { + todo!() + } #[test] fn unnamed_mor_uses_dom_cod_in_equations() { @@ -120,8 +124,7 @@ mod tests { ..StockFlowMassActionAnalysis::default() }; let sys = analysis.build_system(tab_model); - let equations = sys - .to_latex_equations_with_map(|param| latex_names(&model)(param)); + let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 1bc7c87fb..8f9712c7e 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -7,6 +7,7 @@ use std::rc::Rc; use wasm_bindgen::prelude::*; use catlog::dbl::theory::{self as theory, NonUnital, Unital}; +use catlog::latex::LatexEquations; use catlog::one::Path; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::name; diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index f538561db..0f6278dc8 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -72,7 +72,7 @@ where T: ToLatexWithMap, { fn to_latex(&self) -> Latex { - let name = |id: &QualifiedName| {id.to_string()}; + let name = |id: &QualifiedName| id.to_string(); self.to_latex_with_map(name) } } diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 6774c5025..bb5d0de81 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -98,7 +98,7 @@ where Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + ToLatex + PartialEq + One, { - let name = |id: &QualifiedName| {id.to_string()}; + let name = |id: &QualifiedName| id.to_string(); self.to_latex_equations_with_map(name) } @@ -107,7 +107,10 @@ where /// do not want to display UUIDs directly but instead look them up in the model namespace. For more /// details, see `catlog-wasm::src::latex` where we use `to_latex_equations_with_map` and pass in /// the function `catlog-wasm::src::latex_names`. - pub fn to_latex_equations_with_map String>(&self, f: F) -> LatexEquations + pub fn to_latex_equations_with_map String>( + &self, + f: F, + ) -> LatexEquations where Var: Display + ToLatexWithMap, Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, @@ -117,7 +120,10 @@ where self.components .iter() .map(|(var, poly)| LatexEquation { - lhs: Latex(format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {}", var.to_latex_with_map(|var| f(var)))), + lhs: Latex(format!( + "\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {}", + var.to_latex_with_map(|var| f(var)) + )), rhs: poly.to_latex_with_map(|term| f(term)), }) .collect(), diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index a346bfcc9..52c67957f 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -290,15 +290,11 @@ mod test { let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex( - "g_{x} \\cdot x - k_{negative} \\cdot x \\cdot y".to_string(), - ), + rhs: Latex("g_{x} \\cdot x - k_{negative} \\cdot x \\cdot y".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex( - "k_{positive} \\cdot x \\cdot y + g_{y} \\cdot y".to_string(), - ), + rhs: Latex("k_{positive} \\cdot x \\cdot y + g_{y} \\cdot y".to_string()), }, ]); assert_eq!(expected, sys.to_latex_equations()); diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 9fd8d75c3..092129cdc 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -495,18 +495,22 @@ impl ODESemanticsProblemData for MassActionProblemData { } MassActionParameter::Unbalanced { direction, parameter } => { match (direction, parameter) { - (Direction::IncomingFlow, RateParameter::PerTransition { flow: transition }) => { - self.transition_production_rates - .get(transition) - .cloned() - .unwrap_or_default() - } - (Direction::OutgoingFlow, RateParameter::PerTransition { flow: transition }) => { - self.transition_consumption_rates - .get(transition) - .cloned() - .unwrap_or_default() - } + ( + Direction::IncomingFlow, + RateParameter::PerTransition { flow: transition }, + ) => self + .transition_production_rates + .get(transition) + .cloned() + .unwrap_or_default(), + ( + Direction::OutgoingFlow, + RateParameter::PerTransition { flow: transition }, + ) => self + .transition_consumption_rates + .get(transition) + .cloned() + .unwrap_or_default(), ( Direction::IncomingFlow, RateParameter::PerPlace { flow: transition, stock: place }, diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 2b0abb7c1..cf99ed90f 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -28,7 +28,9 @@ use std::{collections::HashMap, fmt}; use crate::{ dbl::{ modal::{List, ModeApp}, - model::{DblModel, DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel}, + model::{ + DblModel, DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel, + }, theory::{NonUnital, Unital}, }, latex::{Latex, ToLatexWithMap}, From 7baf524ef3e2f34f1d1013fffa8fc019875e0b9f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 17:42:30 +0100 Subject: [PATCH 20/38] WIP: More tests for frontend ODE analyses Latex --- packages/catlog-wasm/src/analyses.rs | 9 +- packages/catlog-wasm/src/latex.rs | 119 ++++++++++++++---- .../catlog/src/simulate/ode/polynomial.rs | 4 + packages/catlog/src/zero/alg.rs | 1 + 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 2a2d24c78..ef4e69eaf 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -42,6 +42,11 @@ fn polynomial_ode_system( Ok(analysis.build_system(realised_model)) } +// TODO: can all of the following be generalised by iterating (with a macro) over all the implementations +// of ODESemanics? use e.g. `::ODEParameter` +// +// ... OR just define `fn polynomial_system` ?????????? + /// Generates the PolynomialSystem for Lotka-Volterra dynamics. fn lotka_volterra_system( model: &DblModel, @@ -169,7 +174,7 @@ pub(crate) fn mass_action_equations( -/// Simulates mass-action ODEs. +/// Simulates polynomial ODE equations. pub(crate) fn polynomial_ode_simulation( model: &DblModel, data: ode::PolynomialODEProblemData, @@ -186,6 +191,8 @@ pub(crate) fn polynomial_ode_simulation( }) } +// TODO: define some closure that takes `sys_extended_scalars` to the result + /// Simulates Lotka-Volterra ODEs. pub(crate) fn lotka_volterra_simulation( model: &DblModel, diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index b518d91f5..fea711760 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -4,38 +4,41 @@ use catlog::zero::QualifiedName; use super::model::DblModel; +fn wrap_with_backslash_text(name: String) -> String { + if name.chars().count() > 1 { + format!("\\text{{{name}}}") + } else { + format!("{name}") + } +} + /// Creates a closure that formats object and morphism names for LaTeX output. When a morphism has a /// name (and thus label), it is used directly; when unnamed, the label falls back to the format /// `domain→codomain` (e.g., `X \to Y`). pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { |id: &QualifiedName| { if let Some(ob_label) = model.ob_namespace.label(id) { - if ob_label.to_string().chars().count() > 1 { - format!("\\text{{{ob_label}}}") - } else { - format!("{ob_label}") - } + wrap_with_backslash_text(ob_label.to_string()) } else if let Some(mor_label) = model.mor_namespace.label(id) { - if mor_label.to_string().chars().count() > 1 { - format!("\\text{{{mor_label}}}") - } else { - format!("{mor_label}") - } + wrap_with_backslash_text(mor_label.to_string()) } else { let (dom, cod) = model .mor_generator_dom_cod_label_strings(id) .expect("Morphism in equation system should have domain and codomain"); - format!("\\text{{{dom}}} \\to \\text{{{cod}}}") + format!("{} \\to {}", wrap_with_backslash_text(dom), wrap_with_backslash_text(cod)) } } } #[cfg(test)] mod tests { + use catcolab_document_types::v2::{MorDecl, MorType, Ob, ObDecl, ObType}; use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; - use catlog::stdlib::analyses::ode::{StockFlowMassActionAnalysis, ode_semantics::*}; + use catlog::stdlib::analyses::ode::{ + LotkaVolterraAnalysis, StockFlowMassActionAnalysis, ode_semantics::*, + }; use catlog::stdlib::{analyses::ode, theories}; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; use std::rc::Rc; @@ -43,6 +46,7 @@ mod tests { use super::*; use crate::model::{DblModel, tests::backward_link}; + use crate::theories::ThSignedCategory; #[test] fn stock_flow_balanced_mass_action_latex_equations() { @@ -69,14 +73,14 @@ mod tests { fn stock_flow_unbalanced_mass_action_latex_equations() { let model = backward_link("xxx", "yyy", "fff"); let tab_model = model.discrete_tab().unwrap(); - let analysis = StockFlowMassActionAnalysis { + let equations = StockFlowMassActionAnalysis { mass_conservation_type: ode::MassConservationType::Unbalanced( ode::RateGranularity::PerTransition, ), ..StockFlowMassActionAnalysis::default() - }; - let sys = analysis.build_system(tab_model); - let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); + } + .build_system(tab_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { @@ -95,36 +99,103 @@ mod tests { #[test] fn cld_lotka_volterra_latex_equations() { - todo!() + let th = ThSignedCategory::new().theory(); + let mut model = DblModel::new(&th); + // Constructing a causal loop diagram with objects x, y and negative links f, g : x -> y. + let [x, y, f, g] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + assert!( + model + .add_ob(&ObDecl { + name: "x".into(), + id: x, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: "yellow".into(), + id: y, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_mor(&MorDecl { + name: "f".into(), + id: f, + mor_type: MorType::Basic("Negative".into()), + dom: Some(Ob::Basic(x.to_string())), + cod: Some(Ob::Basic(y.to_string())), + }) + .is_ok() + ); + assert!( + model + .add_mor(&MorDecl { + name: "".into(), + id: g, + mor_type: MorType::Basic("Negative".into()), + dom: Some(Ob::Basic(x.to_string())), + cod: Some(Ob::Basic(y.to_string())), + }) + .is_ok() + ); + + let discrete_model = model.discrete().unwrap(); + let equations = LotkaVolterraAnalysis::default() + .build_system(discrete_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex( + "g_{x} \\cdot x" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yellow}".to_string()), + rhs: Latex( + "(-k_{f} - k_{x \\to \\text{yellow}}) \\cdot x \\cdot \\text{yellow} + g_{\\text{yellow}} \\cdot \\text{yellow}" + .to_string(), + ), + }, + ]); + + assert_eq!(equations, expected); } #[test] fn cld_lcc_latex_equations() { - todo!() + // TODO } #[test] fn petri_net_unbalanced_pp_mass_action_latex_equations() { - todo!() + // TODO } #[test] fn petri_net_unbalanced_pt_mass_action_latex_equations() { - todo!() + // TODO } #[test] fn unnamed_mor_uses_dom_cod_in_equations() { let model = backward_link("xxx", "yyy", ""); let tab_model = model.discrete_tab().unwrap(); - let analysis = StockFlowMassActionAnalysis { + let equations = StockFlowMassActionAnalysis { mass_conservation_type: ode::MassConservationType::Unbalanced( ode::RateGranularity::PerTransition, ), ..StockFlowMassActionAnalysis::default() - }; - let sys = analysis.build_system(tab_model); - let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); + } + .build_system(tab_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ LatexEquation { diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index bb5d0de81..281e7a2fa 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -102,6 +102,10 @@ where self.to_latex_equations_with_map(name) } + // REQUEST | It might be much cleaner to only implement `to_latex_equations_with_map` in the + // FOR | case where `Var = QualifiedName` and `Coef = Parameter`, but I + // FEEDBACK | cannot figure out how to convince Rust to let me do this. + //__________/ /// Converts to equations as Latex string, after applying the function `f : &QualifiedName -> String` /// to each of the variables and coefficients. This is intended for frontend functionality, where we /// do not want to display UUIDs directly but instead look them up in the model namespace. For more diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index b17257629..8388f674d 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -10,6 +10,7 @@ use std::ops::{Add, AddAssign, Mul, Neg}; use derivative::Derivative; use crate::latex::{Latex, ToLatex, ToLatexWithMap}; +use crate::stdlib::analyses::ode::Parameter; use crate::zero::QualifiedName; use super::rig::*; From 95d81163bcb82bb058f286e8fa17fc5c7c1d5edd Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 18:37:08 +0100 Subject: [PATCH 21/38] WIP: Failing tests (but, again, that's good and intended I promise) --- packages/catlog-wasm/src/analyses.rs | 14 +-- packages/catlog-wasm/src/latex.rs | 167 ++++++++++++++++++--------- packages/catlog-wasm/src/model.rs | 112 ++++++++++++++++++ packages/catlog/src/zero/alg.rs | 1 - 4 files changed, 228 insertions(+), 66 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index ef4e69eaf..321a6fc29 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -29,10 +29,6 @@ pub struct ODEResultWithEquations { pub latex_equations: LatexEquations, } - - - - /// Generates the PolynomialSystem for the systems of polynomial ODEs. fn polynomial_ode_system( model: &DblModel, @@ -44,7 +40,7 @@ fn polynomial_ode_system( // TODO: can all of the following be generalised by iterating (with a macro) over all the implementations // of ODESemanics? use e.g. `::ODEParameter` -// +// // ... OR just define `fn polynomial_system` ?????????? /// Generates the PolynomialSystem for Lotka-Volterra dynamics. @@ -102,10 +98,6 @@ fn mass_action_system( } } - - - - /// The analysis data for polynomial ODE equations. #[derive(Serialize, Deserialize, Tsify)] #[tsify(into_wasm_abi, from_wasm_abi)] @@ -170,10 +162,6 @@ pub(crate) fn mass_action_equations( Ok(equations) } - - - - /// Simulates polynomial ODE equations. pub(crate) fn polynomial_ode_simulation( model: &DblModel, diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index fea711760..3100bb3bb 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -8,7 +8,7 @@ fn wrap_with_backslash_text(name: String) -> String { if name.chars().count() > 1 { format!("\\text{{{name}}}") } else { - format!("{name}") + name.to_string() } } @@ -32,12 +32,12 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String #[cfg(test)] mod tests { - use catcolab_document_types::v2::{MorDecl, MorType, Ob, ObDecl, ObType}; use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; use catlog::stdlib::analyses::ode::{ - LotkaVolterraAnalysis, StockFlowMassActionAnalysis, ode_semantics::*, + LCCAnalysis, LotkaVolterraAnalysis, PetriNetMassActionAnalysis, + StockFlowMassActionAnalysis, ode_semantics::*, }; use catlog::stdlib::{analyses::ode, theories}; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; @@ -45,8 +45,8 @@ mod tests { use uuid::Uuid; use super::*; + use crate::model::tests::{catalytic_petri_net, parallel_negative_cld}; use crate::model::{DblModel, tests::backward_link}; - use crate::theories::ThSignedCategory; #[test] fn stock_flow_balanced_mass_action_latex_equations() { @@ -99,50 +99,8 @@ mod tests { #[test] fn cld_lotka_volterra_latex_equations() { - let th = ThSignedCategory::new().theory(); - let mut model = DblModel::new(&th); - // Constructing a causal loop diagram with objects x, y and negative links f, g : x -> y. - let [x, y, f, g] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; - assert!( - model - .add_ob(&ObDecl { - name: "x".into(), - id: x, - ob_type: ObType::Basic("Object".into()) - }) - .is_ok() - ); - assert!( - model - .add_ob(&ObDecl { - name: "yellow".into(), - id: y, - ob_type: ObType::Basic("Object".into()) - }) - .is_ok() - ); - assert!( - model - .add_mor(&MorDecl { - name: "f".into(), - id: f, - mor_type: MorType::Basic("Negative".into()), - dom: Some(Ob::Basic(x.to_string())), - cod: Some(Ob::Basic(y.to_string())), - }) - .is_ok() - ); - assert!( - model - .add_mor(&MorDecl { - name: "".into(), - id: g, - mor_type: MorType::Basic("Negative".into()), - dom: Some(Ob::Basic(x.to_string())), - cod: Some(Ob::Basic(y.to_string())), - }) - .is_ok() - ); + // The CLD with objects "x" and "yellow", and two negative links "f" and [unnamed] from x to y. + let model = parallel_negative_cld("x", "yellow", "f", ""); let discrete_model = model.discrete().unwrap(); let equations = LotkaVolterraAnalysis::default() @@ -171,17 +129,122 @@ mod tests { #[test] fn cld_lcc_latex_equations() { - // TODO + // The CLD with objects "x" and "yellow", and two negative links "f" and [unnamed] from x to y. + let model = parallel_negative_cld("x", "yellow", "f", ""); + let discrete_model = model.discrete().unwrap(); + let equations = LCCAnalysis::default() + .build_system(discrete_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex("0".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yellow}".to_string()), + rhs: Latex( + "(-\\lambda_{f} - \\lambda_{x \\to \\text{yellow}}) \\cdot x".to_string(), + ), + }, + ]); + + assert_eq!(equations, expected); } #[test] - fn petri_net_unbalanced_pp_mass_action_latex_equations() { - // TODO + fn petri_net_balanced_mass_action_latex_equations() { + // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + let modal_model = model.modal_unital().unwrap(); + let equations = PetriNetMassActionAnalysis::default() + .build_system(modal_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), + rhs: Latex( + "-r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), + rhs: Latex( + "r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("0".to_string()), + }, + ]); + assert_eq!(equations, expected); } #[test] fn petri_net_unbalanced_pt_mass_action_latex_equations() { - // TODO + // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + let modal_model = model.modal_unital().unwrap(); + let equations = PetriNetMassActionAnalysis { + mass_conservation_type: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + ..PetriNetMassActionAnalysis::default() + } + .build_system(modal_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), + rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), + rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + #[test] + fn petri_net_unbalanced_pp_mass_action_latex_equations() { + // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + let modal_model = model.modal_unital().unwrap(); + let equations = PetriNetMassActionAnalysis { + mass_conservation_type: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerPlace, + ), + ..PetriNetMassActionAnalysis::default() + } + .build_system(modal_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); + + // TODO: write down the expected equations + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), + rhs: Latex("".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), + rhs: Latex("".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("".to_string()), + }, + ]); + assert_eq!(equations, expected); } #[test] diff --git a/packages/catlog-wasm/src/model.rs b/packages/catlog-wasm/src/model.rs index 7dae0666c..c43c211f3 100644 --- a/packages/catlog-wasm/src/model.rs +++ b/packages/catlog-wasm/src/model.rs @@ -864,10 +864,67 @@ pub(crate) mod tests { assert_eq!(Result::from(model.validate().0).map_err(|errs| errs.len()), Err(2)); } + //. Construct a causal loop diagram with objects x, y and negative links f, g : x -> y. + pub(crate) fn parallel_negative_cld( + src_name: &str, + tgt_name: &str, + first_link_name: &str, + second_link_name: &str, + ) -> DblModel { + let th = ThSignedCategory::new().theory(); + let mut model = DblModel::new(&th); + let [x, y, f, g] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + + assert!( + model + .add_ob(&ObDecl { + name: src_name.into(), + id: x, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: tgt_name.into(), + id: y, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_mor(&MorDecl { + name: first_link_name.into(), + id: f, + mor_type: MorType::Basic("Negative".into()), + dom: Some(Ob::Basic(x.to_string())), + cod: Some(Ob::Basic(y.to_string())), + }) + .is_ok() + ); + assert!( + model + .add_mor(&MorDecl { + name: second_link_name.into(), + id: g, + mor_type: MorType::Basic("Negative".into()), + dom: Some(Ob::Basic(x.to_string())), + cod: Some(Ob::Basic(y.to_string())), + }) + .is_ok() + ); + + model + } + + /// Construct a stock-flow diagram with a backwards link. pub(crate) fn backward_link(src_name: &str, tgt_name: &str, flow_name: &str) -> DblModel { let th = ThCategoryLinks::new().theory(); let mut model = DblModel::new(&th); let [f, x, y, link] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + assert!( model .add_ob(&ObDecl { @@ -911,6 +968,61 @@ pub(crate) mod tests { model } + /// Construct a Petri net representing a catalytic transition [x,c] -> [y,c]. + pub(crate) fn catalytic_petri_net( + src_name: &str, + tgt_name: &str, + catalyst_name: &str, + _transition_name: &str, + ) -> DblModel { + let th = ThSymMonoidalCategory::new().theory(); + let mut model = DblModel::new(&th); + let [x, y, c, _t] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + + assert!( + model + .add_ob(&ObDecl { + name: src_name.into(), + id: x, + // ob_type: ObType::Basic("Object".into()), + // TODO: what is the correct ob_type here? + ob_type: ObType::ModeApp { + modality: Modality::SymmetricList, + ob_type: Box::new(ObType::Basic("Object".into())) + }, + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: tgt_name.into(), + id: y, + // ob_type: ObType::Basic("Object".into()), + ob_type: ObType::ModeApp { + modality: Modality::SymmetricList, + ob_type: Box::new(ObType::Basic("Object".into())) + }, + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: catalyst_name.into(), + id: c, + // ob_type: ObType::Basic("Object".into()), + ob_type: ObType::ModeApp { + modality: Modality::SymmetricList, + ob_type: Box::new(ObType::Basic("Object".into())) + }, + }) + .is_ok() + ); + // TODO: add the transition [x, c] -> [y, c] + + model + } #[test] fn model_category_links() { let model = backward_link("x", "y", "f"); diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 8388f674d..b17257629 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -10,7 +10,6 @@ use std::ops::{Add, AddAssign, Mul, Neg}; use derivative::Derivative; use crate::latex::{Latex, ToLatex, ToLatexWithMap}; -use crate::stdlib::analyses::ode::Parameter; use crate::zero::QualifiedName; use super::rig::*; From cff0843b55258bd0407a6c3346a0ec11ac1cd846 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 18:57:35 +0100 Subject: [PATCH 22/38] WIP: Simplify some of the repetition while we're here --- packages/catlog-wasm/src/analyses.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 321a6fc29..9d0d63c9f 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; -use catlog::stdlib::analyses::ode::{self, ODESemanticsAnalysis, ODESemanticsProblemData}; +use catlog::stdlib::analyses::ode::{ + self, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, +}; use catlog::zero::QualifiedName; use crate::latex::latex_names; @@ -42,6 +44,11 @@ fn polynomial_ode_system( // of ODESemanics? use e.g. `::ODEParameter` // // ... OR just define `fn polynomial_system` ?????????? +// fn polynomial_system( +// model: &DblModel, +// ) -> Result, i8>, String> { +// // TODO: match on some enum `Discrete | Tabulated | ModalUnital | ModalNonUnital` +// } /// Generates the PolynomialSystem for Lotka-Volterra dynamics. fn lotka_volterra_system( From 25aee3dbb3a7a10a200c0714e79a23974cc07a3f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 22 Jun 2026 17:42:37 +0100 Subject: [PATCH 23/38] WIP: Deleting lots of (now) redundant code --- packages/catlog-wasm/src/analyses.rs | 194 +++++++----------- packages/catlog-wasm/src/latex.rs | 8 + packages/catlog-wasm/src/theories.rs | 64 ++++-- .../src/stdlib/analyses/ode/linear_ode.rs | 1 + .../src/stdlib/analyses/ode/lotka_volterra.rs | 1 + .../src/stdlib/analyses/ode/mass_action.rs | 29 ++- .../src/stdlib/analyses/ode/ode_semantics.rs | 14 +- 7 files changed, 164 insertions(+), 147 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 9d0d63c9f..34b9b15c6 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -6,7 +6,7 @@ use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; use catlog::stdlib::analyses::ode::{ - self, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, + self, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, Parameter, }; use catlog::zero::QualifiedName; @@ -32,7 +32,7 @@ pub struct ODEResultWithEquations { } /// Generates the PolynomialSystem for the systems of polynomial ODEs. -fn polynomial_ode_system( +pub(crate) fn polynomial_ode_system( model: &DblModel, ) -> Result, i8>, String> { let realised_model = model.modal_nonunital()?; @@ -40,18 +40,49 @@ fn polynomial_ode_system( Ok(analysis.build_system(realised_model)) } -// TODO: can all of the following be generalised by iterating (with a macro) over all the implementations -// of ODESemanics? use e.g. `::ODEParameter` +// // TODO: This enum should already be defined somewhere else... +// enum Doctrine { +// Discrete, +// Tabulated, +// ModalUnital, +// ModalNonUnital, +// } + +// // TODO: can all of the following be generalised by iterating (with a macro) over all the implementations +// // of ODESemantics? use e.g. `::ODEParameter` +// // +// // ... OR just define `fn polynomial_system` ?????????? // -// ... OR just define `fn polynomial_system` ?????????? -// fn polynomial_system( +// fn ode_semantics_system( // model: &DblModel, +// doctrine: Doctrine, // ) -> Result, i8>, String> { -// // TODO: match on some enum `Discrete | Tabulated | ModalUnital | ModalNonUnital` +// match doctrine { +// Doctrine::Discrete => { +// let realised_model = model.discrete()?; +// let analysis = ::default(); +// Ok(analysis.build_system(realised_model)) +// } +// Doctrine::Tabulated => { +// let realised_model = model.discrete_tab()?; +// let analysis = S::AnalysisType::default(); +// Ok(analysis.build_system(realised_model)) +// } +// Doctrine::ModalUnital => { +// let realised_model = model.modal_unital()?; +// let analysis = S::AnalysisType::default(); +// Ok(analysis.build_system(realised_model)) +// } +// Doctrine::ModalNonUnital => { +// let realised_model = model.modal_nonunital()?; +// let analysis = S::AnalysisType::default(); +// Ok(analysis.build_system(realised_model)) +// } +// } // } /// Generates the PolynomialSystem for Lotka-Volterra dynamics. -fn lotka_volterra_system( +pub(crate) fn lotka_volterra_system( model: &DblModel, ) -> Result, i8>, String> { @@ -61,7 +92,7 @@ fn lotka_volterra_system( } /// Generates the PolynomialSystem for LCC dynamics. -fn linear_ode_system( +pub(crate) fn linear_ode_system( model: &DblModel, ) -> Result, i8>, String> { let realised_model = model.discrete()?; @@ -69,10 +100,11 @@ fn linear_ode_system( Ok(analysis.build_system(realised_model)) } -// TODO: you should be able to REMOVE this enum (or EXTEND it to also contain e.g. Lotka-Volterra) +// TODO: you should be able to REMOVE this enum /// Mass-action analysis is currently implemented for Petri nets and stock-flow diagrams /// and we can avoid some code reduplication by making this explicit. -pub enum MassActionAnalysisLogic { +#[derive(Copy, Clone)] +pub(crate) enum MassActionAnalysisLogic { /// The modal theory of Petri nets. PetriNet, /// The discrete tabulator theory of stock-flow diagrams. @@ -80,7 +112,7 @@ pub enum MassActionAnalysisLogic { } /// Generates the PolynomialSystem for mass-action dynamics. -fn mass_action_system( +pub(crate) fn mass_action_system( model: &DblModel, mass_conservation_type: ode::MassConservationType, logic: MassActionAnalysisLogic, @@ -105,80 +137,19 @@ fn mass_action_system( } } -/// The analysis data for polynomial ODE equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct PolynomialODEEquationsData { - #[serde(rename = "trivialData")] - trivial_data: bool, -} -/// Generates equations for the system of polynomial ODEs. -pub(crate) fn polynomial_ode_equations( - model: &DblModel, - _data: PolynomialODEEquationsData, -) -> Result { - let sys = polynomial_ode_system(model); - let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); - Ok(equations) -} - -/// The analysis data for Lotka-Volterra equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct LotkaVolterraEquationsData { - #[serde(rename = "trivialData")] - trivial_data: bool, -} -/// Generates Lotka-Volterra equations for the system. -pub(crate) fn lotka_volterra_equations(model: &DblModel) -> Result { - let sys = lotka_volterra_system(model); - let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); - Ok(equations) -} - -/// The analysis data for LCC equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct LCCEquationsData { - #[serde(rename = "trivialData")] - trivial_data: bool, -} -/// Generates LCC equations for the system. -pub(crate) fn linear_ode_equations(model: &DblModel) -> Result { - let sys = linear_ode_system(model); - let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); - Ok(equations) -} - -/// The analysis data for mass-action equations. -#[derive(Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct MassActionEquationsData { - /// The mass-conservation type. - #[serde(rename = "massConservationType")] - pub mass_conservation_type: ode::MassConservationType, -} -/// Generates mass-action equations for the system. -pub(crate) fn mass_action_equations( - model: &DblModel, - data: MassActionEquationsData, - logic: MassActionAnalysisLogic, -) -> Result { - let sys = mass_action_system(model, data.mass_conservation_type, logic); - let equations = sys?.to_latex_equations_with_map(|param| latex_names(model)(param)); - Ok(equations) -} - -/// Simulates polynomial ODE equations. -pub(crate) fn polynomial_ode_simulation( +/// TODO: documentation. +// TODO: rewrite this to use ode_semantics_system, so that there's no need to preface with e.g. +// let system = lotka_volterra_system(model); +// in theories.rs +pub(crate) fn ode_semantics_simulation( model: &DblModel, - data: ode::PolynomialODEProblemData, + problem_data: S::ProblemDataType, + system: PolynomialSystem, i8>, ) -> Result { - let sys = polynomial_ode_system(model); - let sys_extended_scalars = ode::extend_polynomial_ode_scalars(sys?, &data); + let sys_extended_scalars = problem_data.extend_scalars(system); let latex_equations = sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, data); + let analysis = problem_data.build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), @@ -186,35 +157,28 @@ pub(crate) fn polynomial_ode_simulation( }) } -// TODO: define some closure that takes `sys_extended_scalars` to the result - -/// Simulates Lotka-Volterra ODEs. -pub(crate) fn lotka_volterra_simulation( +/// TODO: documentation. +// TODO: rewrite this to use ode_semantics_system, so that there's no need to preface with e.g. +// let system = lotka_volterra_system(model); +// in theories.rs +pub(crate) fn ode_semantics_equations( model: &DblModel, - data: ode::LotkaVolterraProblemData, -) -> Result { - let sys = lotka_volterra_system(model); - let sys_extended_scalars = data.extend_scalars(sys?); - let latex_equations = - sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = data.build_analysis(sys_extended_scalars); - let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); - Ok(ODEResultWithEquations { - solution: ODEResult(solution.into()), - latex_equations, - }) + system: PolynomialSystem, i8>, +) -> Result { + Ok(system.to_latex_equations_with_map(|param| latex_names(model)(param))) } -/// Simulates LCC equations. -pub(crate) fn linear_ode_simulation( +// TODO: replace this with ode_semantics_simulation by implementing ODESemantics for polynomial_ode ??? +/// Simulates polynomial ODE equations. +pub(crate) fn polynomial_ode_simulation( model: &DblModel, - data: ode::LCCProblemData, + problem_data: ode::PolynomialODEProblemData, ) -> Result { - let sys = linear_ode_system(model); - let sys_extended_scalars = data.extend_scalars(sys?); + let system = polynomial_ode_system(model); + let sys_extended_scalars = ode::extend_polynomial_ode_scalars(system?, &problem_data); let latex_equations = sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = data.build_analysis(sys_extended_scalars); + let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, problem_data); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), @@ -222,20 +186,10 @@ pub(crate) fn linear_ode_simulation( }) } -/// Simulates mass-action ODEs. -pub(crate) fn mass_action_simulation( - model: &DblModel, - data: ode::MassActionProblemData, - logic: MassActionAnalysisLogic, -) -> Result { - let sys = mass_action_system(model, data.mass_conservation_type, logic); - let sys_extended_scalars = data.extend_scalars(sys?); - let latex_equations = - sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = data.build_analysis(sys_extended_scalars); - let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); - Ok(ODEResultWithEquations { - solution: ODEResult(solution.into()), - latex_equations, - }) +// TODO: replace this with ode_semantics_equations by implementing ODESemantics for polynomial_ode ??? +/// Generates equations for the system of polynomial ODEs. +pub(crate) fn polynomial_ode_equations(model: &DblModel) -> Result { + let system = polynomial_ode_system(model); + let equations = system?.to_latex_equations_with_map(|param| latex_names(model)(param)); + Ok(equations) } diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 3100bb3bb..6f42788c8 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -48,6 +48,8 @@ mod tests { use crate::model::tests::{catalytic_petri_net, parallel_negative_cld}; use crate::model::{DblModel, tests::backward_link}; + // TODO: rewrite these tests to use the code in analyses.rs ??????? + #[test] fn stock_flow_balanced_mass_action_latex_equations() { let model = backward_link("xxx", "yyy", "fff"); @@ -152,7 +154,9 @@ mod tests { assert_eq!(equations, expected); } + // TODO: REMOVE THIS #[ignore] #[test] + #[ignore] fn petri_net_balanced_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. let model = catalytic_petri_net("liquid", "solid", "c", ""); @@ -184,7 +188,9 @@ mod tests { assert_eq!(equations, expected); } + // TODO: REMOVE THIS #[ignore] #[test] + #[ignore] fn petri_net_unbalanced_pt_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. let model = catalytic_petri_net("liquid", "solid", "c", ""); @@ -215,7 +221,9 @@ mod tests { assert_eq!(equations, expected); } + // TODO: REMOVE THIS #[ignore] #[test] + #[ignore] fn petri_net_unbalanced_pp_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. let model = catalytic_petri_net("liquid", "solid", "c", ""); diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 8f9712c7e..98202a973 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -152,13 +152,15 @@ impl ThSignedCategory { model: &DblModel, data: analyses::ode::LotkaVolterraProblemData, ) -> Result { - lotka_volterra_simulation(model, data) + let system = lotka_volterra_system(model); + ode_semantics_simulation::(model, data, system?) } /// Show the equations of the Lotka-Volterra system derived from a model. #[wasm_bindgen(js_name = "lotkaVolterraEquations")] pub fn lotka_volterra_equations(&self, model: &DblModel) -> Result { - lotka_volterra_equations(model) + let system = lotka_volterra_system(model); + ode_semantics_equations::(model, system?) } /// Simulate the linear ODE system derived from a model. @@ -168,13 +170,15 @@ impl ThSignedCategory { model: &DblModel, data: analyses::ode::LCCProblemData, ) -> Result { - linear_ode_simulation(model, data) + let system = linear_ode_system(model); + ode_semantics_simulation::(model, data, system?) } /// Show the equations of the linear ODE system derived from a model. #[wasm_bindgen(js_name = "linearODEEquations")] pub fn linear_ode_equations(&self, model: &DblModel) -> Result { - linear_ode_equations(model) + let system = linear_ode_system(model); + ode_semantics_equations::(model, system?) } } @@ -338,7 +342,14 @@ impl ThCategoryLinks { model: &DblModel, data: analyses::ode::MassActionProblemData, ) -> Result { - mass_action_simulation(model, data, MassActionAnalysisLogic::StockFlow) + let system = mass_action_system( + model, + data.equations_data.mass_conservation_type, + MassActionAnalysisLogic::StockFlow, + ); + ode_semantics_simulation::( + model, data, system?, + ) } /// Returns the symbolic mass-action equations in LaTeX format. @@ -346,9 +357,14 @@ impl ThCategoryLinks { pub fn mass_action_equations( &self, model: &DblModel, - data: MassActionEquationsData, + data: analyses::ode::MassActionEquationsData, ) -> Result { - mass_action_equations(model, data, MassActionAnalysisLogic::StockFlow) + let system = mass_action_system( + model, + data.mass_conservation_type, + MassActionAnalysisLogic::StockFlow, + ); + ode_semantics_equations::(model, system?) } } @@ -392,7 +408,14 @@ impl ThSymMonoidalCategory { model: &DblModel, data: analyses::ode::MassActionProblemData, ) -> Result { - mass_action_simulation(model, data, MassActionAnalysisLogic::PetriNet) + let system = mass_action_system( + model, + data.equations_data.mass_conservation_type, + MassActionAnalysisLogic::PetriNet, + ); + ode_semantics_simulation::( + model, data, system?, + ) } /// Returns the symbolic mass-action equations in LaTeX format. @@ -400,9 +423,14 @@ impl ThSymMonoidalCategory { pub fn mass_action_equations( &self, model: &DblModel, - data: MassActionEquationsData, + data: analyses::ode::MassActionEquationsData, ) -> Result { - mass_action_equations(model, data, MassActionAnalysisLogic::PetriNet) + let system = mass_action_system( + model, + data.mass_conservation_type, + MassActionAnalysisLogic::PetriNet, + ); + ode_semantics_equations::(model, system?) } /// Simulates the stochastic mass-action system derived from a model. @@ -459,12 +487,8 @@ impl ThPolynomialODE { /// Returns the symbolic equations in LaTeX format. #[wasm_bindgen(js_name = "polynomialODEEquations")] - pub fn polynomial_ode_equations( - &self, - model: &DblModel, - data: PolynomialODEEquationsData, - ) -> Result { - polynomial_ode_equations(model, data) + pub fn polynomial_ode_equations(&self, model: &DblModel) -> Result { + polynomial_ode_equations(model) } } @@ -496,12 +520,8 @@ impl ThSignedPolynomialODE { /// Returns the symbolic equations in LaTeX format. #[wasm_bindgen(js_name = "polynomialODEEquations")] - pub fn polynomial_ode_equations( - &self, - model: &DblModel, - data: PolynomialODEEquationsData, - ) -> Result { - polynomial_ode_equations(model, data) + pub fn polynomial_ode_equations(&self, model: &DblModel) -> Result { + polynomial_ode_equations(model) } } diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index f3767c6fc..54e3fd1b9 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -32,6 +32,7 @@ impl ODESemantics for LCCSemantics { type ModelType = DiscreteDblModel; type ParameterType = LCCParameter; type AnalysisType = LCCAnalysis; + type EquationsDataType = (); type ProblemDataType = LCCProblemData; } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 52c67957f..06e97cf69 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -32,6 +32,7 @@ impl ODESemantics for LotkaVolterraSemantics { type ModelType = DiscreteDblModel; type ParameterType = LotkaVolterraParameter; type AnalysisType = LotkaVolterraAnalysis; + type EquationsDataType = (); type ProblemDataType = LotkaVolterraProblemData; } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 092129cdc..c3d65ec30 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -36,6 +36,7 @@ impl ODESemantics for PetriNetMassActionSemantics { type ModelType = ModalDblModel; type ParameterType = MassActionParameter; type AnalysisType = PetriNetMassActionAnalysis; + type EquationsDataType = MassActionEquationsData; type ProblemDataType = MassActionProblemData; } @@ -43,6 +44,7 @@ impl ODESemantics for StockFlowMassActionSemantics { type ModelType = DiscreteTabModel; type ParameterType = MassActionParameter; type AnalysisType = StockFlowMassActionAnalysis; + type EquationsDataType = MassActionEquationsData; type ProblemDataType = MassActionProblemData; } @@ -429,17 +431,38 @@ impl } } -/// Data defining an unbalanced mass-action ODE problem for a model. + +/// Data defining mass-action ODE equations for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( feature = "serde-wasm", - tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) + tsify(into_wasm_abi, from_wasm_abi) )] -pub struct MassActionProblemData { +pub struct MassActionEquationsData { /// Whether or not mass is conserved. #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] pub mass_conservation_type: MassConservationType, +} + +impl Default for MassActionEquationsData { + fn default() -> Self { + Self { mass_conservation_type: MassConservationType::Balanced } + } +} + +impl ODESemanticsEquationsData for MassActionEquationsData {} + +/// Data defining a mass-action ODE problem for a model. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr( + feature = "serde-wasm", + tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) +)] +pub struct MassActionProblemData { + /// Data used for generating the equations (namely, whether or not mass is conserved). + pub equations_data: MassActionEquationsData, /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), /// for the balanced per transition case. diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index cf99ed90f..e373862b0 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -56,6 +56,10 @@ pub trait ODESemantics { /// The data describing the things that the ODE semantics "cares about". (See the documentation /// for `ODESemanticsAnalysis`). type AnalysisType: ODESemanticsAnalysis; + + /// TODO: documentation + type EquationsDataType: ODESemanticsEquationsData; + /// The data describing how to turn the algebraic system of equations into a simulation, /// including e.g. which values that appear in the front-end analysis correspond to which /// parameters within the equations. @@ -215,6 +219,11 @@ pub enum ContributionSign { Negative, } +/// TODO: documentation +// TODO: similar question about including all the serde stuff here +pub trait ODESemanticsEquationsData {} +impl ODESemanticsEquationsData for () {} + /// The trait describing how to turn the formal system of ODEs into a numerical problem, to be /// solved by an ODE solver and presented to the front-end. At minimum, such data must contain /// initial values for variables and the intended duration of simulation, as well as the method for @@ -231,10 +240,11 @@ pub enum ContributionSign { // tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) // )] pub trait ODESemanticsProblemData { - // REQUEST | The two getters (`initial_values()` and `duration()`) are annoying boilerplate to - // FOR | ask to be implemented. Is there a nice way to get rid of them here? Without them, + // REQUEST | These getters (`equations_data`, `initial_values`, and `duration`) are annoying + // FOR | boilerplate to ask for. Is there a nice way to get rid of them here? Without them, // FEEDBACK | the call to `self.initial_values` in `build_analysis()` fails because there is no // _________/ way of knowing whether a struct implementing this trait actually has those fields. + /// Further data needed to specify the ODE equations. /// Map from object IDs to initial values (nonnegative reals). fn initial_values(&self) -> HashMap; /// Duration of simulation. From b90a4e3f63d19ea817e2a9c4be90596b7a239713 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 22 Jun 2026 19:58:57 +0100 Subject: [PATCH 24/38] FIX: Move front-end ODE equation tests to analyses.rs --- packages/catlog-wasm/src/analyses.rs | 425 +++++++++++++++++- packages/catlog-wasm/src/latex.rs | 327 -------------- packages/catlog-wasm/src/model.rs | 187 ++++---- .../src/stdlib/analyses/ode/lotka_volterra.rs | 1 - .../src/stdlib/analyses/ode/mass_action.rs | 15 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 34 +- .../src/stdlib/analyses/ode/polynomial_ode.rs | 111 +++-- 7 files changed, 589 insertions(+), 511 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 34b9b15c6..be3f979b6 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -10,8 +10,7 @@ use catlog::stdlib::analyses::ode::{ }; use catlog::zero::QualifiedName; -use crate::latex::latex_names; - +use super::latex::latex_names; use super::model::DblModel; use super::result::JsResult; @@ -48,15 +47,6 @@ pub(crate) fn polynomial_ode_system( // ModalNonUnital, // } -// // TODO: can all of the following be generalised by iterating (with a macro) over all the implementations -// // of ODESemantics? use e.g. `::ODEParameter` -// // -// // ... OR just define `fn polynomial_system` ?????????? -// -// fn ode_semantics_system( -// model: &DblModel, -// doctrine: Doctrine, -// ) -> Result, i8>, String> { // match doctrine { // Doctrine::Discrete => { // let realised_model = model.discrete()?; @@ -79,6 +69,23 @@ pub(crate) fn polynomial_ode_system( // Ok(analysis.build_system(realised_model)) // } // } + +// // TODO: define `fn polynomial_system` ?????????? +// fn ode_semantics_system( +// model: &DblModel, +// // doctrine: Doctrine, +// ) -> Result, i8>, String> { +// let realised_model = model.discrete()?; +// let analysis = ::default(); +// Ok(analysis.build_system(std::rc::Rc::::unwrap_or_clone(realised_model))) +// } + +// fn ode_semantics_system( +// model: &Rc, +// ) -> Result, i8>, String> { +// let analysis = S::AnalysisType::default(); +// // TODO: can we just use .try_into() directly? as in e.g. the definition for modal_nonunital() +// Ok(analysis.build_system(model)) // } /// Generates the PolynomialSystem for Lotka-Volterra dynamics. @@ -175,10 +182,10 @@ pub(crate) fn polynomial_ode_simulation( problem_data: ode::PolynomialODEProblemData, ) -> Result { let system = polynomial_ode_system(model); - let sys_extended_scalars = ode::extend_polynomial_ode_scalars(system?, &problem_data); + let sys_extended_scalars = problem_data.extend_scalars(system?); let latex_equations = sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, problem_data); + let analysis = problem_data.build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), @@ -193,3 +200,395 @@ pub(crate) fn polynomial_ode_equations(model: &DblModel) -> Result(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex( + "g_{x} \\cdot x" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yellow}".to_string()), + rhs: Latex( + "(-k_{f} - k_{x \\to \\text{yellow}}) \\cdot x \\cdot \\text{yellow} + g_{\\text{yellow}} \\cdot \\text{yellow}" + .to_string(), + ), + }, + ]); + + assert_eq!(equations, expected); + } + + #[test] + fn cld_lcc_latex_equations() { + let model = parallel_negative_cld("x", "yellow", "f", ""); + let system = linear_ode_system(&model).unwrap(); + let equations = ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex("0".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yellow}".to_string()), + rhs: Latex( + "(-\\lambda_{f} - \\lambda_{x \\to \\text{yellow}}) \\cdot x".to_string(), + ), + }, + ]); + + assert_eq!(equations, expected); + } + + #[test] + fn stock_flow_balanced_mass_action_latex_equations() { + let model = backward_link("xxx", "yyy", "fff"); + let system = mass_action_system( + &model, + MassConservationType::Balanced, + MassActionAnalysisLogic::StockFlow, + ).unwrap(); + let equations = ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), + rhs: Latex("-r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), + rhs: Latex("r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + #[test] + fn stock_flow_unbalanced_mass_action_latex_equations() { + let model = backward_link("xxx", "yyy", "fff"); + let system = mass_action_system( + &model, + MassConservationType::Unbalanced(ode::RateGranularity::PerTransition), + MassActionAnalysisLogic::StockFlow, + ).unwrap(); + let equations = ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), + rhs: Latex( + "-\\kappa_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), + rhs: Latex("\\rho_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + // TODO: REMOVE THIS #[ignore] + #[test] + #[ignore] + fn petri_net_balanced_mass_action_latex_equations() { + // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + let system = mass_action_system( + &model, + MassConservationType::Balanced, + MassActionAnalysisLogic::PetriNet, + ).unwrap(); + let equations = ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), + rhs: Latex( + "-r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), + rhs: Latex( + "r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("0".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + // TODO: REMOVE THIS #[ignore] + #[test] + #[ignore] + fn petri_net_unbalanced_pt_mass_action_latex_equations() { + // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + let system = mass_action_system( + &model, + MassConservationType::Unbalanced(ode::RateGranularity::PerTransition), + MassActionAnalysisLogic::PetriNet, + ).unwrap(); + let equations = ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), + rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), + rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + // TODO: REMOVE THIS #[ignore] + #[test] + #[ignore] + fn petri_net_unbalanced_pp_mass_action_latex_equations() { + // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + let system = mass_action_system( + &model, + MassConservationType::Unbalanced(ode::RateGranularity::PerPlace), + MassActionAnalysisLogic::PetriNet, + ).unwrap(); + let equations = ode_semantics_equations::(&model, system).unwrap(); + + // TODO: write down the expected equations + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), + rhs: Latex("".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), + rhs: Latex("".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + #[test] + fn modal_mor_dom_cod_labels() { + let th = Rc::new(theories::th_sym_monoidal_category()); + let ob_type = ModalObType::new(QualifiedName::from("Object")); + let op = QualifiedName::from("tensor"); + + let [s_id, i_id, r_id] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + let [infect_id, recover_id] = [Uuid::now_v7(), Uuid::now_v7()]; + + let mut inner = ModalDblModel::new(th); + inner.add_ob(s_id.into(), ob_type.clone()); + inner.add_ob(i_id.into(), ob_type.clone()); + inner.add_ob(r_id.into(), ob_type.clone()); + + // infect: tensor(S, I) -> tensor(I, I) — product-typed dom and cod. + inner.add_mor( + infect_id.into(), + ModalOb::App( + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(s_id.into()), ModalOb::Generator(i_id.into())], + ) + .into(), + op.clone(), + ), + ModalOb::App( + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())], + ) + .into(), + op.clone(), + ), + ModalMorType::Zero(ob_type.clone()), + ); + + // recover: I -> R — simple generator dom and cod. + inner.add_mor( + recover_id.into(), + ModalOb::Generator(i_id.into()), + ModalOb::Generator(r_id.into()), + ModalMorType::Zero(ob_type), + ); + + let mut ob_namespace = Namespace::new_for_uuid(); + ob_namespace.set_label(s_id, LabelSegment::Text("S".into())); + ob_namespace.set_label(i_id, LabelSegment::Text("I".into())); + ob_namespace.set_label(r_id, LabelSegment::Text("R".into())); + + let model = DblModel { + model: inner.into(), + ty: None, + ob_namespace, + mor_namespace: Namespace::new_for_uuid(), + }; + + // Morphism with basic generator dom/cod resolves labels. + assert_eq!( + model.mor_generator_dom_cod_label_strings(&recover_id.into()), + Some(("I".to_string(), "R".to_string())) + ); + + // Morphism with product-typed dom/cod resolves to bracketed labels. + assert_eq!( + model.mor_generator_dom_cod_label_strings(&infect_id.into()), + Some(("[S, I]".to_string(), "[I, I]".to_string())) + ); + } + + /// Construct a causal loop diagram with objects x, y and negative links f, g : x -> y. + fn parallel_negative_cld( + src_name: &str, + tgt_name: &str, + first_link_name: &str, + second_link_name: &str, + ) -> DblModel { + let th = ThSignedCategory::new().theory(); + let mut model = DblModel::new(&th); + let [x, y, f, g] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + + assert!( + model + .add_ob(&ObDecl { + name: src_name.into(), + id: x, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: tgt_name.into(), + id: y, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_mor(&MorDecl { + name: first_link_name.into(), + id: f, + mor_type: MorType::Basic("Negative".into()), + dom: Some(Ob::Basic(x.to_string())), + cod: Some(Ob::Basic(y.to_string())), + }) + .is_ok() + ); + assert!( + model + .add_mor(&MorDecl { + name: second_link_name.into(), + id: g, + mor_type: MorType::Basic("Negative".into()), + dom: Some(Ob::Basic(x.to_string())), + cod: Some(Ob::Basic(y.to_string())), + }) + .is_ok() + ); + + model + } + + /// Construct a Petri net representing a catalytic transition [x,c] -> [y,c]. + fn catalytic_petri_net( + src_name: &str, + tgt_name: &str, + catalyst_name: &str, + _transition_name: &str, + ) -> DblModel { + let th = ThSymMonoidalCategory::new().theory(); + let mut model = DblModel::new(&th); + let [x, y, c, _t] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + + assert!( + model + .add_ob(&ObDecl { + name: src_name.into(), + id: x, + // ob_type: ObType::Basic("Object".into()), + // TODO: what is the correct ob_type here? + ob_type: ObType::ModeApp { + modality: Modality::SymmetricList, + ob_type: Box::new(ObType::Basic("Object".into())) + }, + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: tgt_name.into(), + id: y, + // ob_type: ObType::Basic("Object".into()), + ob_type: ObType::ModeApp { + modality: Modality::SymmetricList, + ob_type: Box::new(ObType::Basic("Object".into())) + }, + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: catalyst_name.into(), + id: c, + // ob_type: ObType::Basic("Object".into()), + ob_type: ObType::ModeApp { + modality: Modality::SymmetricList, + ob_type: Box::new(ObType::Basic("Object".into())) + }, + }) + .is_ok() + ); + // TODO: add the transition [x, c] -> [y, c] + + model + } +} diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 6f42788c8..ffe93e08f 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -29,330 +29,3 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String } } } - -#[cfg(test)] -mod tests { - use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; - use catlog::dbl::model::{ModalDblModel, MutDblModel}; - use catlog::latex::{Latex, LatexEquation, LatexEquations}; - use catlog::stdlib::analyses::ode::{ - LCCAnalysis, LotkaVolterraAnalysis, PetriNetMassActionAnalysis, - StockFlowMassActionAnalysis, ode_semantics::*, - }; - use catlog::stdlib::{analyses::ode, theories}; - use catlog::zero::{LabelSegment, Namespace, QualifiedName}; - use std::rc::Rc; - use uuid::Uuid; - - use super::*; - use crate::model::tests::{catalytic_petri_net, parallel_negative_cld}; - use crate::model::{DblModel, tests::backward_link}; - - // TODO: rewrite these tests to use the code in analyses.rs ??????? - - #[test] - fn stock_flow_balanced_mass_action_latex_equations() { - let model = backward_link("xxx", "yyy", "fff"); - let tab_model = model.discrete_tab().unwrap(); - let analysis = StockFlowMassActionAnalysis::default(); - let sys = analysis.build_system(tab_model); - let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), - rhs: Latex("-r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), - rhs: Latex("r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), - }, - ]); - assert_eq!(equations, expected); - } - - #[test] - fn stock_flow_unbalanced_mass_action_latex_equations() { - let model = backward_link("xxx", "yyy", "fff"); - let tab_model = model.discrete_tab().unwrap(); - let equations = StockFlowMassActionAnalysis { - mass_conservation_type: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - ..StockFlowMassActionAnalysis::default() - } - .build_system(tab_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), - rhs: Latex( - "-\\kappa_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), - ), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), - rhs: Latex("\\rho_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), - }, - ]); - assert_eq!(equations, expected); - } - - #[test] - fn cld_lotka_volterra_latex_equations() { - // The CLD with objects "x" and "yellow", and two negative links "f" and [unnamed] from x to y. - let model = parallel_negative_cld("x", "yellow", "f", ""); - - let discrete_model = model.discrete().unwrap(); - let equations = LotkaVolterraAnalysis::default() - .build_system(discrete_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex( - "g_{x} \\cdot x" - .to_string(), - ), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yellow}".to_string()), - rhs: Latex( - "(-k_{f} - k_{x \\to \\text{yellow}}) \\cdot x \\cdot \\text{yellow} + g_{\\text{yellow}} \\cdot \\text{yellow}" - .to_string(), - ), - }, - ]); - - assert_eq!(equations, expected); - } - - #[test] - fn cld_lcc_latex_equations() { - // The CLD with objects "x" and "yellow", and two negative links "f" and [unnamed] from x to y. - let model = parallel_negative_cld("x", "yellow", "f", ""); - let discrete_model = model.discrete().unwrap(); - let equations = LCCAnalysis::default() - .build_system(discrete_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex("0".to_string()), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yellow}".to_string()), - rhs: Latex( - "(-\\lambda_{f} - \\lambda_{x \\to \\text{yellow}}) \\cdot x".to_string(), - ), - }, - ]); - - assert_eq!(equations, expected); - } - - // TODO: REMOVE THIS #[ignore] - #[test] - #[ignore] - fn petri_net_balanced_mass_action_latex_equations() { - // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. - let model = catalytic_petri_net("liquid", "solid", "c", ""); - let modal_model = model.modal_unital().unwrap(); - let equations = PetriNetMassActionAnalysis::default() - .build_system(modal_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex( - "-r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" - .to_string(), - ), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex( - "r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" - .to_string(), - ), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("0".to_string()), - }, - ]); - assert_eq!(equations, expected); - } - - // TODO: REMOVE THIS #[ignore] - #[test] - #[ignore] - fn petri_net_unbalanced_pt_mass_action_latex_equations() { - // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. - let model = catalytic_petri_net("liquid", "solid", "c", ""); - let modal_model = model.modal_unital().unwrap(); - let equations = PetriNetMassActionAnalysis { - mass_conservation_type: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - ..PetriNetMassActionAnalysis::default() - } - .build_system(modal_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), - }, - ]); - assert_eq!(equations, expected); - } - - // TODO: REMOVE THIS #[ignore] - #[test] - #[ignore] - fn petri_net_unbalanced_pp_mass_action_latex_equations() { - // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. - let model = catalytic_petri_net("liquid", "solid", "c", ""); - let modal_model = model.modal_unital().unwrap(); - let equations = PetriNetMassActionAnalysis { - mass_conservation_type: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerPlace, - ), - ..PetriNetMassActionAnalysis::default() - } - .build_system(modal_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - // TODO: write down the expected equations - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex("".to_string()), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex("".to_string()), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("".to_string()), - }, - ]); - assert_eq!(equations, expected); - } - - #[test] - fn unnamed_mor_uses_dom_cod_in_equations() { - let model = backward_link("xxx", "yyy", ""); - let tab_model = model.discrete_tab().unwrap(); - let equations = StockFlowMassActionAnalysis { - mass_conservation_type: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - ..StockFlowMassActionAnalysis::default() - } - .build_system(tab_model) - .to_latex_equations_with_map(|param| latex_names(&model)(param)); - - let expected = LatexEquations(vec![ - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), - rhs: Latex( - "-\\kappa_{\\text{xxx} \\to \\text{yyy}} \\cdot \\text{xxx} \\cdot \\text{yyy}" - .to_string(), - ), - }, - LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), - rhs: Latex( - "\\rho_{\\text{xxx} \\to \\text{yyy}} \\cdot \\text{xxx} \\cdot \\text{yyy}" - .to_string(), - ), - }, - ]); - assert_eq!(equations, expected); - } - - #[test] - fn modal_mor_dom_cod_labels() { - let th = Rc::new(theories::th_sym_monoidal_category()); - let ob_type = ModalObType::new(QualifiedName::from("Object")); - let op = QualifiedName::from("tensor"); - - let [s_id, i_id, r_id] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; - let [infect_id, recover_id] = [Uuid::now_v7(), Uuid::now_v7()]; - - let mut inner = ModalDblModel::new(th); - inner.add_ob(s_id.into(), ob_type.clone()); - inner.add_ob(i_id.into(), ob_type.clone()); - inner.add_ob(r_id.into(), ob_type.clone()); - - // infect: tensor(S, I) -> tensor(I, I) — product-typed dom and cod. - inner.add_mor( - infect_id.into(), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(s_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), - ), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), - ), - ModalMorType::Zero(ob_type.clone()), - ); - - // recover: I -> R — simple generator dom and cod. - inner.add_mor( - recover_id.into(), - ModalOb::Generator(i_id.into()), - ModalOb::Generator(r_id.into()), - ModalMorType::Zero(ob_type), - ); - - let mut ob_namespace = Namespace::new_for_uuid(); - ob_namespace.set_label(s_id, LabelSegment::Text("S".into())); - ob_namespace.set_label(i_id, LabelSegment::Text("I".into())); - ob_namespace.set_label(r_id, LabelSegment::Text("R".into())); - - let model = DblModel { - model: inner.into(), - ty: None, - ob_namespace, - mor_namespace: Namespace::new_for_uuid(), - }; - - // Morphism with basic generator dom/cod resolves labels. - assert_eq!( - model.mor_generator_dom_cod_label_strings(&recover_id.into()), - Some(("I".to_string(), "R".to_string())) - ); - - // Morphism with product-typed dom/cod resolves to bracketed labels. - assert_eq!( - model.mor_generator_dom_cod_label_strings(&infect_id.into()), - Some(("[S, I]".to_string(), "[I, I]".to_string())) - ); - } -} diff --git a/packages/catlog-wasm/src/model.rs b/packages/catlog-wasm/src/model.rs index c43c211f3..dc89ec148 100644 --- a/packages/catlog-wasm/src/model.rs +++ b/packages/catlog-wasm/src/model.rs @@ -777,6 +777,14 @@ pub fn elaborate_model( #[cfg(test)] pub(crate) mod tests { + use catlog::{ + dbl::{ + modal::{List, ModalMorType, ModalObType}, + model::ModalDblModel, + }, + stdlib::theories, + zero::LabelSegment, + }; use uuid::Uuid; use super::*; @@ -864,61 +872,6 @@ pub(crate) mod tests { assert_eq!(Result::from(model.validate().0).map_err(|errs| errs.len()), Err(2)); } - //. Construct a causal loop diagram with objects x, y and negative links f, g : x -> y. - pub(crate) fn parallel_negative_cld( - src_name: &str, - tgt_name: &str, - first_link_name: &str, - second_link_name: &str, - ) -> DblModel { - let th = ThSignedCategory::new().theory(); - let mut model = DblModel::new(&th); - let [x, y, f, g] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; - - assert!( - model - .add_ob(&ObDecl { - name: src_name.into(), - id: x, - ob_type: ObType::Basic("Object".into()) - }) - .is_ok() - ); - assert!( - model - .add_ob(&ObDecl { - name: tgt_name.into(), - id: y, - ob_type: ObType::Basic("Object".into()) - }) - .is_ok() - ); - assert!( - model - .add_mor(&MorDecl { - name: first_link_name.into(), - id: f, - mor_type: MorType::Basic("Negative".into()), - dom: Some(Ob::Basic(x.to_string())), - cod: Some(Ob::Basic(y.to_string())), - }) - .is_ok() - ); - assert!( - model - .add_mor(&MorDecl { - name: second_link_name.into(), - id: g, - mor_type: MorType::Basic("Negative".into()), - dom: Some(Ob::Basic(x.to_string())), - cod: Some(Ob::Basic(y.to_string())), - }) - .is_ok() - ); - - model - } - /// Construct a stock-flow diagram with a backwards link. pub(crate) fn backward_link(src_name: &str, tgt_name: &str, flow_name: &str) -> DblModel { let th = ThCategoryLinks::new().theory(); @@ -968,61 +921,6 @@ pub(crate) mod tests { model } - /// Construct a Petri net representing a catalytic transition [x,c] -> [y,c]. - pub(crate) fn catalytic_petri_net( - src_name: &str, - tgt_name: &str, - catalyst_name: &str, - _transition_name: &str, - ) -> DblModel { - let th = ThSymMonoidalCategory::new().theory(); - let mut model = DblModel::new(&th); - let [x, y, c, _t] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; - - assert!( - model - .add_ob(&ObDecl { - name: src_name.into(), - id: x, - // ob_type: ObType::Basic("Object".into()), - // TODO: what is the correct ob_type here? - ob_type: ObType::ModeApp { - modality: Modality::SymmetricList, - ob_type: Box::new(ObType::Basic("Object".into())) - }, - }) - .is_ok() - ); - assert!( - model - .add_ob(&ObDecl { - name: tgt_name.into(), - id: y, - // ob_type: ObType::Basic("Object".into()), - ob_type: ObType::ModeApp { - modality: Modality::SymmetricList, - ob_type: Box::new(ObType::Basic("Object".into())) - }, - }) - .is_ok() - ); - assert!( - model - .add_ob(&ObDecl { - name: catalyst_name.into(), - id: c, - // ob_type: ObType::Basic("Object".into()), - ob_type: ObType::ModeApp { - modality: Modality::SymmetricList, - ob_type: Box::new(ObType::Basic("Object".into())) - }, - }) - .is_ok() - ); - // TODO: add the transition [x, c] -> [y, c] - - model - } #[test] fn model_category_links() { let model = backward_link("x", "y", "f"); @@ -1030,4 +928,73 @@ pub(crate) mod tests { assert_eq!(model.mor_generators().len(), 2); assert_eq!(model.validate().0, JsResult::Ok(())); } + + #[test] + fn modal_mor_dom_cod_labels() { + let th = Rc::new(theories::th_sym_monoidal_category()); + let ob_type = ModalObType::new(QualifiedName::from("Object")); + let op = QualifiedName::from("tensor"); + + let [s_id, i_id, r_id] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + let [infect_id, recover_id] = [Uuid::now_v7(), Uuid::now_v7()]; + + let mut inner = ModalDblModel::new(th); + inner.add_ob(s_id.into(), ob_type.clone()); + inner.add_ob(i_id.into(), ob_type.clone()); + inner.add_ob(r_id.into(), ob_type.clone()); + + // infect: tensor(S, I) -> tensor(I, I) — product-typed dom and cod. + inner.add_mor( + infect_id.into(), + ModalOb::App( + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(s_id.into()), ModalOb::Generator(i_id.into())], + ) + .into(), + op.clone(), + ), + ModalOb::App( + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())], + ) + .into(), + op.clone(), + ), + ModalMorType::Zero(ob_type.clone()), + ); + + // recover: I -> R — simple generator dom and cod. + inner.add_mor( + recover_id.into(), + ModalOb::Generator(i_id.into()), + ModalOb::Generator(r_id.into()), + ModalMorType::Zero(ob_type), + ); + + let mut ob_namespace = Namespace::new_for_uuid(); + ob_namespace.set_label(s_id, LabelSegment::Text("S".into())); + ob_namespace.set_label(i_id, LabelSegment::Text("I".into())); + ob_namespace.set_label(r_id, LabelSegment::Text("R".into())); + + let model = DblModel { + model: inner.into(), + ty: None, + ob_namespace, + mor_namespace: Namespace::new_for_uuid(), + }; + + // Morphism with basic generator dom/cod resolves labels. + assert_eq!( + model.mor_generator_dom_cod_label_strings(&recover_id.into()), + Some(("I".to_string(), "R".to_string())) + ); + + // Morphism with product-typed dom/cod resolves to bracketed labels. + assert_eq!( + model.mor_generator_dom_cod_label_strings(&infect_id.into()), + Some(("[S, I]".to_string(), "[I, I]".to_string())) + ); + } } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 06e97cf69..2157ddb70 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -218,7 +218,6 @@ impl ODESemanticsProblemData<::Parameter let sys = sys.extend_scalars(|poly| { poly.eval(|param| match param { LotkaVolterraParameter::Growth { variable } => { - // FIXME: this won't work, because `variable` will now be `Growth.variable` self.growth_rates.get(variable).cloned().unwrap_or_default() } LotkaVolterraParameter::Interaction { link } => { diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index c3d65ec30..3089ff6ac 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -431,14 +431,11 @@ impl } } - /// Data defining mass-action ODE equations for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr( - feature = "serde-wasm", - tsify(into_wasm_abi, from_wasm_abi) -)] +#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +#[derive(Clone)] pub struct MassActionEquationsData { /// Whether or not mass is conserved. #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] @@ -447,7 +444,9 @@ pub struct MassActionEquationsData { impl Default for MassActionEquationsData { fn default() -> Self { - Self { mass_conservation_type: MassConservationType::Balanced } + Self { + mass_conservation_type: MassConservationType::Balanced, + } } } @@ -499,6 +498,10 @@ pub struct MassActionProblemData { } impl ODESemanticsProblemData for MassActionProblemData { + fn equations_data(&self) -> impl ODESemanticsEquationsData { + self.equations_data.clone() + } + fn initial_values(&self) -> HashMap { self.initial_values.clone() } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index e373862b0..0afca8940 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -4,6 +4,7 @@ //! consist of (in particular) a `PolynomialODESystemBuilder`, which contains all the data needed //! for [`ode::polynomial_ode::PolynomialODEAnalysis`] to do the following: //! +// TODO: is this true???????????? //! 1. Build the system as a model of the theory of polynomial ODE systems (i.e. multicategories) //! with abstract coefficients, using `build_system_custom_parameters()`. //! 2. Substitute in numerical coefficients, using `extend_polynomial_ode_scalars()`. @@ -53,16 +54,16 @@ pub trait ODESemantics { /// identified with one another, or to be rendered differently in debug/LaTeX output. For an /// instructive example, see `MassActionParameter` in `ode::mass_action`. type ParameterType: ODEParameterType; - /// The data describing the things that the ODE semantics "cares about". (See the documentation - /// for `ODESemanticsAnalysis`). + /// The data describing the things that the ODE semantics "cares about". See the documentation + /// for `ODESemanticsAnalysis` for more details. type AnalysisType: ODESemanticsAnalysis; - - /// TODO: documentation + /// The data necessary for displaying the system of equations, to be provided at run-time by the + /// front-end. type EquationsDataType: ODESemanticsEquationsData; - - /// The data describing how to turn the algebraic system of equations into a simulation, - /// including e.g. which values that appear in the front-end analysis correspond to which - /// parameters within the equations. + /// The data necessary for simulating the system of equations, to be provided at run-time by the + /// front-end. For example, which values appear in the front-end analysis widget, and to which + /// which parameters within the algebraic equations they correspond. Note that this is forced to + /// contain a value of type `EquationsDataType` by the definition of `ODESemanticsProblemData`. type ProblemDataType: ODESemanticsProblemData; } @@ -123,6 +124,15 @@ impl PolynomialODESystemBuilder

{ Self::default() } + /// Constructs an ODE system for an existing model of an ODE system. (Essentially trivial, but + /// useful to reduce boilerplate). + pub fn identity(model: ModalDblModel) -> Self { + Self { + model, + associated_parameters: HashMap::new(), + } + } + /// Returns a model of the theory of polynomial ODE systems. pub fn model(self) -> ModalDblModel { self.model @@ -219,7 +229,7 @@ pub enum ContributionSign { Negative, } -/// TODO: documentation +/// TODO: documentation. // TODO: similar question about including all the serde stuff here pub trait ODESemanticsEquationsData {} impl ODESemanticsEquationsData for () {} @@ -244,7 +254,13 @@ pub trait ODESemanticsProblemData { // FOR | boilerplate to ask for. Is there a nice way to get rid of them here? Without them, // FEEDBACK | the call to `self.initial_values` in `build_analysis()` fails because there is no // _________/ way of knowing whether a struct implementing this trait actually has those fields. + // In short: + // is there a better way to ensure that any struct implementing a trait has specific fields? /// Further data needed to specify the ODE equations. + /// TODO: documenation. + fn equations_data(&self) -> impl ODESemanticsEquationsData { + () + } /// Map from object IDs to initial values (nonnegative reals). fn initial_values(&self) -> HashMap; /// Duration of simulation. diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index 26b5e28de..2203aa9fb 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -13,8 +13,6 @@ use std::{collections::HashMap, fmt}; -use indexmap::IndexMap; -use nalgebra::DVector; use num_traits::Zero; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -27,29 +25,23 @@ use crate::{ model::{FpDblModel, ModalDblModel, ModalOb, MutDblModel}, theory::NonUnital, }, - simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, + simulate::ode::PolynomialSystem, + stdlib::analyses::ode::{ + ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, Parameter, + PolynomialODESystemBuilder, + }, zero::{QualifiedName, alg::Polynomial, name, rig::Monomial}, }; -use super::{ODEAnalysis, Parameter}; - -/// Data defining an unbalanced mass-action ODE problem for a model. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr( - feature = "serde-wasm", - tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) -)] -pub struct PolynomialODEProblemData { - /// Map from morphism IDs to coefficients (nonnegative reals). - coefficients: HashMap, - - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] - pub initial_values: HashMap, +/// Implementing Lotka-Volterra as an ODE semantics for models of type `DiscreteDblModel`. +pub struct PolynomialODESemantics; - /// Duration of simulation. - pub duration: f32, +impl ODESemantics for PolynomialODESemantics { + type ModelType = ModalDblModel; + type ParameterType = QualifiedName; + type AnalysisType = PolynomialODEAnalysis; + type EquationsDataType = (); + type ProblemDataType = PolynomialODEProblemData; } /// Polynomial ODE analysis. @@ -75,6 +67,21 @@ impl Default for PolynomialODEAnalysis { } } +// TODO: remove this implementation? it's so silly?????????? but we need it???????????????????????? +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for PolynomialODEAnalysis +{ + fn build_system_builder( + &self, + model: &::ModelType, + ) -> PolynomialODESystemBuilder<::ParameterType> { + PolynomialODESystemBuilder::identity(model.clone()) + } +} + impl PolynomialODEAnalysis { /// Creates a `PolynomialSystem` with symbolic coefficients of type `QualifiedName`. pub fn build_system( @@ -160,36 +167,50 @@ impl PolynomialODEAnalysis { } } -/// Substitutes numerical rate coefficients into a symbolic mass-action system. -pub fn extend_polynomial_ode_scalars( - sys: PolynomialSystem, i8>, - data: &PolynomialODEProblemData, -) -> PolynomialSystem { - let sys = sys.extend_scalars(|poly| { - poly.eval(|mor| data.coefficients.get(mor).cloned().unwrap_or_default()) - }); +/// Data defining an unbalanced mass-action ODE problem for a model. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr( + feature = "serde-wasm", + tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) +)] +pub struct PolynomialODEProblemData { + /// Map from morphism IDs to coefficients (nonnegative reals). + coefficients: HashMap, - sys.normalize() + /// Map from object IDs to initial values (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] + pub initial_values: HashMap, + + /// Duration of simulation. + pub duration: f32, } -/// Builds the numerical ODE analysis for a system of polynomial ODEs whose scalars have been substituted. -pub fn polynomial_ode_analysis( - sys: PolynomialSystem, - data: PolynomialODEProblemData, -) -> ODEAnalysis> { - let ob_index: IndexMap<_, _> = - sys.components.keys().cloned().enumerate().map(|(i, x)| (x, i)).collect(); - let n = ob_index.len(); +impl ODESemanticsProblemData<::ParameterType> + for PolynomialODEProblemData +{ + fn initial_values(&self) -> HashMap { + self.initial_values.clone() + } - let initial_values = ob_index - .keys() - .map(|ob| data.initial_values.get(ob).copied().unwrap_or_default()); - let x0 = DVector::from_iterator(n, initial_values); + fn duration(&self) -> f32 { + self.duration + } - let num_sys = sys.to_numerical(); - let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); + fn extend_scalars( + &self, + sys: PolynomialSystem< + QualifiedName, + Parameter<::ParameterType>, + i8, + >, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|mor| self.coefficients.get(mor).cloned().unwrap_or_default()) + }); - ODEAnalysis::new(problem, ob_index) + sys.normalize() + } } #[cfg(test)] From 309efc4e5b6bc931342679edad0f8b2f9303ccc1 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 23 Jun 2026 20:12:49 +0100 Subject: [PATCH 25/38] ENH: Documentation --- packages/catlog-wasm/src/analyses.rs | 246 ++++-------------- packages/catlog-wasm/src/theories.rs | 90 ++++--- .../src/stdlib/analyses/ode/linear_ode.rs | 62 ++--- .../src/stdlib/analyses/ode/ode_semantics.rs | 30 +-- .../src/stdlib/analyses/ode/polynomial_ode.rs | 6 +- packages/frontend/src/stdlib/analyses.tsx | 18 +- .../src/stdlib/analyses/linear_ode.tsx | 12 +- .../stdlib/analyses/linear_ode_equations.tsx | 12 +- .../src/stdlib/analyses/simulator_types.ts | 10 +- 9 files changed, 178 insertions(+), 308 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index be3f979b6..badfaa1ab 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; -use catlog::stdlib::analyses::ode::{ - self, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, Parameter, -}; +use catlog::stdlib::analyses::ode::{self, ODESemantics, ODESemanticsProblemData, Parameter}; use catlog::zero::QualifiedName; use super::latex::latex_names; @@ -30,124 +28,7 @@ pub struct ODEResultWithEquations { pub latex_equations: LatexEquations, } -/// Generates the PolynomialSystem for the systems of polynomial ODEs. -pub(crate) fn polynomial_ode_system( - model: &DblModel, -) -> Result, i8>, String> { - let realised_model = model.modal_nonunital()?; - let analysis = ode::PolynomialODEAnalysis::default(); - Ok(analysis.build_system(realised_model)) -} - -// // TODO: This enum should already be defined somewhere else... -// enum Doctrine { -// Discrete, -// Tabulated, -// ModalUnital, -// ModalNonUnital, -// } - -// match doctrine { -// Doctrine::Discrete => { -// let realised_model = model.discrete()?; -// let analysis = ::default(); -// Ok(analysis.build_system(realised_model)) -// } -// Doctrine::Tabulated => { -// let realised_model = model.discrete_tab()?; -// let analysis = S::AnalysisType::default(); -// Ok(analysis.build_system(realised_model)) -// } -// Doctrine::ModalUnital => { -// let realised_model = model.modal_unital()?; -// let analysis = S::AnalysisType::default(); -// Ok(analysis.build_system(realised_model)) -// } -// Doctrine::ModalNonUnital => { -// let realised_model = model.modal_nonunital()?; -// let analysis = S::AnalysisType::default(); -// Ok(analysis.build_system(realised_model)) -// } -// } - -// // TODO: define `fn polynomial_system` ?????????? -// fn ode_semantics_system( -// model: &DblModel, -// // doctrine: Doctrine, -// ) -> Result, i8>, String> { -// let realised_model = model.discrete()?; -// let analysis = ::default(); -// Ok(analysis.build_system(std::rc::Rc::::unwrap_or_clone(realised_model))) -// } - -// fn ode_semantics_system( -// model: &Rc, -// ) -> Result, i8>, String> { -// let analysis = S::AnalysisType::default(); -// // TODO: can we just use .try_into() directly? as in e.g. the definition for modal_nonunital() -// Ok(analysis.build_system(model)) -// } - -/// Generates the PolynomialSystem for Lotka-Volterra dynamics. -pub(crate) fn lotka_volterra_system( - model: &DblModel, -) -> Result, i8>, String> -{ - let realised_model = model.discrete()?; - let analysis = ode::LotkaVolterraAnalysis::default(); - Ok(analysis.build_system(realised_model)) -} - -/// Generates the PolynomialSystem for LCC dynamics. -pub(crate) fn linear_ode_system( - model: &DblModel, -) -> Result, i8>, String> { - let realised_model = model.discrete()?; - let analysis = ode::LCCAnalysis::default(); - Ok(analysis.build_system(realised_model)) -} - -// TODO: you should be able to REMOVE this enum -/// Mass-action analysis is currently implemented for Petri nets and stock-flow diagrams -/// and we can avoid some code reduplication by making this explicit. -#[derive(Copy, Clone)] -pub(crate) enum MassActionAnalysisLogic { - /// The modal theory of Petri nets. - PetriNet, - /// The discrete tabulator theory of stock-flow diagrams. - StockFlow, -} - -/// Generates the PolynomialSystem for mass-action dynamics. -pub(crate) fn mass_action_system( - model: &DblModel, - mass_conservation_type: ode::MassConservationType, - logic: MassActionAnalysisLogic, -) -> Result, i8>, String> { - match logic { - MassActionAnalysisLogic::PetriNet => { - let realised_model = model.modal_unital()?; - let analysis = ode::PetriNetMassActionAnalysis { - mass_conservation_type, - ..ode::PetriNetMassActionAnalysis::default() - }; - Ok(analysis.build_system(realised_model)) - } - MassActionAnalysisLogic::StockFlow => { - let realised_model = model.discrete_tab()?; - let analysis = ode::StockFlowMassActionAnalysis { - mass_conservation_type, - ..ode::StockFlowMassActionAnalysis::default() - }; - Ok(analysis.build_system(realised_model)) - } - } -} - -/// TODO: documentation. -// TODO: rewrite this to use ode_semantics_system, so that there's no need to preface with e.g. -// let system = lotka_volterra_system(model); -// in theories.rs +/// Simulate specific ODE semantics on a model, for use in a simulation analysis. pub(crate) fn ode_semantics_simulation( model: &DblModel, problem_data: S::ProblemDataType, @@ -164,10 +45,7 @@ pub(crate) fn ode_semantics_simulation( }) } -/// TODO: documentation. -// TODO: rewrite this to use ode_semantics_system, so that there's no need to preface with e.g. -// let system = lotka_volterra_system(model); -// in theories.rs +/// Generate the equations of specific ODE semantics on a model, for use in an equations analysis. pub(crate) fn ode_semantics_equations( model: &DblModel, system: PolynomialSystem, i8>, @@ -175,53 +53,30 @@ pub(crate) fn ode_semantics_equations( Ok(system.to_latex_equations_with_map(|param| latex_names(model)(param))) } -// TODO: replace this with ode_semantics_simulation by implementing ODESemantics for polynomial_ode ??? -/// Simulates polynomial ODE equations. -pub(crate) fn polynomial_ode_simulation( - model: &DblModel, - problem_data: ode::PolynomialODEProblemData, -) -> Result { - let system = polynomial_ode_system(model); - let sys_extended_scalars = problem_data.extend_scalars(system?); - let latex_equations = - sys_extended_scalars.map_variables(latex_names(model)).to_latex_equations(); - let analysis = problem_data.build_analysis(sys_extended_scalars); - let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); - Ok(ODEResultWithEquations { - solution: ODEResult(solution.into()), - latex_equations, - }) -} - -// TODO: replace this with ode_semantics_equations by implementing ODESemantics for polynomial_ode ??? -/// Generates equations for the system of polynomial ODEs. -pub(crate) fn polynomial_ode_equations(model: &DblModel) -> Result { - let system = polynomial_ode_system(model); - let equations = system?.to_latex_equations_with_map(|param| latex_names(model)(param)); - Ok(equations) -} - #[cfg(test)] mod tests { use super::*; - use crate::latex::latex_names; use crate::model::{DblModel, tests::backward_link}; use crate::theories::{ThSignedCategory, ThSymMonoidalCategory}; use catcolab_document_types::v2::{Modality, MorDecl, MorType, Ob, ObDecl, ObType}; use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; - use catlog::stdlib::analyses::ode::{MassConservationType, PetriNetMassActionAnalysis, StockFlowMassActionAnalysis}; - use catlog::stdlib::{analyses::ode, theories}; + use catlog::stdlib::{ + analyses::ode::{self, MassConservationType, ODESemanticsAnalysis}, + theories, + }; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; use std::rc::Rc; use uuid::Uuid; + // TODO: test for polynomial_ode_simulation + #[test] fn cld_lotka_volterra_latex_equations() { let model = parallel_negative_cld("x", "yellow", "f", ""); - let system = lotka_volterra_system(&model).unwrap(); + let system = ode::LotkaVolterraAnalysis::default().build_system(model.discrete().unwrap()); let equations = ode_semantics_equations::(&model, system).unwrap(); @@ -248,8 +103,8 @@ mod tests { #[test] fn cld_lcc_latex_equations() { let model = parallel_negative_cld("x", "yellow", "f", ""); - let system = linear_ode_system(&model).unwrap(); - let equations = ode_semantics_equations::(&model, system).unwrap(); + let system = ode::LinearODEAnalysis::default().build_system(model.discrete().unwrap()); + let equations = ode_semantics_equations::(&model, system).unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -270,12 +125,13 @@ mod tests { #[test] fn stock_flow_balanced_mass_action_latex_equations() { let model = backward_link("xxx", "yyy", "fff"); - let system = mass_action_system( - &model, - MassConservationType::Balanced, - MassActionAnalysisLogic::StockFlow, - ).unwrap(); - let equations = ode_semantics_equations::(&model, system).unwrap(); + let system = ode::StockFlowMassActionAnalysis { + mass_conservation_type: MassConservationType::Balanced, + ..ode::StockFlowMassActionAnalysis::default() + } + .build_system(model.discrete_tab().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -293,12 +149,15 @@ mod tests { #[test] fn stock_flow_unbalanced_mass_action_latex_equations() { let model = backward_link("xxx", "yyy", "fff"); - let system = mass_action_system( - &model, - MassConservationType::Unbalanced(ode::RateGranularity::PerTransition), - MassActionAnalysisLogic::StockFlow, - ).unwrap(); - let equations = ode_semantics_equations::(&model, system).unwrap(); + let system = ode::StockFlowMassActionAnalysis { + mass_conservation_type: MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + ..ode::StockFlowMassActionAnalysis::default() + } + .build_system(model.discrete_tab().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -315,18 +174,17 @@ mod tests { assert_eq!(equations, expected); } - // TODO: REMOVE THIS #[ignore] #[test] - #[ignore] fn petri_net_balanced_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. let model = catalytic_petri_net("liquid", "solid", "c", ""); - let system = mass_action_system( - &model, - MassConservationType::Balanced, - MassActionAnalysisLogic::PetriNet, - ).unwrap(); - let equations = ode_semantics_equations::(&model, system).unwrap(); + let system = ode::PetriNetMassActionAnalysis { + mass_conservation_type: MassConservationType::Balanced, + ..ode::PetriNetMassActionAnalysis::default() + } + .build_system(model.modal_unital().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -351,18 +209,19 @@ mod tests { assert_eq!(equations, expected); } - // TODO: REMOVE THIS #[ignore] #[test] - #[ignore] fn petri_net_unbalanced_pt_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. let model = catalytic_petri_net("liquid", "solid", "c", ""); - let system = mass_action_system( - &model, - MassConservationType::Unbalanced(ode::RateGranularity::PerTransition), - MassActionAnalysisLogic::PetriNet, - ).unwrap(); - let equations = ode_semantics_equations::(&model, system).unwrap(); + let system = ode::PetriNetMassActionAnalysis { + mass_conservation_type: MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + ..ode::PetriNetMassActionAnalysis::default() + } + .build_system(model.modal_unital().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -381,18 +240,19 @@ mod tests { assert_eq!(equations, expected); } - // TODO: REMOVE THIS #[ignore] #[test] - #[ignore] fn petri_net_unbalanced_pp_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. let model = catalytic_petri_net("liquid", "solid", "c", ""); - let system = mass_action_system( - &model, - MassConservationType::Unbalanced(ode::RateGranularity::PerPlace), - MassActionAnalysisLogic::PetriNet, - ).unwrap(); - let equations = ode_semantics_equations::(&model, system).unwrap(); + let system = ode::PetriNetMassActionAnalysis { + mass_conservation_type: MassConservationType::Unbalanced( + ode::RateGranularity::PerPlace, + ), + ..ode::PetriNetMassActionAnalysis::default() + } + .build_system(model.modal_unital().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); // TODO: write down the expected equations let expected = LatexEquations(vec![ diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 98202a973..d5ff7df46 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -9,13 +9,13 @@ use wasm_bindgen::prelude::*; use catlog::dbl::theory::{self as theory, NonUnital, Unital}; use catlog::latex::LatexEquations; use catlog::one::Path; +use catlog::stdlib::analyses::ode::ODESemanticsAnalysis; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::name; use super::latex::LatexEquations; use super::model_morphism::{MotifOccurrence, MotifsOptions, motifs}; use super::result::JsResult; -use super::theories::MassActionAnalysisLogic; use super::{analyses::*, model::DblModel, theory::DblTheory}; /// The empty or initial theory. @@ -152,15 +152,17 @@ impl ThSignedCategory { model: &DblModel, data: analyses::ode::LotkaVolterraProblemData, ) -> Result { - let system = lotka_volterra_system(model); - ode_semantics_simulation::(model, data, system?) + let system = + analyses::ode::LotkaVolterraAnalysis::default().build_system(model.discrete()?); + ode_semantics_simulation::(model, data, system) } /// Show the equations of the Lotka-Volterra system derived from a model. #[wasm_bindgen(js_name = "lotkaVolterraEquations")] pub fn lotka_volterra_equations(&self, model: &DblModel) -> Result { - let system = lotka_volterra_system(model); - ode_semantics_equations::(model, system?) + let system = + analyses::ode::LotkaVolterraAnalysis::default().build_system(model.discrete()?); + ode_semantics_equations::(model, system) } /// Simulate the linear ODE system derived from a model. @@ -168,17 +170,17 @@ impl ThSignedCategory { pub fn linear_ode( &self, model: &DblModel, - data: analyses::ode::LCCProblemData, + data: analyses::ode::LinearODEProblemData, ) -> Result { - let system = linear_ode_system(model); - ode_semantics_simulation::(model, data, system?) + let system = analyses::ode::LinearODEAnalysis::default().build_system(model.discrete()?); + ode_semantics_simulation::(model, data, system) } /// Show the equations of the linear ODE system derived from a model. #[wasm_bindgen(js_name = "linearODEEquations")] pub fn linear_ode_equations(&self, model: &DblModel) -> Result { - let system = linear_ode_system(model); - ode_semantics_equations::(model, system?) + let system = analyses::ode::LinearODEAnalysis::default().build_system(model.discrete()?); + ode_semantics_equations::(model, system) } } @@ -342,14 +344,12 @@ impl ThCategoryLinks { model: &DblModel, data: analyses::ode::MassActionProblemData, ) -> Result { - let system = mass_action_system( - model, - data.equations_data.mass_conservation_type, - MassActionAnalysisLogic::StockFlow, - ); - ode_semantics_simulation::( - model, data, system?, - ) + let system = analyses::ode::StockFlowMassActionAnalysis { + mass_conservation_type: data.equations_data.mass_conservation_type, + ..analyses::ode::StockFlowMassActionAnalysis::default() + } + .build_system(model.discrete_tab()?); + ode_semantics_simulation::(model, data, system) } /// Returns the symbolic mass-action equations in LaTeX format. @@ -359,12 +359,12 @@ impl ThCategoryLinks { model: &DblModel, data: analyses::ode::MassActionEquationsData, ) -> Result { - let system = mass_action_system( - model, - data.mass_conservation_type, - MassActionAnalysisLogic::StockFlow, - ); - ode_semantics_equations::(model, system?) + let system = analyses::ode::StockFlowMassActionAnalysis { + mass_conservation_type: data.mass_conservation_type, + ..analyses::ode::StockFlowMassActionAnalysis::default() + } + .build_system(model.discrete_tab()?); + ode_semantics_equations::(model, system) } } @@ -408,14 +408,12 @@ impl ThSymMonoidalCategory { model: &DblModel, data: analyses::ode::MassActionProblemData, ) -> Result { - let system = mass_action_system( - model, - data.equations_data.mass_conservation_type, - MassActionAnalysisLogic::PetriNet, - ); - ode_semantics_simulation::( - model, data, system?, - ) + let system = analyses::ode::PetriNetMassActionAnalysis { + mass_conservation_type: data.equations_data.mass_conservation_type, + ..analyses::ode::PetriNetMassActionAnalysis::default() + } + .build_system(model.modal_unital()?); + ode_semantics_simulation::(model, data, system) } /// Returns the symbolic mass-action equations in LaTeX format. @@ -425,12 +423,12 @@ impl ThSymMonoidalCategory { model: &DblModel, data: analyses::ode::MassActionEquationsData, ) -> Result { - let system = mass_action_system( - model, - data.mass_conservation_type, - MassActionAnalysisLogic::PetriNet, - ); - ode_semantics_equations::(model, system?) + let system = analyses::ode::PetriNetMassActionAnalysis { + mass_conservation_type: data.mass_conservation_type, + ..analyses::ode::PetriNetMassActionAnalysis::default() + } + .build_system(model.modal_unital()?); + ode_semantics_equations::(model, system) } /// Simulates the stochastic mass-action system derived from a model. @@ -482,13 +480,17 @@ impl ThPolynomialODE { model: &DblModel, data: analyses::ode::PolynomialODEProblemData, ) -> Result { - polynomial_ode_simulation(model, data) + let system = + analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); + ode_semantics_simulation::(model, data, system) } /// Returns the symbolic equations in LaTeX format. #[wasm_bindgen(js_name = "polynomialODEEquations")] pub fn polynomial_ode_equations(&self, model: &DblModel) -> Result { - polynomial_ode_equations(model) + let system = + analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); + ode_semantics_equations::(model, system) } } @@ -515,13 +517,17 @@ impl ThSignedPolynomialODE { model: &DblModel, data: analyses::ode::PolynomialODEProblemData, ) -> Result { - polynomial_ode_simulation(model, data) + let system = + analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); + ode_semantics_simulation::(model, data, system) } /// Returns the symbolic equations in LaTeX format. #[wasm_bindgen(js_name = "polynomialODEEquations")] pub fn polynomial_ode_equations(&self, model: &DblModel) -> Result { - polynomial_ode_equations(model) + let system = + analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); + ode_semantics_equations::(model, system) } } diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 54e3fd1b9..c6d2e8641 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -1,7 +1,7 @@ -//! Linear constant-coefficient (LCC) first-order ODE analysis of models. +//! Linear constant-coefficient first-order ODE analysis of models. //! //! This follows the structure of [`ode::ode_semantics`], implementing `ODESemantics` for the struct -//! `LCCSemantics`. For heritage reasons, "LCC" is sometimes referred to as "LinearODE". +//! `LinearODESemantics`. //! //! [`ode::ode_semantics`]: crate::stdlib::analyses::ode::ode_semantics @@ -25,20 +25,20 @@ use crate::stdlib::analyses::ode::ode_semantics::{ use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; -/// Implementing LCC as an ODE semantics for models of type `DiscreteDblModel`. -pub struct LCCSemantics; +/// Implementing LinearODE as an ODE semantics for models of type `DiscreteDblModel`. +pub struct LinearODESemantics; -impl ODESemantics for LCCSemantics { +impl ODESemantics for LinearODESemantics { type ModelType = DiscreteDblModel; - type ParameterType = LCCParameter; - type AnalysisType = LCCAnalysis; + type ParameterType = LinearODEParameter; + type AnalysisType = LinearODEAnalysis; type EquationsDataType = (); - type ProblemDataType = LCCProblemData; + type ProblemDataType = LinearODEProblemData; } /// Parameters in the linear equations correspond only to morphisms. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum LCCParameter { +pub enum LinearODEParameter { /// The parameter associated to a morphism. Parameter { /// The morphism. @@ -46,7 +46,7 @@ pub enum LCCParameter { }, } -impl fmt::Display for LCCParameter { +impl fmt::Display for LinearODEParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Parameter { morphism } => { @@ -56,7 +56,7 @@ impl fmt::Display for LCCParameter { } } -impl ToLatexWithMap for LCCParameter { +impl ToLatexWithMap for LinearODEParameter { fn to_latex_with_map String>(&self, f: T) -> Latex { match self { Self::Parameter { morphism } => Latex(format!("\\lambda_{{{}}}", f(morphism))), @@ -64,10 +64,10 @@ impl ToLatexWithMap for LCCParameter { } } -impl ODEParameterType for LCCParameter {} +impl ODEParameterType for LinearODEParameter {} /// Linear ODE analysis for causal loop diagrams (CLDs). -pub struct LCCAnalysis { +pub struct LinearODEAnalysis { /// Object type for variables. pub var_ob_type: QualifiedName, /// Morphism type for positive links. @@ -76,7 +76,7 @@ pub struct LCCAnalysis { pub neg_link_type: QualifiedPath, } -impl Default for LCCAnalysis { +impl Default for LinearODEAnalysis { fn default() -> Self { let ob_type = name("Object"); Self { @@ -89,17 +89,17 @@ impl Default for LCCAnalysis { impl ODESemanticsAnalysis< - ::ModelType, - ::ParameterType, - > for LCCAnalysis + ::ModelType, + ::ParameterType, + > for LinearODEAnalysis { /// Creates a linear system with symbolic rate coefficients. /// - /// A system of ODEs for building arbitrary LCC ODEs from CLDs. + /// A system of ODEs for building arbitrary LinearODE ODEs from CLDs. fn build_system_builder( &self, - model: &::ModelType, - ) -> PolynomialODESystemBuilder<::ParameterType> { + model: &::ModelType, + ) -> PolynomialODESystemBuilder<::ParameterType> { let mut builder = PolynomialODESystemBuilder::new(); for var in model.ob_generators_with_type(&self.var_ob_type) { @@ -120,7 +120,7 @@ impl mor.clone(), cod.clone(), ContributionSign::Positive, - LCCParameter::Parameter { morphism: mor }, + LinearODEParameter::Parameter { morphism: mor }, [dom.clone()], ); } @@ -138,7 +138,7 @@ impl mor.clone(), cod.clone(), ContributionSign::Negative, - LCCParameter::Parameter { morphism: mor }, + LinearODEParameter::Parameter { morphism: mor }, [dom.clone()], ); } @@ -154,7 +154,7 @@ impl feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] -pub struct LCCProblemData { +pub struct LinearODEProblemData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "coefficients"))] coefficients: HashMap, @@ -167,7 +167,7 @@ pub struct LCCProblemData { duration: f32, } -impl ODESemanticsProblemData<::ParameterType> for LCCProblemData { +impl ODESemanticsProblemData<::ParameterType> for LinearODEProblemData { fn initial_values(&self) -> HashMap { self.initial_values.clone() } @@ -180,13 +180,13 @@ impl ODESemanticsProblemData<::ParameterType> for &self, sys: PolynomialSystem< QualifiedName, - Parameter<::ParameterType>, + Parameter<::ParameterType>, i8, >, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { poly.eval(|param| match param { - LCCParameter::Parameter { morphism } => { + LinearODEParameter::Parameter { morphism } => { self.coefficients.get(morphism).cloned().unwrap_or_default() } }) @@ -214,7 +214,7 @@ mod test { fn predator_prey_symbolic() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let sys = LCCAnalysis::default().build_system(&model); + let sys = LinearODEAnalysis::default().build_system(&model); let expected = expect!([r#" dx = -Parameter(negative) y dy = Parameter(positive) x @@ -236,7 +236,7 @@ mod test { model.add_mor(name("i"), name("a"), name("c"), name("Negative").into()); model.add_mor(name("j"), name("c"), name("d"), Path::Id(name("Object"))); model.add_mor(name("k"), name("d"), name("b"), name("Negative").into()); - let sys = LCCAnalysis::default().build_system(&model); + let sys = LinearODEAnalysis::default().build_system(&model); let expected = expect!([r#" da = (Parameter(g) - Parameter(h)) b db = Parameter(f) a - Parameter(k) d @@ -252,7 +252,7 @@ mod test { fn to_latex() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let sys = LCCAnalysis::default().build_system(&model); + let sys = LinearODEAnalysis::default().build_system(&model); // .extend_scalars(|param| param.map_variables(to_latex)) let expected = LatexEquations(vec![ LatexEquation { @@ -274,13 +274,13 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let data = LCCProblemData { + let data = LinearODEProblemData { coefficients: [(name("positive"), 3.0), (name("negative"), 2.0)].into_iter().collect(), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, }; - let sys = LCCAnalysis::default().build_system(&model); + let sys = LinearODEAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" dx = -2 y diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 0afca8940..9d523cf42 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -1,25 +1,22 @@ //! Analyses for different ODE semantics on models. //! //! Inspired by schema migration, we define the data of an ODE semantics on models in a theory to -//! consist of (in particular) a `PolynomialODESystemBuilder`, which contains all the data needed -//! for [`ode::polynomial_ode::PolynomialODEAnalysis`] to do the following: -//! -// TODO: is this true???????????? -//! 1. Build the system as a model of the theory of polynomial ODE systems (i.e. multicategories) -//! with abstract coefficients, using `build_system_custom_parameters()`. -//! 2. Substitute in numerical coefficients, using `extend_polynomial_ode_scalars()`. -//! 3. Build an `ODEAnalysis>` that can be fed into an ODE solver, -//! using `polynomial_ode_analysis()`. -//! +//! consist of (in particular) a `PolynomialODESystemBuilder`, which constructs a model of the +//! theory of multicategories (viewed as polynomial ODE systems with abstract coefficients). This +//! is then passed to [`ode::polynomial_ode::PolynomialODEAnalysis`] which constructs from this a +//! `PolynomialSystem`, using `build_system_custom_parameters()`. + //! In short, this module constructs multicategories from models, and [`ode::polynomial_ode`] then //! constructs `PolynomialSystem` from multicategories. //! //! To implement a new ODE semantics for models in some theory, one essentially needs to create an //! empty struct and implement `ODESemantics`, and then follow the compiler. For more documentation, -//! see [`ode::polynomial_ode`]; for an example implementation, see [`ode::mass_action`]. +//! see [`ode::polynomial_ode`]; for a simple example see [`ode::lotka_volterra`], and for a more +//! complicated example see [`ode::mass_action`]. //! //! [`ode::polynomial_ode`]: crate::stdlib::analyses::ode::polynomial_ode //! [`ode::polynomial_ode::PolynomialODEAnalysis`]: crate::stdlib::analyses::ode::polynomial_ode::PolynomialODEAnalysis +//! [`ode::lotka_volterra`]: crate::stdlib::analyses::ode::lotka_volterra //! [`ode::mass_action`]: crate::stdlib::analyses::ode::mass_action use indexmap::IndexMap; @@ -229,15 +226,19 @@ pub enum ContributionSign { Negative, } -/// TODO: documentation. -// TODO: similar question about including all the serde stuff here +/// For some ODE semantics, it might be the case there extra information can be given to determine +/// the equations. For example, a boolean describing whether or not mass should be conserved, or +/// something more complicated. This is generally data that will be exposed to the frontend in the +/// corresponding analysis. For an example, see `mass_action::MassActionEquationsData`. pub trait ODESemanticsEquationsData {} impl ODESemanticsEquationsData for () {} /// The trait describing how to turn the formal system of ODEs into a numerical problem, to be /// solved by an ODE solver and presented to the front-end. At minimum, such data must contain /// initial values for variables and the intended duration of simulation, as well as the method for -/// converting the parameters (which are of type `ODEParameterType`) into floats. +/// converting the parameters (which are of type `ODEParameterType`) into floats. Note that it must +/// also contain `ODESemanticsEquationsData`, since we need to know how to build the equations +/// before we are able to solve them numerically. // REQUEST | If you look at a struct that implements this trait (such as `LotkaVolterraProblemData`), // FOR | there are a lot of serde statements going on. Should I be able to just move them // FEEDBACK | (that is, those that come *before* the struct) here and have things all work? I'm still @@ -257,7 +258,6 @@ pub trait ODESemanticsProblemData { // In short: // is there a better way to ensure that any struct implementing a trait has specific fields? /// Further data needed to specify the ODE equations. - /// TODO: documenation. fn equations_data(&self) -> impl ODESemanticsEquationsData { () } diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index 2203aa9fb..901e88d92 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -67,7 +67,11 @@ impl Default for PolynomialODEAnalysis { } } -// TODO: remove this implementation? it's so silly?????????? but we need it???????????????????????? +// We give a trivial implementation of `ODESemanticsAnalysis` using the helper method +// `PolynomialODESystemBuilder::identity`. This is nice from a conceptual point of view (in that all +// polynomial ODE semantics are unified under one trait), but also concretely helpful in reducing +// boilerplate since we can then use `catlog-wasm::src::analyses::ode_semantics_simulation` and +// `catlog-wasm::src::analyses::ode_semantics_equations`. impl ODESemanticsAnalysis< ::ModelType, diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index a5ebbead1..65619d29d 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,7 +1,7 @@ import { lazy } from "solid-js"; import type { - LCCEquationsData, + LinearODEEquationsData, LotkaVolterraEquationsData, MassActionEquationsData, MorType, @@ -109,9 +109,9 @@ const Kuramoto = lazy(() => import("./analyses/kuramoto")); export function linearODE( options: Partial & { - simulate: Simulators.LCCSimulator; + simulate: Simulators.LinearODESimulator; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "linear-ode", name = "Linear ODE dynamics", @@ -124,7 +124,7 @@ export function linearODE( name, description, help, - component: (props) => , + component: (props) => , initialContent: () => ({ coefficients: {}, initialValues: {}, @@ -133,13 +133,13 @@ export function linearODE( }; } -const LCC = lazy(() => import("./analyses/linear_ode")); +const LinearODE = lazy(() => import("./analyses/linear_ode")); export function linearODEEquations( options: Partial & { - getEquations: Simulators.LCCEquations; + getEquations: Simulators.LinearODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "linear-ode-equations", name = "Linear ODE equations", @@ -152,13 +152,13 @@ export function linearODEEquations( name, description, help, - component: (props) => , + component: (props) => , initialContent: () => ({ trivialData: true, }), }; } -const LCCEquationsDisplay = lazy(() => import("./analyses/linear_ode_equations")); +const LinearODEEquationsDisplay = lazy(() => import("./analyses/linear_ode_equations")); export function lotkaVolterra( options: Partial & { diff --git a/packages/frontend/src/stdlib/analyses/linear_ode.tsx b/packages/frontend/src/stdlib/analyses/linear_ode.tsx index 40e3cd7a4..94c42538b 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode.tsx @@ -7,19 +7,19 @@ import { ExpandableTable, KatexDisplay, } from "catcolab-ui-components"; -import type { LCCProblemData, QualifiedName } from "catlog-wasm"; +import type { LinearODEProblemData, QualifiedName } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { morLabelOrDefault } from "../../model"; import { ODEResultPlot } from "../../visualization"; import { createModelODEPlotWithEquations } from "./model_ode_plot"; -import type { LCCSimulator } from "./simulator_types"; +import type { LinearODESimulator } from "./simulator_types"; import "./simulation.css"; -/** Analyze a model using LCC dynamics. */ -export default function LCC( - props: ModelAnalysisProps & { - simulate: LCCSimulator; +/** Analyze a model using LinearODE dynamics. */ +export default function LinearODE( + props: ModelAnalysisProps & { + simulate: LinearODESimulator; title?: string; }, ) { diff --git a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx index 73f0be0e0..d0e65d2db 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx @@ -1,16 +1,16 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import { LCCEquationsData } from "catlog-wasm"; +import { LinearODEEquationsData } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { createModelODELatex } from "./model_ode_plot"; -import type { LCCEquations } from "./simulator_types"; +import type { LinearODEEquations } from "./simulator_types"; import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ -export default function LCCEquationsDisplay( - props: ModelAnalysisProps & { - content: LCCEquationsData; - getEquations: LCCEquations; +export default function LinearODEEquationsDisplay( + props: ModelAnalysisProps & { + content: LinearODEEquationsData; + getEquations: LinearODEEquations; title?: string; }, ) { diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index 4915c981b..02c8abf34 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -2,8 +2,8 @@ import type { DblModel, KuramotoProblemData, LatexEquations, - LCCProblemData, - LCCEquationsData, + LinearODEProblemData, + LinearODEEquationsData, LotkaVolterraProblemData, LotkaVolterraEquationsData, MassActionEquationsData, @@ -17,15 +17,15 @@ import type { export type { KuramotoProblemData, - LCCProblemData, + LinearODEProblemData, LotkaVolterraProblemData, MassActionProblemData, PolynomialODEProblemData, }; export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; -export type LCCSimulator = (model: DblModel, data: LCCProblemData) => ODEResultWithEquations; -export type LCCEquations = (model: DblModel, data: LCCEquationsData) => LatexEquations; +export type LinearODESimulator = (model: DblModel, data: LinearODEProblemData) => ODEResultWithEquations; +export type LinearODEEquations = (model: DblModel, data: LinearODEEquationsData) => LatexEquations; export type LotkaVolterraSimulator = ( model: DblModel, data: LotkaVolterraProblemData, From 40c6cb94e18a5d20916b2ed5bf7ce269a336e1f8 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 23 Jun 2026 20:57:06 +0100 Subject: [PATCH 26/38] WIP: Tests revealing error in latex_names() for objects that are lists --- packages/catlog-wasm/src/analyses.rs | 201 ++++++------------ .../src/stdlib/analyses/ode/linear_ode.rs | 4 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 4 +- 3 files changed, 67 insertions(+), 142 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index badfaa1ab..50d6b5fc0 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -58,8 +58,8 @@ mod tests { use super::*; use crate::model::{DblModel, tests::backward_link}; - use crate::theories::{ThSignedCategory, ThSymMonoidalCategory}; - use catcolab_document_types::v2::{Modality, MorDecl, MorType, Ob, ObDecl, ObType}; + use crate::theories::ThSignedCategory; + use catcolab_document_types::v2::{MorDecl, MorType, Ob, ObDecl, ObType}; use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; @@ -124,7 +124,7 @@ mod tests { #[test] fn stock_flow_balanced_mass_action_latex_equations() { - let model = backward_link("xxx", "yyy", "fff"); + let model = backward_link("xylophone", "y", "fff"); let system = ode::StockFlowMassActionAnalysis { mass_conservation_type: MassConservationType::Balanced, ..ode::StockFlowMassActionAnalysis::default() @@ -135,12 +135,12 @@ mod tests { let expected = LatexEquations(vec![ LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), - rhs: Latex("-r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xylophone}".to_string()), + rhs: Latex("-r_{\\text{fff}} \\cdot \\text{xylophone} \\cdot y".to_string()), }, LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), - rhs: Latex("r_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("r_{\\text{fff}} \\cdot \\text{xylophone} \\cdot y".to_string()), }, ]); assert_eq!(equations, expected); @@ -148,7 +148,7 @@ mod tests { #[test] fn stock_flow_unbalanced_mass_action_latex_equations() { - let model = backward_link("xxx", "yyy", "fff"); + let model = backward_link("xylophone", "y", "fff"); let system = ode::StockFlowMassActionAnalysis { mass_conservation_type: MassConservationType::Unbalanced( ode::RateGranularity::PerTransition, @@ -161,14 +161,12 @@ mod tests { let expected = LatexEquations(vec![ LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string()), - rhs: Latex( - "-\\kappa_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string(), - ), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xylophone}".to_string()), + rhs: Latex("-\\kappa_{\\text{fff}} \\cdot \\text{xylophone} \\cdot y".to_string()), }, LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string()), - rhs: Latex("\\rho_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".to_string()), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("\\rho_{\\text{fff}} \\cdot \\text{xylophone} \\cdot y".to_string()), }, ]); assert_eq!(equations, expected); @@ -254,97 +252,27 @@ mod tests { let equations = ode_semantics_equations::(&model, system).unwrap(); - // TODO: write down the expected equations let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex("".to_string()), + rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{liquid}} \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex("".to_string()), + rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}} \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("".to_string()), + rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); } - #[test] - fn modal_mor_dom_cod_labels() { - let th = Rc::new(theories::th_sym_monoidal_category()); - let ob_type = ModalObType::new(QualifiedName::from("Object")); - let op = QualifiedName::from("tensor"); - - let [s_id, i_id, r_id] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; - let [infect_id, recover_id] = [Uuid::now_v7(), Uuid::now_v7()]; - - let mut inner = ModalDblModel::new(th); - inner.add_ob(s_id.into(), ob_type.clone()); - inner.add_ob(i_id.into(), ob_type.clone()); - inner.add_ob(r_id.into(), ob_type.clone()); - - // infect: tensor(S, I) -> tensor(I, I) — product-typed dom and cod. - inner.add_mor( - infect_id.into(), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(s_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), - ), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), - ), - ModalMorType::Zero(ob_type.clone()), - ); - - // recover: I -> R — simple generator dom and cod. - inner.add_mor( - recover_id.into(), - ModalOb::Generator(i_id.into()), - ModalOb::Generator(r_id.into()), - ModalMorType::Zero(ob_type), - ); - - let mut ob_namespace = Namespace::new_for_uuid(); - ob_namespace.set_label(s_id, LabelSegment::Text("S".into())); - ob_namespace.set_label(i_id, LabelSegment::Text("I".into())); - ob_namespace.set_label(r_id, LabelSegment::Text("R".into())); - - let model = DblModel { - model: inner.into(), - ty: None, - ob_namespace, - mor_namespace: Namespace::new_for_uuid(), - }; - - // Morphism with basic generator dom/cod resolves labels. - assert_eq!( - model.mor_generator_dom_cod_label_strings(&recover_id.into()), - Some(("I".to_string(), "R".to_string())) - ); - - // Morphism with product-typed dom/cod resolves to bracketed labels. - assert_eq!( - model.mor_generator_dom_cod_label_strings(&infect_id.into()), - Some(("[S, I]".to_string(), "[I, I]".to_string())) - ); - } - /// Construct a causal loop diagram with objects x, y and negative links f, g : x -> y. fn parallel_negative_cld( - src_name: &str, - tgt_name: &str, + source_name: &str, + target_name: &str, first_link_name: &str, second_link_name: &str, ) -> DblModel { @@ -355,7 +283,7 @@ mod tests { assert!( model .add_ob(&ObDecl { - name: src_name.into(), + name: source_name.into(), id: x, ob_type: ObType::Basic("Object".into()) }) @@ -364,7 +292,7 @@ mod tests { assert!( model .add_ob(&ObDecl { - name: tgt_name.into(), + name: target_name.into(), id: y, ob_type: ObType::Basic("Object".into()) }) @@ -398,57 +326,54 @@ mod tests { /// Construct a Petri net representing a catalytic transition [x,c] -> [y,c]. fn catalytic_petri_net( - src_name: &str, - tgt_name: &str, + source_name: &str, + target_name: &str, catalyst_name: &str, - _transition_name: &str, + transition_name: &str, ) -> DblModel { - let th = ThSymMonoidalCategory::new().theory(); - let mut model = DblModel::new(&th); - let [x, y, c, _t] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + let th = Rc::new(theories::th_sym_monoidal_category()); + let ob_type = ModalObType::new(QualifiedName::from("Object")); + let op = QualifiedName::from("tensor"); - assert!( - model - .add_ob(&ObDecl { - name: src_name.into(), - id: x, - // ob_type: ObType::Basic("Object".into()), - // TODO: what is the correct ob_type here? - ob_type: ObType::ModeApp { - modality: Modality::SymmetricList, - ob_type: Box::new(ObType::Basic("Object".into())) - }, - }) - .is_ok() - ); - assert!( - model - .add_ob(&ObDecl { - name: tgt_name.into(), - id: y, - // ob_type: ObType::Basic("Object".into()), - ob_type: ObType::ModeApp { - modality: Modality::SymmetricList, - ob_type: Box::new(ObType::Basic("Object".into())) - }, - }) - .is_ok() - ); - assert!( - model - .add_ob(&ObDecl { - name: catalyst_name.into(), - id: c, - // ob_type: ObType::Basic("Object".into()), - ob_type: ObType::ModeApp { - modality: Modality::SymmetricList, - ob_type: Box::new(ObType::Basic("Object".into())) - }, - }) - .is_ok() + let [x, y, c, t] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + + let mut inner = ModalDblModel::new(th); + inner.add_ob(x.into(), ob_type.clone()); + inner.add_ob(y.into(), ob_type.clone()); + inner.add_ob(c.into(), ob_type.clone()); + + inner.add_mor( + t.into(), + ModalOb::App( + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(x.into()), ModalOb::Generator(c.into())], + ) + .into(), + op.clone(), + ), + ModalOb::App( + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(y.into()), ModalOb::Generator(c.into())], + ) + .into(), + op.clone(), + ), + ModalMorType::Zero(ob_type.clone()), ); - // TODO: add the transition [x, c] -> [y, c] - model + let mut ob_namespace = Namespace::new_for_uuid(); + ob_namespace.set_label(x, LabelSegment::Text(source_name.into())); + ob_namespace.set_label(y, LabelSegment::Text(target_name.into())); + ob_namespace.set_label(c, LabelSegment::Text(catalyst_name.into())); + ob_namespace.set_label(t, LabelSegment::Text(transition_name.into())); + + DblModel { + model: inner.into(), + ty: None, + ob_namespace, + mor_namespace: Namespace::new_for_uuid(), + } } } diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index c6d2e8641..1d9d3e73e 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -167,7 +167,9 @@ pub struct LinearODEProblemData { duration: f32, } -impl ODESemanticsProblemData<::ParameterType> for LinearODEProblemData { +impl ODESemanticsProblemData<::ParameterType> + for LinearODEProblemData +{ fn initial_values(&self) -> HashMap { self.initial_values.clone() } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 9d523cf42..747d09b3d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -258,9 +258,7 @@ pub trait ODESemanticsProblemData { // In short: // is there a better way to ensure that any struct implementing a trait has specific fields? /// Further data needed to specify the ODE equations. - fn equations_data(&self) -> impl ODESemanticsEquationsData { - () - } + fn equations_data(&self) -> impl ODESemanticsEquationsData {} /// Map from object IDs to initial values (nonnegative reals). fn initial_values(&self) -> HashMap; /// Duration of simulation. From def96e7b4617cbbf21bba8de477a73fff0b801e1 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 15:41:28 +0100 Subject: [PATCH 27/38] FIX: Passing all Latex tests --- packages/catlog-wasm/src/analyses.rs | 14 +-- packages/catlog-wasm/src/latex.rs | 35 +++++- packages/catlog-wasm/src/model.rs | 107 +++--------------- packages/frontend/src/stdlib/analyses.tsx | 4 +- .../src/stdlib/analyses/simulator_types.ts | 5 +- 5 files changed, 59 insertions(+), 106 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 50d6b5fc0..ae4986dec 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -188,14 +188,14 @@ mod tests { LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), rhs: Latex( - "-r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" + "-r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\cdot \\text{liquid} \\cdot c" .to_string(), ), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), rhs: Latex( - "r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c" + "r_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\cdot \\text{liquid} \\cdot c" .to_string(), ), }, @@ -224,11 +224,11 @@ mod tests { let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), + rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\cdot \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\text{liquid} \\cdot c".to_string()), + rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\cdot \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), @@ -255,15 +255,15 @@ mod tests { let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{liquid}} \\text{liquid} \\cdot c".to_string()), + rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{liquid}} \\cdot \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}} \\text{liquid} \\cdot c".to_string()), + rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}} \\cdot \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{\\text{solid}}) \\cdot \\text{liquid} \\cdot c".to_string()), + rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{c} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{c}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index ffe93e08f..464fd5545 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -4,6 +4,10 @@ use catlog::zero::QualifiedName; use super::model::DblModel; +/// Wrap a string with a Latex text literal if it is longer than a single character. +/// +/// Note that this is not a perfect solution, and is built on a lot of assumptions. Ideally, the +/// frontend should allow users to mark names as Latex or not. fn wrap_with_backslash_text(name: String) -> String { if name.chars().count() > 1 { format!("\\text{{{name}}}") @@ -12,6 +16,15 @@ fn wrap_with_backslash_text(name: String) -> String { } } +/// Display a single-object list [x] directly as "x", but display any longer list as "[x, y ,z]". +fn list_object_as_latex(vec: Vec) -> String { + if vec.len() > 1 { + format!("[{}]", vec.join(", ")) + } else { + vec[0].to_string() + } +} + /// Creates a closure that formats object and morphism names for LaTeX output. When a morphism has a /// name (and thus label), it is used directly; when unnamed, the label falls back to the format /// `domain→codomain` (e.g., `X \to Y`). @@ -23,9 +36,25 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String wrap_with_backslash_text(mor_label.to_string()) } else { let (dom, cod) = model - .mor_generator_dom_cod_label_strings(id) - .expect("Morphism in equation system should have domain and codomain"); - format!("{} \\to {}", wrap_with_backslash_text(dom), wrap_with_backslash_text(cod)) + .mor_generator_dom_cod(id) + .expect("Morphism in equation system should have domain and codomain."); + let dom_labels: Vec = model + .get_ob_label(&dom) + .expect("Object in equation system should have a label.") + .into_iter() + .map(|label| wrap_with_backslash_text(label.to_string())) + .collect(); + let cod_labels: Vec = model + .get_ob_label(&cod) + .expect("Object in equation system should have a label.") + .into_iter() + .map(|label| wrap_with_backslash_text(label.to_string())) + .collect(); + format!( + "{} \\to {}", + list_object_as_latex(dom_labels), + list_object_as_latex(cod_labels) + ) } } } diff --git a/packages/catlog-wasm/src/model.rs b/packages/catlog-wasm/src/model.rs index dc89ec148..b2fb94b02 100644 --- a/packages/catlog-wasm/src/model.rs +++ b/packages/catlog-wasm/src/model.rs @@ -427,46 +427,42 @@ impl DblModel { Ok(()) } - /// Gets label strings for the domain and codomain of a morphism generator. + /// Gets the domain and codomain of a morphism generator. /// - /// Returns `Some((dom_label, cod_label))` when the morphism has a domain - /// and codomain whose labels can be resolved from the namespace. - pub fn mor_generator_dom_cod_label_strings( - &self, - id: &QualifiedName, - ) -> Option<(String, String)> { + /// Returns `Some((dom, cod))`. + pub fn mor_generator_dom_cod(&self, id: &QualifiedName) -> Option<(Ob, Ob)> { let (dom, cod) = all_the_same!(match &self.model { DblModelBox::[Discrete, DiscreteTab, ModalUnital, ModalNonUnital](model) => { (Quoter.quote(model.get_dom(id)?), Quoter.quote(model.get_cod(id)?)) } }); - Some((self.ob_label_string(&dom)?, self.ob_label_string(&cod)?)) + Some((dom, cod)) } - /// Gets a label string for an object. + /// Gets the list of labels for an object. /// - /// For a single object returns its label (e.g. `"S"`). For a list of - /// objects returns bracketed labels (e.g. `"[S, I]"`). - fn ob_label_string(&self, ob: &Ob) -> Option { + /// This works for both basic objects and list objects (e.g. "[x,y]" in a Petri net). + pub fn get_ob_label(&self, ob: &Ob) -> Option> { match ob { Ob::Basic(s) => { let name = QualifiedName::deserialize_str(s).ok()?; - Some(self.ob_namespace.label_string(&name)) + self.ob_namespace.label(&name).map(|var| vec![var]) } Ob::App { ob, .. } => { // FIXME: This is incorrect in general. The design issue is that // this pretty printer claims to handles all models, but is // customized to Petri nets as free SMCs where we prefer to omit // the tensor application. - self.ob_label_string(ob) + self.get_ob_label(ob) } Ob::List { objects, .. } => { - let labels: Option> = objects + let labels: Vec<_> = objects .iter() - .map(|ob| ob.as_ref().and_then(|ob| self.ob_label_string(ob))) + .filter_map(|ob| ob.as_ref().and_then(|ob| self.get_ob_label(ob))) + .flatten() .collect(); - Some(format!("[{}]", labels?.join(", "))) + Some(labels) } _ => None, } @@ -777,14 +773,6 @@ pub fn elaborate_model( #[cfg(test)] pub(crate) mod tests { - use catlog::{ - dbl::{ - modal::{List, ModalMorType, ModalObType}, - model::ModalDblModel, - }, - stdlib::theories, - zero::LabelSegment, - }; use uuid::Uuid; use super::*; @@ -928,73 +916,4 @@ pub(crate) mod tests { assert_eq!(model.mor_generators().len(), 2); assert_eq!(model.validate().0, JsResult::Ok(())); } - - #[test] - fn modal_mor_dom_cod_labels() { - let th = Rc::new(theories::th_sym_monoidal_category()); - let ob_type = ModalObType::new(QualifiedName::from("Object")); - let op = QualifiedName::from("tensor"); - - let [s_id, i_id, r_id] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; - let [infect_id, recover_id] = [Uuid::now_v7(), Uuid::now_v7()]; - - let mut inner = ModalDblModel::new(th); - inner.add_ob(s_id.into(), ob_type.clone()); - inner.add_ob(i_id.into(), ob_type.clone()); - inner.add_ob(r_id.into(), ob_type.clone()); - - // infect: tensor(S, I) -> tensor(I, I) — product-typed dom and cod. - inner.add_mor( - infect_id.into(), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(s_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), - ), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), - ), - ModalMorType::Zero(ob_type.clone()), - ); - - // recover: I -> R — simple generator dom and cod. - inner.add_mor( - recover_id.into(), - ModalOb::Generator(i_id.into()), - ModalOb::Generator(r_id.into()), - ModalMorType::Zero(ob_type), - ); - - let mut ob_namespace = Namespace::new_for_uuid(); - ob_namespace.set_label(s_id, LabelSegment::Text("S".into())); - ob_namespace.set_label(i_id, LabelSegment::Text("I".into())); - ob_namespace.set_label(r_id, LabelSegment::Text("R".into())); - - let model = DblModel { - model: inner.into(), - ty: None, - ob_namespace, - mor_namespace: Namespace::new_for_uuid(), - }; - - // Morphism with basic generator dom/cod resolves labels. - assert_eq!( - model.mor_generator_dom_cod_label_strings(&recover_id.into()), - Some(("I".to_string(), "R".to_string())) - ); - - // Morphism with product-typed dom/cod resolves to bracketed labels. - assert_eq!( - model.mor_generator_dom_cod_label_strings(&infect_id.into()), - Some(("[S, I]".to_string(), "[I, I]".to_string())) - ); - } } diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 65619d29d..ede184e34 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -152,7 +152,9 @@ export function linearODEEquations( name, description, help, - component: (props) => , + component: (props) => ( + + ), initialContent: () => ({ trivialData: true, }), diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index 02c8abf34..335b5e7ed 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -24,7 +24,10 @@ export type { }; export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; -export type LinearODESimulator = (model: DblModel, data: LinearODEProblemData) => ODEResultWithEquations; +export type LinearODESimulator = ( + model: DblModel, + data: LinearODEProblemData, +) => ODEResultWithEquations; export type LinearODEEquations = (model: DblModel, data: LinearODEEquationsData) => LatexEquations; export type LotkaVolterraSimulator = ( model: DblModel, From dfd4e396dfeb30da5fce8ca8c689083682a74a22 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 18:08:37 +0100 Subject: [PATCH 28/38] WIP: Fix frontend --- package.json | 3 ++- .../src/stdlib/analyses/ode/mass_action.rs | 1 + packages/frontend/src/stdlib/analyses.tsx | 23 ++++++------------- .../stdlib/analyses/linear_ode_equations.tsx | 7 +++--- .../analyses/lotka_volterra_equations.tsx | 7 +++--- .../src/stdlib/analyses/mass_action.tsx | 12 +++++----- .../analyses/mass_action_config_form.tsx | 4 ++-- .../analyses/polynomial_ode_equations.tsx | 7 +++--- .../src/stdlib/analyses/simulator_types.ts | 15 +++--------- .../src/stdlib/theories/polynomial-ode.ts | 4 ++-- .../stdlib/theories/signed-polynomial-ode.ts | 4 ++-- 11 files changed, 34 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 0ae3ca7e7..3e0ba2625 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build:deps": "pnpm --filter ./packages/frontend run build:deps", - "dev": "pnpm --filter ./packages/frontend run dev" + "dev": "pnpm --filter ./packages/frontend run dev", + "check": "cargo +nightly fmt && cargo clippy && pnpm --filter ./packages/frontend run check" }, "engines": { "node": "^24.4.0" diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 3089ff6ac..91131e5c2 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -461,6 +461,7 @@ impl ODESemanticsEquationsData for MassActionEquationsData {} )] pub struct MassActionProblemData { /// Data used for generating the equations (namely, whether or not mass is conserved). + #[cfg_attr(feature = "serde", serde(rename = "equationsData"))] pub equations_data: MassActionEquationsData, /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index ede184e34..779345810 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,12 +1,9 @@ import { lazy } from "solid-js"; import type { - LinearODEEquationsData, - LotkaVolterraEquationsData, MassActionEquationsData, MorType, ObType, - PolynomialODEEquationsData, StochasticMassActionProblemData, } from "catlog-wasm"; import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; @@ -139,7 +136,7 @@ export function linearODEEquations( options: Partial & { getEquations: Simulators.LinearODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "linear-ode-equations", name = "Linear ODE equations", @@ -155,9 +152,7 @@ export function linearODEEquations( component: (props) => ( ), - initialContent: () => ({ - trivialData: true, - }), + initialContent: () => null, }; } const LinearODEEquationsDisplay = lazy(() => import("./analyses/linear_ode_equations")); @@ -195,7 +190,7 @@ export function lotkaVolterraEquations( options: Partial & { getEquations: Simulators.LotkaVolterraEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "lotka-volterra-equations", name = "Lotka–Volterra equations", @@ -211,9 +206,7 @@ export function lotkaVolterraEquations( component: (props) => ( ), - initialContent: () => ({ - trivialData: true, - }), + initialContent: () => null, }; } const LotkaVolterraEquationsDisplay = lazy(() => import("./analyses/lotka_volterra_equations")); @@ -240,7 +233,7 @@ export function massAction( help, component: (props) => , initialContent: () => ({ - massConservationType: { type: "Balanced" }, + equationsData: { massConservationType: { type: "Balanced" } }, rates: {}, transitionProductionRates: {}, transitionConsumptionRates: {}, @@ -428,7 +421,7 @@ export function polynomialODEEquations( options: Partial & { getEquations: Simulators.PolynomialODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "polynomial-ode-equations", name = "Polynomial ODE equations", @@ -444,9 +437,7 @@ export function polynomialODEEquations( component: (props) => ( ), - initialContent: () => ({ - trivialData: true, - }), + initialContent: () => null, }; } const PolynomialODEEquationsDisplay = lazy(() => import("./analyses/polynomial_ode_equations")); diff --git a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx index d0e65d2db..727b48a3f 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx @@ -1,5 +1,4 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import { LinearODEEquationsData } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { createModelODELatex } from "./model_ode_plot"; import type { LinearODEEquations } from "./simulator_types"; @@ -8,15 +7,15 @@ import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function LinearODEEquationsDisplay( - props: ModelAnalysisProps & { - content: LinearODEEquationsData; + props: ModelAnalysisProps & { + content: null; getEquations: LinearODEEquations; title?: string; }, ) { const latexEquations = createModelODELatex( () => props.liveModel.validatedModel(), - (model) => props.getEquations(model, props.content), + (model) => props.getEquations(model), ); return ( diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx index dcb6271d4..c44286108 100644 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx @@ -1,5 +1,4 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import { LotkaVolterraEquationsData } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { createModelODELatex } from "./model_ode_plot"; import type { LotkaVolterraEquations } from "./simulator_types"; @@ -8,15 +7,15 @@ import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function LotkaVolterraEquationsDisplay( - props: ModelAnalysisProps & { - content: LotkaVolterraEquationsData; + props: ModelAnalysisProps & { + content: null; getEquations: LotkaVolterraEquations; title?: string; }, ) { const latexEquations = createModelODELatex( () => props.liveModel.validatedModel(), - (model) => props.getEquations(model, props.content), + (model) => props.getEquations(model), ); return ( diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 6cfa1fe43..270020c70 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -253,13 +253,13 @@ export default function MassAction( // Now we can generate the parameter tables that will actually be rendered. const ParameterTables = () => ( - + @@ -267,8 +267,8 @@ export default function MassAction( @@ -304,7 +304,7 @@ export default function MassAction( title={props.title} settingsPane={ diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index 6c0f5e282..237ec3eec 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -1,10 +1,10 @@ import { Show } from "solid-js"; import { CheckboxField, FormGroup, SelectField } from "catcolab-ui-components"; -import type { MassActionEquationsData, MassActionProblemData, RateGranularity } from "catlog-wasm"; +import type { MassActionEquationsData, RateGranularity } from "catlog-wasm"; /** Configuration of a mass-action analysis. */ -export type Config = MassActionProblemData | MassActionEquationsData; +export type Config = MassActionEquationsData; /** Form to configure a mass-action analysis. */ export function MassActionConfigForm(props: { diff --git a/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx index 33486ff9e..735498f61 100644 --- a/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx @@ -1,5 +1,4 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import { PolynomialODEEquationsData } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { createModelODELatex } from "./model_ode_plot"; import type { PolynomialODEEquations } from "./simulator_types"; @@ -8,15 +7,15 @@ import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function PolynomialODEEquationsDisplay( - props: ModelAnalysisProps & { - content: PolynomialODEEquationsData; + props: ModelAnalysisProps & { + content: null; getEquations: PolynomialODEEquations; title?: string; }, ) { const latexEquations = createModelODELatex( () => props.liveModel.validatedModel(), - (model) => props.getEquations(model, props.content), + (model) => props.getEquations(model), ); return ( diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index 335b5e7ed..cdd3c0f35 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -3,14 +3,11 @@ import type { KuramotoProblemData, LatexEquations, LinearODEProblemData, - LinearODEEquationsData, LotkaVolterraProblemData, - LotkaVolterraEquationsData, MassActionEquationsData, MassActionProblemData, ODEResult, ODEResultWithEquations, - PolynomialODEEquationsData, PolynomialODEProblemData, StochasticMassActionProblemData, } from "catlog-wasm"; @@ -28,15 +25,12 @@ export type LinearODESimulator = ( model: DblModel, data: LinearODEProblemData, ) => ODEResultWithEquations; -export type LinearODEEquations = (model: DblModel, data: LinearODEEquationsData) => LatexEquations; +export type LinearODEEquations = (model: DblModel) => LatexEquations; export type LotkaVolterraSimulator = ( model: DblModel, data: LotkaVolterraProblemData, ) => ODEResultWithEquations; -export type LotkaVolterraEquations = ( - model: DblModel, - data: LotkaVolterraEquationsData, -) => LatexEquations; +export type LotkaVolterraEquations = (model: DblModel) => LatexEquations; export type MassActionSimulator = ( model: DblModel, data: MassActionProblemData, @@ -53,10 +47,7 @@ export type PolynomialODESimulator = ( model: DblModel, data: PolynomialODEProblemData, ) => ODEResultWithEquations; -export type PolynomialODEEquations = ( - model: DblModel, - data: PolynomialODEEquationsData, -) => LatexEquations; +export type PolynomialODEEquations = (model: DblModel) => LatexEquations; /** Configuration for a Decapodes analysis of a diagram. */ export type DecapodesAnalysisContent = { diff --git a/packages/frontend/src/stdlib/theories/polynomial-ode.ts b/packages/frontend/src/stdlib/theories/polynomial-ode.ts index be3e3b03b..30b7b0995 100644 --- a/packages/frontend/src/stdlib/theories/polynomial-ode.ts +++ b/packages/frontend/src/stdlib/theories/polynomial-ode.ts @@ -34,8 +34,8 @@ export default function createPolynomialODETheory(theoryMeta: TheoryMeta): Theor ], modelAnalyses: [ analyses.polynomialODEEquations({ - getEquations(model, data) { - return thPolynomialODE.polynomialODEEquations(model, data); + getEquations(model) { + return thPolynomialODE.polynomialODEEquations(model); }, }), analyses.polynomialODESimulation({ diff --git a/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts b/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts index 63fb66988..45c02d810 100644 --- a/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts +++ b/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts @@ -51,8 +51,8 @@ export default function createSignedPolynomialODETheory(theoryMeta: TheoryMeta): ], modelAnalyses: [ analyses.polynomialODEEquations({ - getEquations(model, data) { - return thSignedPolynomialODE.polynomialODEEquations(model, data); + getEquations(model) { + return thSignedPolynomialODE.polynomialODEEquations(model); }, }), analyses.polynomialODESimulation({ From f316617265d4322826a665d522b93fc968706f62 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 18:23:26 +0100 Subject: [PATCH 29/38] FIX: Fix (??) frontend --- packages/frontend/src/stdlib/analyses/mass_action.tsx | 4 +++- .../frontend/src/stdlib/analyses/mass_action_config_form.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 270020c70..a518c03ef 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -305,7 +305,9 @@ export default function MassAction( settingsPane={ { + change(props.content.equationsData); + }} enableGranularity={props.ratesHaveGranularity} /> } diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index 237ec3eec..9f30fab94 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -4,7 +4,7 @@ import { CheckboxField, FormGroup, SelectField } from "catcolab-ui-components"; import type { MassActionEquationsData, RateGranularity } from "catlog-wasm"; /** Configuration of a mass-action analysis. */ -export type Config = MassActionEquationsData; +export type Config = MassActionEquationsData; /** Form to configure a mass-action analysis. */ export function MassActionConfigForm(props: { From 0124d43e0838afaa84390313c7926f5b87ec09ec Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 18:24:48 +0100 Subject: [PATCH 30/38] WIP: Here's the problem --- packages/frontend/src/stdlib/analyses/mass_action.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index a518c03ef..dbc27cf28 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -305,6 +305,7 @@ export default function MassAction( settingsPane={ { change(props.content.equationsData); }} From 891fdaeffcdd5a60d5e07d17bddff39f63229aa1 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 15:56:57 +0100 Subject: [PATCH 31/38] FIX: Working reusable mass-action config form --- .../src/stdlib/analyses/mass_action.tsx | 7 +--- .../analyses/mass_action_config_form.tsx | 41 +++++++++++++++---- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index dbc27cf28..107a5f4bb 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -304,11 +304,8 @@ export default function MassAction( title={props.title} settingsPane={ { - change(props.content.equationsData); - }} + config={props.content} + changeConfig={props.changeContent} enableGranularity={props.ratesHaveGranularity} /> } diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index 9f30fab94..333b0d4d2 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -1,10 +1,14 @@ import { Show } from "solid-js"; import { CheckboxField, FormGroup, SelectField } from "catcolab-ui-components"; -import type { MassActionEquationsData, RateGranularity } from "catlog-wasm"; +import type { MassActionEquationsData, MassActionProblemData, RateGranularity } from "catlog-wasm"; /** Configuration of a mass-action analysis. */ -export type Config = MassActionEquationsData; +export type Config = MassActionEquationsData | MassActionProblemData; + +function isMassActionProblemData (config: Config): config is MassActionProblemData { + return (config as MassActionProblemData).equationsData !== undefined +} /** Form to configure a mass-action analysis. */ export function MassActionConfigForm(props: { @@ -12,10 +16,17 @@ export function MassActionConfigForm(props: { changeConfig: (f: (config: Config) => void) => void; enableGranularity: boolean; }) { - const massConservation = () => props.config.massConservationType; + let correctConfig: MassActionEquationsData; + if (isMassActionProblemData(props.config)) { + correctConfig = props.config.equationsData; + } else { + correctConfig = props.config; + } + + const massConservation = () => correctConfig.massConservationType; const massConservationGranularity = () => - props.config.massConservationType.type === "Unbalanced" - ? props.config.massConservationType.granularity + correctConfig.massConservationType.type === "Unbalanced" + ? correctConfig.massConservationType.granularity : undefined; return ( @@ -25,12 +36,18 @@ export function MassActionConfigForm(props: { checked={massConservation().type === "Balanced"} onChange={(evt) => { props.changeConfig((content) => { + let correctConfig: MassActionEquationsData; + if (isMassActionProblemData(content)) { + correctConfig = content.equationsData; + } else { + correctConfig = content; + } if (evt.currentTarget.checked) { - content.massConservationType = { + correctConfig.massConservationType = { type: "Balanced", }; } else { - content.massConservationType = { + correctConfig.massConservationType = { type: "Unbalanced", granularity: "PerPlace", }; @@ -44,8 +61,14 @@ export function MassActionConfigForm(props: { value={massConservationGranularity() ?? "PerPlace"} onChange={(evt) => { props.changeConfig((content) => { - if (content.massConservationType.type === "Unbalanced") { - content.massConservationType.granularity = evt.currentTarget + let correctConfig: MassActionEquationsData; + if (isMassActionProblemData(content)) { + correctConfig = content.equationsData; + } else { + correctConfig = content; + } + if (correctConfig.massConservationType.type === "Unbalanced") { + correctConfig.massConservationType.granularity = evt.currentTarget .value as RateGranularity; } }); From 4cdaa3da7385c9e8f406b429bd209c624a407cbe Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 17:43:42 +0100 Subject: [PATCH 32/38] ENH: Add (failing) test for polynomial ODE; simplify some types --- packages/catlog-wasm/src/analyses.rs | 111 +++++++++++++++++- packages/catlog-wasm/src/latex.rs | 26 +--- packages/catlog-wasm/src/model.rs | 2 +- packages/catlog/src/latex.rs | 21 ++++ .../src/stdlib/analyses/ode/linear_ode.rs | 23 ++-- .../src/stdlib/analyses/ode/lotka_volterra.rs | 22 ++-- .../src/stdlib/analyses/ode/mass_action.rs | 10 +- .../src/stdlib/analyses/ode/polynomial_ode.rs | 95 +++++++++++---- .../analyses/mass_action_config_form.tsx | 4 +- 9 files changed, 229 insertions(+), 85 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index ae4986dec..2fa7738a2 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -60,7 +60,7 @@ mod tests { use crate::model::{DblModel, tests::backward_link}; use crate::theories::ThSignedCategory; use catcolab_document_types::v2::{MorDecl, MorType, Ob, ObDecl, ObType}; - use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType}; + use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType, ModeApp}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; use catlog::stdlib::{ @@ -71,7 +71,36 @@ mod tests { use std::rc::Rc; use uuid::Uuid; - // TODO: test for polynomial_ode_simulation + #[test] + fn signed_polynomial_ode_latex_equations() { + // The signed multicategory with objects `x`, `y`, and `zonk`, (unnamed) positive morphisms + // `[x,y] -+-> z` and `q : z -+-> y`, and a negative morphism `negative : [x,x,y,z] ---> x`. + let model = example_signed_multicategory("x", "y", "zonk", "", "", "negative"); + let system = + ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), + rhs: Latex( + "-\\lambda_{\\text{negative}} \\cdot x^2 \\cdot y \\cdot \\text{zonk}" + .to_string(), + ), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("\\lambda_{\\text{zonk} \\to y} \\cdot \\text{zonk}".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{zonk}".to_string()), + rhs: Latex("\\lambda_{[x,y] \\to \\text{zonk}} \\cdot x \\cdot y".to_string()), + }, + ]); + + assert_eq!(equations, expected); + } #[test] fn cld_lotka_volterra_latex_equations() { @@ -174,7 +203,7 @@ mod tests { #[test] fn petri_net_balanced_mass_action_latex_equations() { - // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + // The Petri net with places `liquid`, `solid`, and `c`, and one (unnamed) transition `[liquid, c] -> [solid, c]`. let model = catalytic_petri_net("liquid", "solid", "c", ""); let system = ode::PetriNetMassActionAnalysis { mass_conservation_type: MassConservationType::Balanced, @@ -324,6 +353,82 @@ mod tests { model } + /// Construct a signed multicategory with objects `x, y, z`, positive morphisms `p : [x,y] -+-> z` + /// and `q : z -+-> y`, and negative morphism `n : [x,x,y,z] ---> x`. + fn example_signed_multicategory( + x_name: &str, + y_name: &str, + z_name: &str, + p_name: &str, + q_name: &str, + n_name: &str, + ) -> DblModel { + let th = Rc::new(theories::th_signed_polynomial_ode_system()); + let ob_type = ModalObType::new(("State").into()); + let pos_mor_type: ModalMorType = ModeApp::new(("Contribution").into()).into(); + let neg_mor_type: ModalMorType = ModeApp::new(("NegativeContribution").into()).into(); + + let mut inner = ModalDblModel::new(th); + + let [x, y, z, p, q, n] = [ + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ]; + + inner.add_ob(x.into(), ob_type.clone()); + inner.add_ob(y.into(), ob_type.clone()); + inner.add_ob(z.into(), ob_type.clone()); + + inner.add_mor( + p_name.into(), + ModalOb::List( + List::Symmetric, + vec![ModalOb::Generator(x.into()), ModalOb::Generator(y.into())], + ), + ModalOb::Generator(z.into()), + pos_mor_type.clone(), + ); + inner.add_mor( + q_name.into(), + ModalOb::List(List::Symmetric, vec![ModalOb::Generator(z.into())]), + ModalOb::Generator(y.into()), + pos_mor_type.clone(), + ); + inner.add_mor( + n_name.into(), + ModalOb::List( + List::Symmetric, + vec![ + ModalOb::Generator(x.into()), + ModalOb::Generator(x.into()), + ModalOb::Generator(y.into()), + ModalOb::Generator(z.into()), + ], + ), + ModalOb::Generator(x.into()), + neg_mor_type.clone(), + ); + + let mut ob_namespace = Namespace::new_for_uuid(); + ob_namespace.set_label(x, LabelSegment::Text(x_name.into())); + ob_namespace.set_label(y, LabelSegment::Text(y_name.into())); + ob_namespace.set_label(z, LabelSegment::Text(z_name.into())); + ob_namespace.set_label(p, LabelSegment::Text(p_name.into())); + ob_namespace.set_label(q, LabelSegment::Text(q_name.into())); + ob_namespace.set_label(n, LabelSegment::Text(n_name.into())); + + DblModel { + model: inner.into(), + ty: None, + ob_namespace, + mor_namespace: Namespace::new_for_uuid(), + } + } + /// Construct a Petri net representing a catalytic transition [x,c] -> [y,c]. fn catalytic_petri_net( source_name: &str, diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 464fd5545..cb7db4b80 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -1,30 +1,12 @@ //! Auxiliary structs and glue code for any LaTeX code being passed through analyses. -use catlog::zero::QualifiedName; +use catlog::{ + latex::{list_object_as_latex, wrap_with_backslash_text}, + zero::QualifiedName, +}; use super::model::DblModel; -/// Wrap a string with a Latex text literal if it is longer than a single character. -/// -/// Note that this is not a perfect solution, and is built on a lot of assumptions. Ideally, the -/// frontend should allow users to mark names as Latex or not. -fn wrap_with_backslash_text(name: String) -> String { - if name.chars().count() > 1 { - format!("\\text{{{name}}}") - } else { - name.to_string() - } -} - -/// Display a single-object list [x] directly as "x", but display any longer list as "[x, y ,z]". -fn list_object_as_latex(vec: Vec) -> String { - if vec.len() > 1 { - format!("[{}]", vec.join(", ")) - } else { - vec[0].to_string() - } -} - /// Creates a closure that formats object and morphism names for LaTeX output. When a morphism has a /// name (and thus label), it is used directly; when unnamed, the label falls back to the format /// `domain→codomain` (e.g., `X \to Y`). diff --git a/packages/catlog-wasm/src/model.rs b/packages/catlog-wasm/src/model.rs index b2fb94b02..a56120d32 100644 --- a/packages/catlog-wasm/src/model.rs +++ b/packages/catlog-wasm/src/model.rs @@ -442,7 +442,7 @@ impl DblModel { /// Gets the list of labels for an object. /// - /// This works for both basic objects and list objects (e.g. "[x,y]" in a Petri net). + /// This works for both basic objects and list objects (e.g. `[x,y]` in a Petri net). pub fn get_ob_label(&self, ob: &Ob) -> Option> { match ob { Ob::Basic(s) => { diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 0f6278dc8..42e4b25b4 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -85,3 +85,24 @@ impl ToLatexWithMap for T { Latex(self.to_string()) } } + +/// Wrap a string with a Latex text literal if it is longer than a single character. +// FIXME: This is built on the assumption that any single letter should be rendered as a variable +// name, and any longer name should be a text literal. A more correct solution should allow +// us to write e.g. `$\pi_1$` as a name directly. +pub fn wrap_with_backslash_text(name: String) -> String { + if name.chars().count() > 1 { + format!("\\text{{{name}}}") + } else { + name.to_string() + } +} + +/// Display a single-object list [x] directly as `x`, but display any longer list as `[x, y ,z]`. +pub fn list_object_as_latex(vec: Vec) -> String { + if vec.len() > 1 { + format!("[{}]", vec.join(", ")) + } else { + vec[0].to_string() + } +} diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs index 1d9d3e73e..02b06d839 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -98,8 +98,8 @@ impl /// A system of ODEs for building arbitrary LinearODE ODEs from CLDs. fn build_system_builder( &self, - model: &::ModelType, - ) -> PolynomialODESystemBuilder<::ParameterType> { + model: &DiscreteDblModel, + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); for var in model.ob_generators_with_type(&self.var_ob_type) { @@ -180,11 +180,7 @@ impl ODESemanticsProblemData<::ParameterType fn extend_scalars( &self, - sys: PolynomialSystem< - QualifiedName, - Parameter<::ParameterType>, - i8, - >, + sys: PolynomialSystem, i8>, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { poly.eval(|param| match param { @@ -206,7 +202,7 @@ mod test { use super::*; use crate::{ dbl::model::MutDblModel, - latex::{LatexEquation, LatexEquations}, + latex::{LatexEquation, LatexEquations, wrap_with_backslash_text}, stdlib::{models::*, theories::*}, }; @@ -254,19 +250,20 @@ mod test { fn to_latex() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let sys = LinearODEAnalysis::default().build_system(&model); - // .extend_scalars(|param| param.map_variables(to_latex)) + let system = LinearODEAnalysis::default().build_system(&model); + let equations = + system.to_latex_equations_with_map(|name| wrap_with_backslash_text(name.to_string())); let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex("-\\lambda_{negative} \\cdot y".to_string()), + rhs: Latex("-\\lambda_{\\text{negative}} \\cdot y".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex("\\lambda_{positive} \\cdot x".to_string()), + rhs: Latex("\\lambda_{\\text{positive}} \\cdot x".to_string()), }, ]); - assert_eq!(expected, sys.to_latex_equations()); + assert_eq!(expected, equations); } // Numerical test. diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 2157ddb70..9b2057205 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -111,8 +111,8 @@ impl /// and [our paper on regulatory networks](crate::refs::RegNets). fn build_system_builder( &self, - model: &::ModelType, - ) -> PolynomialODESystemBuilder<::ParameterType> { + model: &DiscreteDblModel, + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); for var in model.ob_generators_with_type(&self.var_ob_type) { @@ -209,11 +209,7 @@ impl ODESemanticsProblemData<::Parameter fn extend_scalars( &self, - sys: PolynomialSystem< - QualifiedName, - Parameter<::ParameterType>, - i8, - >, + sys: PolynomialSystem, i8>, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { poly.eval(|param| match param { @@ -238,7 +234,7 @@ mod test { use super::*; use crate::{ dbl::model::MutDblModel, - latex::{LatexEquation, LatexEquations}, + latex::{LatexEquation, LatexEquations, wrap_with_backslash_text}, stdlib::{models::*, theories::*}, }; @@ -286,18 +282,20 @@ mod test { fn to_latex() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let sys = LotkaVolterraAnalysis::default().build_system(&model); + let system = LotkaVolterraAnalysis::default().build_system(&model); + let equations = + system.to_latex_equations_with_map(|name| wrap_with_backslash_text(name.to_string())); let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), - rhs: Latex("g_{x} \\cdot x - k_{negative} \\cdot x \\cdot y".to_string()), + rhs: Latex("g_{x} \\cdot x - k_{\\text{negative}} \\cdot x \\cdot y".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex("k_{positive} \\cdot x \\cdot y + g_{y} \\cdot y".to_string()), + rhs: Latex("k_{\\text{positive}} \\cdot x \\cdot y + g_{y} \\cdot y".to_string()), }, ]); - assert_eq!(expected, sys.to_latex_equations()); + assert_eq!(expected, equations); } // Numerical test. diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 91131e5c2..c70eed04e 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -221,9 +221,8 @@ impl { fn build_system_builder( &self, - model: &::ModelType, - ) -> PolynomialODESystemBuilder<::ParameterType> - { + model: &ModalDblModel, + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); for place in model.ob_generators_with_type(&self.place_ob_type) { @@ -358,9 +357,8 @@ impl { fn build_system_builder( &self, - model: &::ModelType, - ) -> PolynomialODESystemBuilder<::ParameterType> - { + model: &DiscreteTabModel, + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); for stock in model.ob_generators_with_type(&self.stock_ob_type) { diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index 901e88d92..8f764f01d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -25,9 +25,10 @@ use crate::{ model::{FpDblModel, ModalDblModel, ModalOb, MutDblModel}, theory::NonUnital, }, + latex::{Latex, ToLatexWithMap}, simulate::ode::PolynomialSystem, stdlib::analyses::ode::{ - ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, Parameter, + ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsProblemData, Parameter, PolynomialODESystemBuilder, }, zero::{QualifiedName, alg::Polynomial, name, rig::Monomial}, @@ -38,12 +39,38 @@ pub struct PolynomialODESemantics; impl ODESemantics for PolynomialODESemantics { type ModelType = ModalDblModel; - type ParameterType = QualifiedName; + type ParameterType = PolynomialODEParameter; type AnalysisType = PolynomialODEAnalysis; type EquationsDataType = (); type ProblemDataType = PolynomialODEProblemData; } +/// Parameters come precisely from contributions. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum PolynomialODEParameter { + /// The parameter associated to a contribution. + Coefficient { + /// The contribution. + contribution: QualifiedName, + }, +} + +impl fmt::Display for PolynomialODEParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self::Coefficient { contribution } = &self; + write!(f, "Coefficient({})", contribution) + } +} + +impl ToLatexWithMap for PolynomialODEParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { + let Self::Coefficient { contribution } = self; + Latex(format!("\\lambda_{{{}}}", f(contribution))) + } +} + +impl ODEParameterType for PolynomialODEParameter {} + /// Polynomial ODE analysis. /// /// The "canonical" analysis for system of polynomial ODEs, namely interpreting @@ -80,31 +107,41 @@ impl { fn build_system_builder( &self, - model: &::ModelType, - ) -> PolynomialODESystemBuilder<::ParameterType> { + model: &ModalDblModel, + ) -> PolynomialODESystemBuilder { PolynomialODESystemBuilder::identity(model.clone()) } } impl PolynomialODEAnalysis { - /// Creates a `PolynomialSystem` with symbolic coefficients of type `QualifiedName`. + /// Creates a `PolynomialSystem` with symbolic coefficients of type `PolynomialODEParameter`. pub fn build_system( &self, model: &ModalDblModel, - ) -> PolynomialSystem, i8> { + ) -> PolynomialSystem< + QualifiedName, + Parameter<::ParameterType>, + i8, + > { // The default is to build a system whose parameters are in bijective correspondence // with morphisms, given by using the `QualifiedName` of the morphism as the parameter - // generator. We thus build the graph of the identity function to pass as the HashMap - // of associated parameters. - let mut associated_parameters: HashMap = HashMap::new(); + // generator. + let mut associated_parameters: HashMap = + HashMap::new(); for mor in model.mor_generators_with_type(&self.positive_contribution_mor_type) { - associated_parameters.insert(mor.clone(), mor.clone()); + associated_parameters.insert( + mor.clone(), + PolynomialODEParameter::Coefficient { contribution: mor.clone() }, + ); } for mor in model.mor_generators_with_type(&self.negative_contribution_mor_type) { - associated_parameters.insert(mor.clone(), mor.clone()); + associated_parameters.insert( + mor.clone(), + PolynomialODEParameter::Coefficient { contribution: mor.clone() }, + ); } - self.build_system_custom_parameters::(model, associated_parameters) + self.build_system_custom_parameters::(model, associated_parameters) } /// Creates a `PolynomialSystem` with symbolic coefficients of some generic type. @@ -210,7 +247,11 @@ impl ODESemanticsProblemData<::Parameter >, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { - poly.eval(|mor| self.coefficients.get(mor).cloned().unwrap_or_default()) + poly.eval(|mor| match mor { + PolynomialODEParameter::Coefficient { contribution } => { + self.coefficients.get(contribution).cloned().unwrap_or_default() + } + }) }); sys.normalize() @@ -224,42 +265,44 @@ mod tests { use super::*; use crate::{ - latex::{Latex, LatexEquation, LatexEquations}, + latex::{Latex, LatexEquation, LatexEquations, wrap_with_backslash_text}, stdlib::{models::*, theories::*}, tt, }; /// (Unsigned) Lotka-Volterra dynamics on a two-level model. #[test] - fn unsigned_lotka_volterra_equations() { + fn polynomial_ode_unsigned_lotka_volterra_equations() { let th = Rc::new(th_polynomial_ode_system()); let model = unsigned_lotka_volterra_dynamics(th); let sys = PolynomialODEAnalysis::default().build_system(&model); let expected = expect!([r#" - dA = A_growth A + BA_interaction A B - dB = AB_interaction A B + B_growth B + CB_interaction B C - dC = BC_interaction B C + C_growth C + dA = Coefficient(A_growth) A + Coefficient(BA_interaction) A B + dB = Coefficient(AB_interaction) A B + Coefficient(B_growth) B + Coefficient(CB_interaction) B C + dC = Coefficient(BC_interaction) B C + Coefficient(C_growth) C "#]); expected.assert_eq(&sys.to_string()); } /// Lotka-Volterra dynamics on a two-level model with LaTeX. #[test] - fn lotka_volterra_equations_latex() { + fn polynomial_ode_lotka_volterra_equations_latex() { let th = Rc::new(th_signed_polynomial_ode_system()); let model = signed_lotka_volterra_dynamics(th); - let sys = PolynomialODEAnalysis::default().build_system(&model); + let system = PolynomialODEAnalysis::default().build_system(&model); + let equations = + system.to_latex_equations_with_map(|name| wrap_with_backslash_text(name.to_string())); let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} A".to_string()), - rhs: Latex("A_growth \\cdot A - BA_interaction \\cdot A \\cdot B".to_string()), + rhs: Latex("\\lambda_{\\text{A_growth}} \\cdot A - \\lambda_{\\text{BA_interaction}} \\cdot A \\cdot B".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} B".to_string()), - rhs: Latex("AB_interaction \\cdot A \\cdot B + B_growth \\cdot B".to_string()), + rhs: Latex("\\lambda_{\\text{AB_interaction}} \\cdot A \\cdot B + \\lambda_{\\text{B_growth}} \\cdot B".to_string()), }, ]); - assert_eq!(expected, sys.to_latex_equations()); + assert_eq!(expected, equations); } /// DoubleTT elaboration from text. @@ -280,9 +323,9 @@ mod tests { let model = model.unwrap().as_modal_non_unital().unwrap(); let sys = PolynomialODEAnalysis::default().build_system(&model); let expected = expect!([r#" - dX = h A - dY = g X^2 - dA = f X Y^2 + dX = Coefficient(h) A + dY = Coefficient(g) X^2 + dA = Coefficient(f) X Y^2 "#]); expected.assert_eq(&sys.to_string()); } diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index 333b0d4d2..761b9cec3 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -6,8 +6,8 @@ import type { MassActionEquationsData, MassActionProblemData, RateGranularity } /** Configuration of a mass-action analysis. */ export type Config = MassActionEquationsData | MassActionProblemData; -function isMassActionProblemData (config: Config): config is MassActionProblemData { - return (config as MassActionProblemData).equationsData !== undefined +function isMassActionProblemData(config: Config): config is MassActionProblemData { + return (config as MassActionProblemData).equationsData !== undefined; } /** Form to configure a mass-action analysis. */ From c9aceffb8e75475aac2c00e6b27487af2b12c20f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 18:18:00 +0100 Subject: [PATCH 33/38] WIP: Namespace problems --- packages/catlog-wasm/src/analyses.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 2fa7738a2..ae5c1818c 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -75,7 +75,7 @@ mod tests { fn signed_polynomial_ode_latex_equations() { // The signed multicategory with objects `x`, `y`, and `zonk`, (unnamed) positive morphisms // `[x,y] -+-> z` and `q : z -+-> y`, and a negative morphism `negative : [x,x,y,z] ---> x`. - let model = example_signed_multicategory("x", "y", "zonk", "", "", "negative"); + let model = example_signed_multicategory("x", "y", "zonk", "P", "", "negative"); let system = ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital().unwrap()); let equations = @@ -238,7 +238,8 @@ mod tests { #[test] fn petri_net_unbalanced_pt_mass_action_latex_equations() { - // The Petri net with places "liquid", "solid", and "c", and one (unnamed) transition [liquid, c] -> [solid, c]. + // The Petri net with places "liquid", "solid", and "c", and one transition + // `transition : [liquid, c] -> [solid, c]`. let model = catalytic_petri_net("liquid", "solid", "c", ""); let system = ode::PetriNetMassActionAnalysis { mass_conservation_type: MassConservationType::Unbalanced( @@ -253,15 +254,15 @@ mod tests { let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".to_string()), - rhs: Latex("-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\cdot \\text{liquid} \\cdot c".to_string()), + rhs: Latex("-\\kappa_{\\text{transition}} \\cdot \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{solid}".to_string()), - rhs: Latex("\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} \\cdot \\text{liquid} \\cdot c".to_string()), + rhs: Latex("\\rho_{\\text{transition}} \\cdot \\text{liquid} \\cdot c".to_string()), }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("(\\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), + rhs: Latex("(\\rho_{\\text{transition}} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); @@ -417,15 +418,17 @@ mod tests { ob_namespace.set_label(x, LabelSegment::Text(x_name.into())); ob_namespace.set_label(y, LabelSegment::Text(y_name.into())); ob_namespace.set_label(z, LabelSegment::Text(z_name.into())); - ob_namespace.set_label(p, LabelSegment::Text(p_name.into())); - ob_namespace.set_label(q, LabelSegment::Text(q_name.into())); - ob_namespace.set_label(n, LabelSegment::Text(n_name.into())); + + let mut mor_namespace = Namespace::new_for_uuid(); + mor_namespace.set_label(p, LabelSegment::Text(p_name.into())); + mor_namespace.set_label(q, LabelSegment::Text(q_name.into())); + mor_namespace.set_label(n, LabelSegment::Text(n_name.into())); DblModel { model: inner.into(), ty: None, ob_namespace, - mor_namespace: Namespace::new_for_uuid(), + mor_namespace, } } @@ -472,13 +475,15 @@ mod tests { ob_namespace.set_label(x, LabelSegment::Text(source_name.into())); ob_namespace.set_label(y, LabelSegment::Text(target_name.into())); ob_namespace.set_label(c, LabelSegment::Text(catalyst_name.into())); - ob_namespace.set_label(t, LabelSegment::Text(transition_name.into())); + + let mut mor_namespace = Namespace::new_for_uuid(); + mor_namespace.set_label(t, LabelSegment::Text(transition_name.into())); DblModel { model: inner.into(), ty: None, ob_namespace, - mor_namespace: Namespace::new_for_uuid(), + mor_namespace, } } } From 5a28cb5b861177b1c04a75896b4ee5ea785ba011 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 19:16:17 +0100 Subject: [PATCH 34/38] FIX: Passing all Rust tests --- packages/catlog-wasm/src/analyses.rs | 26 ++++++++++++++------------ packages/catlog/src/latex.rs | 20 +++++++++++++++++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index ae5c1818c..19877f51b 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -73,9 +73,9 @@ mod tests { #[test] fn signed_polynomial_ode_latex_equations() { - // The signed multicategory with objects `x`, `y`, and `zonk`, (unnamed) positive morphisms + // The signed multicategory with objects `x`, `yum`, and `z`, (unnamed) positive morphisms // `[x,y] -+-> z` and `q : z -+-> y`, and a negative morphism `negative : [x,x,y,z] ---> x`. - let model = example_signed_multicategory("x", "y", "zonk", "P", "", "negative"); + let model = example_signed_multicategory("x", "yum", "z", "", "", "negative"); let system = ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital().unwrap()); let equations = @@ -85,17 +85,19 @@ mod tests { LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), rhs: Latex( - "-\\lambda_{\\text{negative}} \\cdot x^2 \\cdot y \\cdot \\text{zonk}" + "-\\lambda_{\\text{negative}} \\cdot x^2 \\cdot \\text{yum} \\cdot z" .to_string(), ), }, LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), - rhs: Latex("\\lambda_{\\text{zonk} \\to y} \\cdot \\text{zonk}".to_string()), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yum}".to_string()), + rhs: Latex("\\lambda_{z \\to \\text{yum}} \\cdot z".to_string()), }, LatexEquation { - lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{zonk}".to_string()), - rhs: Latex("\\lambda_{[x,y] \\to \\text{zonk}} \\cdot x \\cdot y".to_string()), + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} z".to_string()), + rhs: Latex( + "\\lambda_{[x, \\text{yum}] \\to z} \\cdot x \\cdot \\text{yum}".to_string(), + ), }, ]); @@ -240,7 +242,7 @@ mod tests { fn petri_net_unbalanced_pt_mass_action_latex_equations() { // The Petri net with places "liquid", "solid", and "c", and one transition // `transition : [liquid, c] -> [solid, c]`. - let model = catalytic_petri_net("liquid", "solid", "c", ""); + let model = catalytic_petri_net("liquid", "solid", "c", "transition"); let system = ode::PetriNetMassActionAnalysis { mass_conservation_type: MassConservationType::Unbalanced( ode::RateGranularity::PerTransition, @@ -262,7 +264,7 @@ mod tests { }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("(\\rho_{\\text{transition}} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), + rhs: Latex("(\\rho_{\\text{transition}} - \\kappa_{\\text{transition}}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); @@ -385,7 +387,7 @@ mod tests { inner.add_ob(z.into(), ob_type.clone()); inner.add_mor( - p_name.into(), + p.into(), ModalOb::List( List::Symmetric, vec![ModalOb::Generator(x.into()), ModalOb::Generator(y.into())], @@ -394,13 +396,13 @@ mod tests { pos_mor_type.clone(), ); inner.add_mor( - q_name.into(), + q.into(), ModalOb::List(List::Symmetric, vec![ModalOb::Generator(z.into())]), ModalOb::Generator(y.into()), pos_mor_type.clone(), ); inner.add_mor( - n_name.into(), + n.into(), ModalOb::List( List::Symmetric, vec![ diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 42e4b25b4..3e316cc3f 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -29,10 +29,11 @@ impl fmt::Display for Latex { } /// An equation in Latex format with a left-hand side and a right-hand side. -#[derive(Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +#[derive(Clone)] pub struct LatexEquation { /// The left-hand side of the equation. pub lhs: Latex, @@ -40,13 +41,26 @@ pub struct LatexEquation { pub rhs: Latex, } +impl fmt::Display for LatexEquation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} = {}", self.lhs, self.rhs) + } +} + /// Symbolic equations in Latex format. -#[derive(Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] pub struct LatexEquations(pub Vec); +impl fmt::Debug for LatexEquations { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let eqns: Vec = self.0.clone().into_iter().map(|eqn| format!("{}", eqn)).collect(); + write!(f, "\n{}", eqns.join("\n")) + } +} + /// An object that can be rendered to Latex. pub trait ToLatex { /// Convert the object to its Latex representation. @@ -98,7 +112,7 @@ pub fn wrap_with_backslash_text(name: String) -> String { } } -/// Display a single-object list [x] directly as `x`, but display any longer list as `[x, y ,z]`. +/// Display a single-object list `[x]` directly as `x`, but display any longer list as `[x, y ,z]`. pub fn list_object_as_latex(vec: Vec) -> String { if vec.len() > 1 { format!("[{}]", vec.join(", ")) From b29046aad1d447f811bab905d19dce340521cfda Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 26 Jun 2026 11:51:17 +0100 Subject: [PATCH 35/38] merge main --- CHANGELOG.md | 5 ++++- packages/ui-components/src/inline_list_editor.module.css | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb21c6ad..c456a7dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ announcement and a blog post. Minor versions are not announced but allow features and fixes to be released with greater frequency. Minor versions often include notable new features. -## [Unreleased] +## [v0.6.0](https://github.com/ToposInstitute/CatColab/releases/tag/v0.6.0) (2026-05-27) + +Blog post: [CatColab v0.6: +Starling](https://topos.institute/blog/2026-06-01-catcolab-0-6-starling/) ### Added diff --git a/packages/ui-components/src/inline_list_editor.module.css b/packages/ui-components/src/inline_list_editor.module.css index 0d28ad348..fae302910 100644 --- a/packages/ui-components/src/inline_list_editor.module.css +++ b/packages/ui-components/src/inline_list_editor.module.css @@ -19,3 +19,12 @@ .defaultDelimiter { transform: scale(1, 1.5); } + +.emptyListInput { + background: transparent; + border: none; + outline: none; + width: 0.5ex; + margin: 0; + padding: 0; +} From 612ffa8d296c2a96c1b88110363803a431dfbb12 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 26 Jun 2026 12:58:02 +0100 Subject: [PATCH 36/38] FIX: Single ode_semantics_equations.tsx; fix reactivity in mass_action_config_form.tsx --- packages/frontend/src/stdlib/analyses.tsx | 10 ++--- .../analyses/lotka_volterra_equations.tsx | 35 --------------- .../analyses/mass_action_config_form.tsx | 45 ++++++++++--------- ...ations.tsx => ode_semantics_equations.tsx} | 6 +-- .../analyses/polynomial_ode_equations.tsx | 35 --------------- 5 files changed, 32 insertions(+), 99 deletions(-) delete mode 100644 packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx rename packages/frontend/src/stdlib/analyses/{linear_ode_equations.tsx => ode_semantics_equations.tsx} (85%) delete mode 100644 packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 779345810..a1e39371b 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -9,6 +9,7 @@ import type { import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import * as GraphLayoutConfig from "../visualization/graph_layout_config"; import type * as Checkers from "./analyses/checker_types"; +import ODESemanticsEquationsDisplay from "./analyses/ode_semantics_equations"; import { defaultSchemaERDConfig, type SchemaERDConfig } from "./analyses/schema_erd_config"; import type * as Simulators from "./analyses/simulator_types"; import type * as SQLDownloadConfig from "./analyses/sql"; @@ -150,12 +151,11 @@ export function linearODEEquations( description, help, component: (props) => ( - + ), initialContent: () => null, }; } -const LinearODEEquationsDisplay = lazy(() => import("./analyses/linear_ode_equations")); export function lotkaVolterra( options: Partial & { @@ -204,12 +204,11 @@ export function lotkaVolterraEquations( description, help, component: (props) => ( - + ), initialContent: () => null, }; } -const LotkaVolterraEquationsDisplay = lazy(() => import("./analyses/lotka_volterra_equations")); export function massAction( options: Partial & { @@ -435,12 +434,11 @@ export function polynomialODEEquations( description, help, component: (props) => ( - + ), initialContent: () => null, }; } -const PolynomialODEEquationsDisplay = lazy(() => import("./analyses/polynomial_ode_equations")); export function polynomialODESimulation( options: Partial & { diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx deleted file mode 100644 index c44286108..000000000 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import type { ModelAnalysisProps } from "../../analysis"; -import { createModelODELatex } from "./model_ode_plot"; -import type { LotkaVolterraEquations } from "./simulator_types"; - -import "./simulation.css"; - -/** Display the symbolic mass-action dynamics equations for a model. */ -export default function LotkaVolterraEquationsDisplay( - props: ModelAnalysisProps & { - content: null; - getEquations: LotkaVolterraEquations; - title?: string; - }, -) { - const latexEquations = createModelODELatex( - () => props.liveModel.validatedModel(), - (model) => props.getEquations(model), - ); - - return ( -

- - }, - { cell: () => }, - { cell: (row) => }, - ]} - /> -
- ); -} diff --git a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx index 761b9cec3..ef8feb2c0 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -16,18 +16,21 @@ export function MassActionConfigForm(props: { changeConfig: (f: (config: Config) => void) => void; enableGranularity: boolean; }) { - let correctConfig: MassActionEquationsData; - if (isMassActionProblemData(props.config)) { - correctConfig = props.config.equationsData; - } else { - correctConfig = props.config; + function massActionEquationsData(): MassActionEquationsData { + if (isMassActionProblemData(props.config)) { + return props.config.equationsData; + } else { + return props.config; + } } - const massConservation = () => correctConfig.massConservationType; - const massConservationGranularity = () => - correctConfig.massConservationType.type === "Unbalanced" - ? correctConfig.massConservationType.granularity + const massConservation = () => massActionEquationsData().massConservationType; + const massConservationGranularity = () => { + const massConversarvation = massActionEquationsData().massConservationType; + return massConversarvation.type === "Unbalanced" + ? massConversarvation.granularity : undefined; + }; return ( @@ -36,18 +39,18 @@ export function MassActionConfigForm(props: { checked={massConservation().type === "Balanced"} onChange={(evt) => { props.changeConfig((content) => { - let correctConfig: MassActionEquationsData; + let massActionEquationsData: MassActionEquationsData; if (isMassActionProblemData(content)) { - correctConfig = content.equationsData; + massActionEquationsData = content.equationsData; } else { - correctConfig = content; + massActionEquationsData = content; } if (evt.currentTarget.checked) { - correctConfig.massConservationType = { + massActionEquationsData.massConservationType = { type: "Balanced", }; } else { - correctConfig.massConservationType = { + massActionEquationsData.massConservationType = { type: "Unbalanced", granularity: "PerPlace", }; @@ -61,15 +64,17 @@ export function MassActionConfigForm(props: { value={massConservationGranularity() ?? "PerPlace"} onChange={(evt) => { props.changeConfig((content) => { - let correctConfig: MassActionEquationsData; + let massActionEquationsData: MassActionEquationsData; if (isMassActionProblemData(content)) { - correctConfig = content.equationsData; + massActionEquationsData = content.equationsData; } else { - correctConfig = content; + massActionEquationsData = content; } - if (correctConfig.massConservationType.type === "Unbalanced") { - correctConfig.massConservationType.granularity = evt.currentTarget - .value as RateGranularity; + if ( + massActionEquationsData.massConservationType.type === "Unbalanced" + ) { + massActionEquationsData.massConservationType.granularity = evt + .currentTarget.value as RateGranularity; } }); }} diff --git a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx similarity index 85% rename from packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx rename to packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx index 727b48a3f..774a37d85 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx @@ -1,15 +1,15 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; +import { DblModel, LatexEquations } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { createModelODELatex } from "./model_ode_plot"; -import type { LinearODEEquations } from "./simulator_types"; import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ -export default function LinearODEEquationsDisplay( +export default function ODESemanticsEquationsDisplay( props: ModelAnalysisProps & { content: null; - getEquations: LinearODEEquations; + getEquations: (model: DblModel) => LatexEquations; title?: string; }, ) { diff --git a/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx deleted file mode 100644 index 735498f61..000000000 --- a/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import type { ModelAnalysisProps } from "../../analysis"; -import { createModelODELatex } from "./model_ode_plot"; -import type { PolynomialODEEquations } from "./simulator_types"; - -import "./simulation.css"; - -/** Display the symbolic mass-action dynamics equations for a model. */ -export default function PolynomialODEEquationsDisplay( - props: ModelAnalysisProps & { - content: null; - getEquations: PolynomialODEEquations; - title?: string; - }, -) { - const latexEquations = createModelODELatex( - () => props.liveModel.validatedModel(), - (model) => props.getEquations(model), - ); - - return ( -
- - }, - { cell: () => }, - { cell: (row) => }, - ]} - /> -
- ); -} From 98e121e5f16cc0f3b26d3a767eed9eceb00f6f6a Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Sat, 1 Aug 2026 18:04:36 +0100 Subject: [PATCH 37/38] FIX: Errant bad rebase line --- packages/catlog-wasm/src/theories.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index d5ff7df46..0948227f6 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -13,7 +13,6 @@ use catlog::stdlib::analyses::ode::ODESemanticsAnalysis; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::name; -use super::latex::LatexEquations; use super::model_morphism::{MotifOccurrence, MotifsOptions, motifs}; use super::result::JsResult; use super::{analyses::*, model::DblModel, theory::DblTheory}; From 20a07e81de78789144629043bced780deaac2312 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 4 Aug 2026 15:50:45 +0100 Subject: [PATCH 38/38] FIX: Dynamically load ODESemanticsEquationsDisplay --- packages/frontend/src/stdlib/analyses.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index a1e39371b..d997c0a2c 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -9,7 +9,6 @@ import type { import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import * as GraphLayoutConfig from "../visualization/graph_layout_config"; import type * as Checkers from "./analyses/checker_types"; -import ODESemanticsEquationsDisplay from "./analyses/ode_semantics_equations"; import { defaultSchemaERDConfig, type SchemaERDConfig } from "./analyses/schema_erd_config"; import type * as Simulators from "./analyses/simulator_types"; import type * as SQLDownloadConfig from "./analyses/sql"; @@ -22,6 +21,8 @@ type AnalysisOptions = { help?: string; }; +const ODESemanticsEquationsDisplay = lazy(() => import("./analyses/ode_semantics_equations")); + export const decapodes = ( options: AnalysisOptions, ): DiagramAnalysisMeta => ({