From d201a8011e04cd619c5bb0c797e05a763801feae Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 7 May 2026 00:24:57 +0100 Subject: [PATCH 01/39] 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 2b95175c5..8d0156aba 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -156,16 +156,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. @@ -173,17 +171,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) } } @@ -346,26 +342,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 e0edf3094..5aabdb961 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}, }; @@ -108,20 +111,16 @@ impl PetriNetStochasticMassActionAnalysis { let mut problem = gillespie::Gillespie::new(initial, false); 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 6965d47f9..970332d15 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -167,14 +167,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()); @@ -235,6 +238,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::*; @@ -288,6 +339,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 38b19a6a0..1db2c1b0e 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 aafac798ac3c16d8687ba177275997947530d007 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 11 Jun 2026 00:08:39 +0100 Subject: [PATCH 02/39] 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 a04ec46546764309b853eecdbc644ce322b2c242 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 12:22:09 +0100 Subject: [PATCH 03/39] 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 51f23ffa6a0f00c090879114551665fc9e93107f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 15:11:10 +0100 Subject: [PATCH 04/39] 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 d6cd50f9a8a24cd8ac57110838eb82736931fd6a Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 15:41:18 +0100 Subject: [PATCH 05/39] 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 121eb6ea979e2815054abffc79ac0c2791a751ba Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 16:45:10 +0100 Subject: [PATCH 06/39] 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 7f42ba4007c324c09bc539687fed9c80e51885d3 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 16:54:42 +0100 Subject: [PATCH 07/39] 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 17fb71c7390103b2c9e8db7a8832eed186fc484c Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 12 Jun 2026 17:20:47 +0100 Subject: [PATCH 08/39] 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 aad1f32d78bdf3d4320694a4dfec0ccb469dda00 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 15 Jun 2026 12:36:30 +0100 Subject: [PATCH 09/39] 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 3f9e052772f0767d3c1d106c13049f0a0a6041fe Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 15 Jun 2026 12:36:30 +0100 Subject: [PATCH 10/39] ENH: Documentation --- .../stdlib/analyses/ode/#ode_semantics.rs# | 256 ------------------ .../src/stdlib/analyses/ode/linear_ode.rs | 4 +- .../src/stdlib/analyses/ode/mass_action.rs | 33 ++- .../src/stdlib/analyses/ode/ode_semantics.rs | 118 ++++---- 4 files changed, 78 insertions(+), 333 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) - } -} 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 7a8e39c736133bb3624fa347c556c00eed00506a Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 15 Jun 2026 20:34:59 +0100 Subject: [PATCH 11/39] 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 1f881f648a70b22600388a581f80b15fff21c058 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 16 Jun 2026 15:29:07 +0100 Subject: [PATCH 12/39] 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-wasm/src/theories.rs | 2 +- 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 +- 10 files changed, 82 insertions(+), 105 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-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 8d0156aba..37771d6f6 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -10,8 +10,8 @@ use catlog::dbl::theory::{self as theory, NonUnital, Unital}; use catlog::one::Path; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::{QualifiedLabel, name}; +use catlog::latex::LatexEquations; -use super::latex::LatexEquations; use super::model_morphism::{MotifOccurrence, MotifsOptions, motifs}; use super::result::JsResult; use super::theories::MassActionAnalysisLogic; 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 b19727e4d5350e5818467b9d1af757c94de5f474 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 16 Jun 2026 17:47:47 +0100 Subject: [PATCH 13/39] 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 09bbfb82a6cd1fd5b139381a86d8d8725d66f441 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 16 Jun 2026 19:39:15 +0100 Subject: [PATCH 14/39] 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 46de5472e14c3096a9e3db8e406454cef2200ff1 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 17 Jun 2026 19:30:43 +0100 Subject: [PATCH 15/39] 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 c4888bc4f63d625955cb755e201662f8ad4e987f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 13:14:12 +0100 Subject: [PATCH 16/39] 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 0ec751a8ab714f2cf10a1c4137760f35eb79a5a9 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 18:09:21 +0100 Subject: [PATCH 17/39] 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 8701ee928bed2e5975ed36525747cb1cd23fa61a Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 18:12:48 +0100 Subject: [PATCH 18/39] 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 b8071ee21c1a06be8a00f1e7e2f5b98d33150454 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 18 Jun 2026 18:21:47 +0100 Subject: [PATCH 19/39] 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 | 2 +- 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(+), 126 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 37771d6f6..348234745 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -7,10 +7,10 @@ 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::{QualifiedLabel, name}; -use catlog::latex::LatexEquations; use super::model_morphism::{MotifOccurrence, MotifsOptions, motifs}; use super::result::JsResult; 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 5c654bcbece6576bd01a7b87f55bc4eddee1f25e Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 17:42:30 +0100 Subject: [PATCH 20/39] 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 247bfa36670f6cc421202c73940a80036956ff8f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 18:37:08 +0100 Subject: [PATCH 21/39] 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 79fbf79fc70da1c38093e9f36f81f79f291e9c15 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 18:42:39 +0100 Subject: [PATCH 22/39] FIX: Backwards compatibility --- packages/catlog-wasm/src/latex.rs | 12 ++-- .../src/stdlib/analyses/ode/mass_action.rs | 58 +++++++++---------- .../src/stdlib/analyses/mass_action.tsx | 8 +-- .../analyses/mass_action_config_form.tsx | 8 +-- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 4234fef0b..37f6ea678 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -81,21 +81,21 @@ pub(crate) fn latex_mor_names_mass_action( match (direction, parameter) { ( ode::Direction::IncomingFlow, - ode::RateParameter::PerFlow { flow: transition }, + ode::RateParameter::PerTransition { flow: transition }, ) => { let sub = transition_subscript(transition); format!("\\rho_{{{sub}}}") } ( ode::Direction::OutgoingFlow, - ode::RateParameter::PerFlow { flow: transition }, + ode::RateParameter::PerTransition { flow: transition }, ) => { let sub = transition_subscript(transition); format!("\\kappa_{{{sub}}}") } ( ode::Direction::IncomingFlow, - ode::RateParameter::PerStock { flow: transition, stock: place }, + ode::RateParameter::PerPlace { flow: transition, stock: place }, ) => { let sub = transition_subscript(transition); let output_place_label = model.ob_namespace.label_string(place); @@ -103,7 +103,7 @@ pub(crate) fn latex_mor_names_mass_action( } ( ode::Direction::OutgoingFlow, - ode::RateParameter::PerStock { flow: transition, stock: place }, + ode::RateParameter::PerPlace { flow: transition, stock: place }, ) => { let sub = transition_subscript(transition); let input_place_label = model.ob_namespace.label_string(place); @@ -195,7 +195,7 @@ 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() }; @@ -224,7 +224,7 @@ 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() }; diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index f5371a1d5..a4ca963dc 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -69,11 +69,11 @@ pub enum MassConservationType { #[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, + PerTransition, /// Each flow gets assigned a consumption rate for each input stock and /// a production rate for each output stock. - PerStock, + PerPlace, } /// Now, corresponding to each term of `MassConvervationType`, we have different @@ -99,14 +99,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. @@ -134,25 +134,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) } @@ -230,13 +230,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(), }, @@ -261,20 +261,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(), }, @@ -360,7 +360,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()); @@ -370,7 +370,7 @@ impl } MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerFlow { flow: flow.clone() }, + parameter: RateParameter::PerTransition { flow: flow.clone() }, }, }; builder.add_contribution( @@ -388,7 +388,7 @@ impl } MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerFlow { flow: flow.clone() }, + parameter: RateParameter::PerTransition { flow: flow.clone() }, }, }; builder.add_contribution( @@ -470,13 +470,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() @@ -484,7 +484,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) @@ -493,7 +493,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) @@ -539,7 +539,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() } @@ -573,7 +573,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() } @@ -592,7 +592,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() } @@ -613,7 +613,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/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..84332f3be 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: "PerTransition", }; } }); @@ -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 5ffcb6e474f92ca12d177dce04cf557b30657b5b Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 19 Jun 2026 18:57:35 +0100 Subject: [PATCH 23/39] 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 59249d097f9e8894dca3d58b49a3af434168e7a1 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 22 Jun 2026 17:42:37 +0100 Subject: [PATCH 24/39] 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 348234745..6e8f6e13a 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -157,13 +157,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. @@ -173,13 +175,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?) } } @@ -313,7 +317,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. @@ -321,9 +332,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?) } } @@ -367,7 +383,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. @@ -375,9 +398,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. @@ -434,12 +462,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) } } @@ -471,12 +495,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 edcd5b7cc0ab0d8b36f6da2f7b4c973f69c84fd6 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Mon, 22 Jun 2026 19:58:57 +0100 Subject: [PATCH 25/39] 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 b77997c5a3c8a5aeb71a691c7e692cb8943486a2 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 23 Jun 2026 20:12:49 +0100 Subject: [PATCH 26/39] 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 6e8f6e13a..81a517a70 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -9,12 +9,12 @@ 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::{QualifiedLabel, name}; 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. @@ -157,15 +157,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. @@ -173,17 +175,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) } } @@ -317,14 +319,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. @@ -334,12 +334,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) } } @@ -383,14 +383,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. @@ -400,12 +398,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. @@ -457,13 +455,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) } } @@ -490,13 +492,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 1db2c1b0e..f4e326deb 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 c1a3497ac8ac07e7a19565dc68bc35db30a534d7 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 23 Jun 2026 20:57:06 +0100 Subject: [PATCH 27/39] 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 70f2f4a6f6e2bc446c096954416ab5f0b9fd500c Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 15:41:28 +0100 Subject: [PATCH 28/39] 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 f4e326deb..7a2ca468a 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 3a5cc775d8bf01f7b43274472ec3cba9a727b645 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 18:08:37 +0100 Subject: [PATCH 29/39] 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 f0853878b..4e2388100 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" }, "devDependencies": { "depcheck": "^1.4.7", 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 7a2ca468a..645e8e44c 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: {}, @@ -426,7 +419,7 @@ export function polynomialODEEquations( options: Partial & { getEquations: Simulators.PolynomialODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "polynomial-ode-equations", name = "Polynomial ODE equations", @@ -442,9 +435,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 dc95774a53e85821606bbd57c12b9b9c93f79685 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 18:23:26 +0100 Subject: [PATCH 30/39] 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 e923149e256841c5b400224e000da7367b2b7bdd Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Wed, 24 Jun 2026 18:24:48 +0100 Subject: [PATCH 31/39] 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 582951b497f376c5e88a7dedf90b729001a2df2c Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 15:56:57 +0100 Subject: [PATCH 32/39] 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 991adca149ee2496fe6b97297b55bc73e2d44065 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 17:43:42 +0100 Subject: [PATCH 33/39] 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 b0803270df2b7083f67446955151c35d82dfb3fe Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 18:18:00 +0100 Subject: [PATCH 34/39] 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 d350cf71107ed875965e57ac23b00527a7e24d0c Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 25 Jun 2026 19:16:17 +0100 Subject: [PATCH 35/39] 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 fe5fd6cc8f1901c3a6aba47fd90e32ceb0ad0b43 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 26 Jun 2026 11:51:17 +0100 Subject: [PATCH 36/39] merge main --- .github/CODEOWNERS | 8 - .github/workflows/build.yml | 11 + .github/workflows/ci.yml | 25 - .../workflows/github-pr-sync-with-notion.yml | 474 ++++++++++++ .github/workflows/julia-tests.yml | 29 + .github/workflows/ui-components-tests.yml | 52 ++ .../workflows/vite-plugin-monorepo-dedupe.yml | 41 ++ CHANGELOG.md | 5 +- CONTRIBUTING.md | 4 - dev-docs/fixing-hash-mismatches.md | 60 -- dev-docs/typedoc.json | 3 +- flake.lock | 57 +- flake.nix | 236 +++--- packages/algjulia-interop/README.md | 2 +- packages/catlog-wasm/src/theories.rs | 22 +- packages/catlog/src/one/graph_algorithms.rs | 65 +- packages/catlog/src/stdlib/analyses/sql.rs | 447 ++++++++++-- packages/document-types/src/common_test.rs | 1 + packages/frontend/README.md | 13 - packages/frontend/default.nix | 153 +++- packages/frontend/package.json | 5 +- packages/frontend/pnpm-lock.yaml | 109 +-- packages/frontend/src/App.tsx | 25 +- .../frontend/src/analysis/analysis_editor.tsx | 8 +- .../src/components/document_picker.tsx | 9 +- .../frontend/src/diagram/diagram_editor.tsx | 8 +- .../src/diagram/morphism_cell_editor.tsx | 53 +- .../src/diagram/object_cell_editor.tsx | 29 +- .../src/model/contribution_cell_editor.tsx | 48 +- .../model/contribution_monomial_editor.tsx | 6 +- packages/frontend/src/model/editors.ts | 5 +- .../src/model/instantiation_cell_editor.tsx | 123 ++-- packages/frontend/src/model/model_editor.tsx | 10 +- .../src/model/morphism_cell_editor.tsx | 52 +- .../frontend/src/model/object_cell_editor.tsx | 3 +- .../frontend/src/model/object_list_editor.css | 29 - .../frontend/src/model/object_list_editor.tsx | 177 +---- .../string_diagram_morphism_cell_editor.tsx | 36 +- .../frontend/src/notebook/notebook_cell.tsx | 18 +- .../frontend/src/notebook/notebook_editor.tsx | 101 +-- packages/frontend/src/page/document_page.tsx | 94 ++- .../src/page/document_page_sidebar.tsx | 56 +- .../frontend/src/page/history_sidebar.tsx | 5 +- packages/frontend/src/page/home_page.css | 32 - packages/frontend/src/page/home_page.tsx | 28 +- packages/frontend/src/page/sidebar_layout.css | 6 +- packages/frontend/src/stdlib/analyses.tsx | 2 +- .../src/stdlib/analyses/sql.module.css | 9 + packages/frontend/src/stdlib/analyses/sql.tsx | 28 +- .../frontend/src/visualization/elk_svg.tsx | 16 +- packages/frontend/vite.config.ts | 34 +- packages/gaios/package.json | 1 + packages/gaios/pnpm-lock.yaml | 4 +- packages/gaios/src/model_tool.tsx | 6 +- packages/gaios/vite.config.ts | 40 +- packages/ui-components/package.json | 2 + packages/ui-components/pnpm-lock.yaml | 334 +++++++++ packages/ui-components/src/button.tsx | 6 +- .../ui-components/src/code_view.stories.tsx | 91 +++ packages/ui-components/src/code_view.tsx | 26 + packages/ui-components/src/colors.css | 17 +- packages/ui-components/src/colors.stories.tsx | 38 +- .../ui-components/src/foldable.stories.tsx | 2 +- .../src/history_navigator.module.css | 2 +- packages/ui-components/src/index.ts | 3 + .../src/inline_list_editor.module.css | 30 + .../src/inline_list_editor.stories.tsx | 130 ++++ .../ui-components/src/inline_list_editor.tsx | 234 ++++++ packages/ui-components/src/panel.css | 1 + packages/ui-components/src/text_input.tsx | 13 +- packages/ui-components/src/util/focus.ts | 45 ++ packages/ui-components/src/util/keyboard.ts | 50 ++ packages/ui-components/vite.config.ts | 8 +- pnpm-workspace.yaml | 3 +- rfc/0001.md | 3 +- rfc/0004.md | 16 +- rfc/_macros.qmd | 2 + rfc/filters.lua | 3 + rfc/nonactionable/0005.md | 20 +- .../vite-plugin-monorepo-dedupe/package.json | 23 + .../pnpm-lock.yaml | 680 ++++++++++++++++++ .../vite-plugin-monorepo-dedupe/src/index.ts | 291 ++++++++ .../vite-plugin-monorepo-dedupe/tsconfig.json | 19 + 83 files changed, 3914 insertions(+), 1101 deletions(-) delete mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/github-pr-sync-with-notion.yml create mode 100644 .github/workflows/julia-tests.yml create mode 100644 .github/workflows/ui-components-tests.yml create mode 100644 .github/workflows/vite-plugin-monorepo-dedupe.yml delete mode 100644 dev-docs/fixing-hash-mismatches.md delete mode 100644 packages/frontend/src/model/object_list_editor.css create mode 100644 packages/frontend/src/stdlib/analyses/sql.module.css create mode 100644 packages/ui-components/src/code_view.stories.tsx create mode 100644 packages/ui-components/src/code_view.tsx create mode 100644 packages/ui-components/src/inline_list_editor.module.css create mode 100644 packages/ui-components/src/inline_list_editor.stories.tsx create mode 100644 packages/ui-components/src/inline_list_editor.tsx create mode 100644 packages/ui-components/src/util/focus.ts create mode 100644 tools/vite-plugin-monorepo-dedupe/package.json create mode 100644 tools/vite-plugin-monorepo-dedupe/pnpm-lock.yaml create mode 100644 tools/vite-plugin-monorepo-dedupe/src/index.ts create mode 100644 tools/vite-plugin-monorepo-dedupe/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 37ee268d9..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,8 +0,0 @@ -# Build and CI infrastructure -/.github/workflows/ @kasbah @jmoggr -/infrastructure/ @kasbah @jmoggr -*.nix @kasbah @jmoggr - -# Components and styling -*.css @kasbah -/packages/ui-components/ @kasbah diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d34b6c53..d53d47ac9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -227,6 +227,11 @@ jobs: run: | tlmgr update --self tlmgr install dvisvgm standalone preview pgf tikz-cd amsmath quiver spath3 ebproof + # Work around https://github.com/loopspace/spath3/issues/37 by + # dropping the redundant `NNn` variant that newer expl3 rejects. + SPATH3="$(kpsewhich spath3.sty)" + sed -i 's|\\spath_maybe_split_curve:NNn {NNn, NNV }|\\spath_maybe_split_curve:NNn {NNV}|' "$SPATH3" + sed -i 's|\\spath_maybe_gsplit_curve:NNn {NNn, NNV}|\\spath_maybe_gsplit_curve:NNn {NNV}|' "$SPATH3" - name: Build mathematical docs if: steps.cache-math-docs.outputs.cache-hit != 'true' @@ -287,12 +292,18 @@ jobs: run: | tlmgr update --self tlmgr install dvisvgm standalone preview pgf tikz-cd amsmath quiver spath3 ebproof luatex85 + # Work around https://github.com/loopspace/spath3/issues/37 by + # dropping the redundant `NNn` variant that newer expl3 rejects. + SPATH3="$(kpsewhich spath3.sty)" + sed -i 's|\\spath_maybe_split_curve:NNn {NNn, NNV }|\\spath_maybe_split_curve:NNn {NNV}|' "$SPATH3" + sed -i 's|\\spath_maybe_gsplit_curve:NNn {NNn, NNV}|\\spath_maybe_gsplit_curve:NNn {NNV}|' "$SPATH3" - name: Render Quarto project if: steps.cache-rfc.outputs.cache-hit != 'true' uses: quarto-dev/quarto-actions/render@v2 with: path: rfc + to: html - name: Report cache status run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15dbf1b93..0f1402532 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,31 +81,6 @@ jobs: run: | nix build .#rust-docs-check - ui_components_tests: - name: ui-components tests - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup NodeJS - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install - - - name: Install Playwright browser - run: pnpm --filter ./packages/ui-components exec playwright install chromium - - - name: Run ui-components tests - run: pnpm --filter ./packages/ui-components run test - npm_checks: name: npm checks runs-on: ubuntu-latest diff --git a/.github/workflows/github-pr-sync-with-notion.yml b/.github/workflows/github-pr-sync-with-notion.yml new file mode 100644 index 000000000..e574697d9 --- /dev/null +++ b/.github/workflows/github-pr-sync-with-notion.yml @@ -0,0 +1,474 @@ +# GitHub PR Sync with Notion +# +# Based on https://github.com/isoppp/github-pr-sync-with-notion +# Copyright (c) 2026 isoppp +# +# MIT License; see LICENSE-MIT for full text. The original copyright notice +# and permission notice are reproduced here as required by the license. +# +# The inline script below is derived from isoppp's composite action, extended +# with draft / ready-for-review / changes-requested / closed-unmerged status +# mapping for our Notion database. + +name: GitHub PR Sync with Notion + +on: + pull_request: + types: [opened, edited, closed, ready_for_review, converted_to_draft] + pull_request_review: + types: [submitted] + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + steps: + - name: Sync to Notion + uses: actions/github-script@v7 + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} + NOTION_ID_PROPERTY: 'ID' + NOTION_STATUS_PROPERTY: 'Status' + NOTION_PR_PROPERTY: 'GitHub PR' + PR_TITLE_PREFIX: 'OBEE' + # Status labels mapped from PR state (must exist as options on the + # Notion Status property). Override here if your DB uses different names. + NOTION_STATUS_IN_PROGRESS: 'In progress' + NOTION_STATUS_IN_REVIEW: 'In review' + NOTION_STATUS_DONE: 'Done' + NOTION_STATUS_CLOSED: 'Closed' + with: + script: | + const eventName = context.eventName; // 'pull_request' | 'pull_request_review' + const eventAction = context.payload.action; // 'opened' | 'edited' | 'closed' | 'ready_for_review' | 'converted_to_draft' | 'submitted' + const pr = context.payload.pull_request; + const prNumber = pr.number; + const prUrl = pr.html_url; + const prefix = process.env.PR_TITLE_PREFIX; + + const headers = { + 'Authorization': `Bearer ${process.env.NOTION_TOKEN}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json' + }; + + // Escape regex special characters for safe use in RegExp + function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function extractTaskIds(title) { + if (!title) return []; + const escapedPrefix = escapeRegex(prefix); + const pattern = new RegExp(`${escapedPrefix}-(\\d+)`, 'g'); + const matches = [...title.matchAll(pattern)]; + return matches.map(match => parseInt(match[1])); + } + + async function findNotionPage(taskId) { + console.log(`Searching for task ID: ${prefix}-${taskId}`); + const response = await fetch( + `https://api.notion.com/v1/databases/${process.env.NOTION_DATABASE_ID}/query`, + { + method: 'POST', + headers, + body: JSON.stringify({ + filter: { + property: process.env.NOTION_ID_PROPERTY, + number: { equals: taskId } + } + }) + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Notion search failed (${response.status}): ${error}`); + } + + const data = await response.json(); + return data.results.length > 0 ? data.results[0] : null; + } + + async function getNotionPage(pageId) { + const response = await fetch( + `https://api.notion.com/v1/pages/${pageId}`, + { + method: 'GET', + headers + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get Notion page (${response.status}): ${error}`); + } + + return await response.json(); + } + + function addPrLinkToRichText(existingRichText, prNumber, prUrl) { + const newLink = { + text: { + content: `PR #${prNumber}`, + link: { url: prUrl } + } + }; + + // Remove existing link with same PR number (use strict equality to avoid PR #1 matching PR #10) + const filtered = existingRichText.filter(item => { + const content = item.text?.content || ''; + const isPrLink = content === `PR #${prNumber}`; + return !isPrLink; + }); + + if (filtered.length > 0) { + return [...filtered, { text: { content: '\n' } }, newLink]; + } + return [newLink]; + } + + function removePrLinkFromRichText(existingRichText, prNumber) { + // Filter out exact PR link match (e.g., "PR #123") + // Use strict equality check to avoid false positives (PR #1 vs PR #10) + const filtered = existingRichText.filter(item => { + const content = item.text?.content || ''; + const isPrLink = content === `PR #${prNumber}`; + return !isPrLink; + }); + + // Remove orphaned newlines (consecutive newlines or leading/trailing) + const cleaned = []; + for (let i = 0; i < filtered.length; i++) { + const current = filtered[i]; + const content = current.text?.content || ''; + const isNewline = content === '\n'; + + if (!isNewline) { + cleaned.push(current); + } else { + // Keep newline only if: + // 1. Not at the beginning + // 2. Not at the end + // 3. Not consecutive with previous newline + const isFirst = i === 0; + const isLast = i === filtered.length - 1; + const prevIsNewline = i > 0 && (filtered[i - 1].text?.content || '') === '\n'; + + if (!isFirst && !isLast && !prevIsNewline) { + cleaned.push(current); + } + } + } + + return cleaned; + } + + async function updateNotionPage(pageId, properties) { + console.log('Updating Notion page...'); + const response = await fetch( + `https://api.notion.com/v1/pages/${pageId}`, + { + method: 'PATCH', + headers, + body: JSON.stringify({ properties }) + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Notion update failed (${response.status}): ${error}`); + } + + return await response.json(); + } + + // Set the Status property on every Notion page whose task ID appears + // in the PR title. No-op if the title carries no task IDs or if the + // target status value is empty. + async function setStatusForTasks(statusValue) { + if (!statusValue) { + console.log(`⏭️ Skipped status update (status value is empty)`); + return; + } + const title = pr.title; + const taskIds = extractTaskIds(title); + if (taskIds.length === 0) { + console.log(`No ${prefix} ID found in PR title: ${title}`); + return; + } + for (const taskId of taskIds) { + const page = await findNotionPage(taskId); + if (!page) { + console.log(`No Notion page found with ${process.env.NOTION_ID_PROPERTY}=${taskId}`); + continue; + } + await updateNotionPage(page.id, { + [process.env.NOTION_STATUS_PROPERTY]: { + status: { name: statusValue } + } + }); + console.log(`✅ Updated status for ${prefix}-${taskId} to "${statusValue}"`); + } + } + + // Find existing Notion sync comment + async function findNotionSyncComment() { + // Use paginate to fetch all comments (handles PRs with 30+ comments) + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + return comments.find(comment => + comment.user.login === 'github-actions[bot]' && + comment.body.includes('') + ); + } + + // Post or update PR comment + async function postOrUpdatePrComment(notionPages) { + const lines = notionPages.map(({ page, taskId }) => { + const pageTitle = page.properties.Name?.title?.[0]?.plain_text || + page.properties.Title?.title?.[0]?.plain_text || + `Task ${taskId}`; + const pageUrl = page.url; + return `- [${prefix}-${taskId}: ${pageTitle}](${pageUrl})`; + }); + + const message = `\n## Notion Integration\n\n${lines.join('\n')}`; + + // Search for existing comment + const existingComment = await findNotionSyncComment(); + + if (existingComment) { + // Update comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: message + }); + console.log('Updated existing PR comment'); + } else { + // Post new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: message + }); + console.log('Posted new PR comment'); + } + } + + // Delete Notion sync comment + async function deleteNotionSyncComment() { + const existingComment = await findNotionSyncComment(); + if (existingComment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id + }); + console.log('Deleted PR comment'); + } + } + + // Main processing + try { + console.log(`Event: ${eventName}/${eventAction}`); + + // ===================================================== + // pull_request_review: changes requested -> In progress + // (approved / commented do not trigger a status change) + // ===================================================== + if (eventName === 'pull_request_review') { + const reviewState = context.payload.review?.state; + console.log(`Review state: ${reviewState}`); + if (reviewState === 'changes_requested') { + await setStatusForTasks(process.env.NOTION_STATUS_IN_PROGRESS); + } else { + console.log(`Review state "${reviewState}" does not trigger a status change`); + } + + // ===================================== + // PR opened: sync PR link + set status + // draft -> In progress + // non-draft -> In review + // ===================================== + } else if (eventAction === 'opened') { + const title = pr.title; + const taskIds = extractTaskIds(title); + + const notionPages = []; + + if (taskIds.length > 0) { + for (const taskId of taskIds) { + const page = await findNotionPage(taskId); + if (!page) { + console.log(`No Notion page found with ${process.env.NOTION_ID_PROPERTY}=${taskId}`); + continue; + } + + // Get existing rich text + const pageData = await getNotionPage(page.id); + const existingRichText = pageData.properties[process.env.NOTION_PR_PROPERTY]?.rich_text || []; + + // Add PR link + const updatedRichText = addPrLinkToRichText(existingRichText, prNumber, prUrl); + + const updatedPage = await updateNotionPage(page.id, { + [process.env.NOTION_PR_PROPERTY]: { + rich_text: updatedRichText + } + }); + + console.log(`✅ Added PR link to ${prefix}-${taskId}`); + console.log(` PR: ${prUrl}`); + + notionPages.push({ page: updatedPage, taskId }); + } + + // Post or update PR comment + if (notionPages.length > 0) { + await postOrUpdatePrComment(notionPages); + } + } else { + console.log(`No ${prefix} ID found in PR title: ${title}`); + } + + // Status based on draft state + if (pr.draft) { + await setStatusForTasks(process.env.NOTION_STATUS_IN_PROGRESS); + } else { + await setStatusForTasks(process.env.NOTION_STATUS_IN_REVIEW); + } + + // ===================================== + // PR edited: sync PR links on title change (no status change) + // ===================================== + } else if (eventAction === 'edited') { + // Skip if title is unchanged (e.g., Description change) + if (!context.payload.changes?.title) { + console.log('Title unchanged, skipping sync'); + return; + } + + const oldTitle = context.payload.changes.title.from; + const newTitle = pr.title; + + const oldTaskIds = extractTaskIds(oldTitle); + const newTaskIds = extractTaskIds(newTitle); + + console.log(`Title changed: "${oldTitle}" -> "${newTitle}"`); + console.log(`Task IDs changed: [${oldTaskIds.join(', ') || 'none'}] -> [${newTaskIds.join(', ') || 'none'}]`); + + // Skip if task IDs are unchanged + const oldTaskIdsSet = new Set(oldTaskIds); + const newTaskIdsSet = new Set(newTaskIds); + const hasChanges = oldTaskIds.length !== newTaskIds.length || + oldTaskIds.some(id => !newTaskIdsSet.has(id)) || + newTaskIds.some(id => !oldTaskIdsSet.has(id)); + + if (!hasChanges) { + console.log('Task IDs unchanged, skipping sync'); + return; + } + + const notionPages = []; + + // Remove PR link from tasks no longer in title + const removedTaskIds = oldTaskIds.filter(id => !newTaskIdsSet.has(id)); + for (const taskId of removedTaskIds) { + const oldPage = await findNotionPage(taskId); + if (oldPage) { + const oldPageData = await getNotionPage(oldPage.id); + const oldRichText = oldPageData.properties[process.env.NOTION_PR_PROPERTY]?.rich_text || []; + const updatedOldRichText = removePrLinkFromRichText(oldRichText, prNumber); + + await updateNotionPage(oldPage.id, { + [process.env.NOTION_PR_PROPERTY]: { + rich_text: updatedOldRichText + } + }); + + console.log(`✅ Removed PR link from ${prefix}-${taskId}`); + } + } + + // Add PR link to new tasks + const addedTaskIds = newTaskIds.filter(id => !oldTaskIdsSet.has(id)); + for (const taskId of addedTaskIds) { + const newPage = await findNotionPage(taskId); + if (newPage) { + const newPageData = await getNotionPage(newPage.id); + const newRichText = newPageData.properties[process.env.NOTION_PR_PROPERTY]?.rich_text || []; + const updatedNewRichText = addPrLinkToRichText(newRichText, prNumber, prUrl); + + const updatedNewPage = await updateNotionPage(newPage.id, { + [process.env.NOTION_PR_PROPERTY]: { + rich_text: updatedNewRichText + } + }); + + console.log(`✅ Added PR link to ${prefix}-${taskId}`); + console.log(` PR: ${prUrl}`); + notionPages.push({ page: updatedNewPage, taskId }); + } + } + + // Also include existing tasks (not added, but still in title) in comment + const existingTaskIds = newTaskIds.filter(id => oldTaskIdsSet.has(id)); + for (const taskId of existingTaskIds) { + const page = await findNotionPage(taskId); + if (page) { + const pageData = await getNotionPage(page.id); + notionPages.push({ page: pageData, taskId }); + } + } + + // Sort by task ID for consistent display + notionPages.sort((a, b) => a.taskId - b.taskId); + + // Post or update PR comment + if (notionPages.length > 0) { + await postOrUpdatePrComment(notionPages); + } else { + // Delete comment if all task IDs were removed from title + await deleteNotionSyncComment(); + } + + // ===================================== + // ready_for_review: draft -> In review + // ===================================== + } else if (eventAction === 'ready_for_review') { + await setStatusForTasks(process.env.NOTION_STATUS_IN_REVIEW); + + // ===================================== + // converted_to_draft: -> In progress + // ===================================== + } else if (eventAction === 'converted_to_draft') { + await setStatusForTasks(process.env.NOTION_STATUS_IN_PROGRESS); + + // ===================================== + // closed: merged -> Done | unmerged -> Closed + // ===================================== + } else if (eventAction === 'closed') { + if (pr.merged) { + await setStatusForTasks(process.env.NOTION_STATUS_DONE); + } else { + await setStatusForTasks(process.env.NOTION_STATUS_CLOSED); + } + + } else { + console.log(`Event action "${eventAction}" is not handled`); + } + + } catch (error) { + core.setFailed(`Failed to sync: ${error.message}`); + } diff --git a/.github/workflows/julia-tests.yml b/.github/workflows/julia-tests.yml new file mode 100644 index 000000000..63eead5e6 --- /dev/null +++ b/.github/workflows/julia-tests.yml @@ -0,0 +1,29 @@ +name: julia-tests + +on: + push: + branches: + - main + paths: + - 'packages/algjulia-interop/**' + - '.github/workflows/julia-tests.yml' + pull_request: + paths: + - 'packages/algjulia-interop/**' + - '.github/workflows/julia-tests.yml' + +jobs: + julia-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Julia + uses: julia-actions/setup-julia@v2 + with: + version: '1' + + - name: Tests + run: | + julia --project=packages/algjulia-interop -e 'import Pkg; Pkg.test()' diff --git a/.github/workflows/ui-components-tests.yml b/.github/workflows/ui-components-tests.yml new file mode 100644 index 000000000..a4b2cb76a --- /dev/null +++ b/.github/workflows/ui-components-tests.yml @@ -0,0 +1,52 @@ +name: ui-components-tests + +on: + push: + branches: + - main + paths: + - "packages/ui-components/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "flake.nix" + - "flake.lock" + - ".github/workflows/ui-components-tests.yml" + pull_request: + paths: + - "packages/ui-components/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "flake.nix" + - "flake.lock" + - ".github/workflows/ui-components-tests.yml" + +jobs: + ui-components-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v25 + + - name: Configure Cachix + uses: cachix/cachix-action@v14 + with: + name: catcolab-jmoggr + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + # The ui-components-tests devShell provides nodejs_24, pnpm, and + # playwright-driver.browsers (set via PLAYWRIGHT_BROWSERS_PATH). + # PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 is exported from the shell so + # `pnpm install` will not try to fetch browsers. + # See https://wiki.nixos.org/wiki/Playwright. + - name: Run ui-components tests + run: | + nix develop .#ui-components-tests --command bash -c ' + set -euo pipefail + pnpm install --frozen-lockfile + pnpm --filter ./packages/ui-components run test + ' diff --git a/.github/workflows/vite-plugin-monorepo-dedupe.yml b/.github/workflows/vite-plugin-monorepo-dedupe.yml new file mode 100644 index 000000000..7bc5b28c5 --- /dev/null +++ b/.github/workflows/vite-plugin-monorepo-dedupe.yml @@ -0,0 +1,41 @@ +name: vite-plugin-monorepo-dedupe + +on: + push: + branches: + - main + paths: + - "tools/vite-plugin-monorepo-dedupe/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/vite-plugin-monorepo-dedupe-checks.yml" + pull_request: + paths: + - "tools/vite-plugin-monorepo-dedupe/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/vite-plugin-monorepo-dedupe-checks.yml" + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Run vite-plugin-monorepo-dedupe checks + run: pnpm --filter ./tools/vite-plugin-monorepo-dedupe run ci diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e134c084..e49186561 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/CONTRIBUTING.md b/CONTRIBUTING.md index 2e4b80355..647ab70dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,8 +69,4 @@ cargo clippy Try to remember to run these commands before making a PR. (If you forget, the CI will remind you.) -## Developer documentation -Additional documentation for developers: - -- [Fixing Hash Mismatches in Nix](./dev-docs/fixing-hash-mismatches.md) diff --git a/dev-docs/fixing-hash-mismatches.md b/dev-docs/fixing-hash-mismatches.md deleted file mode 100644 index 43e806c83..000000000 --- a/dev-docs/fixing-hash-mismatches.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "Fixing hash mismatches in Nix" ---- - -### Fixing Hash Mismatches in Nix - -Nix uses **fixed-output derivations** to fetch external dependencies (from npm, crates.io, GitHub, etc.). -These derivations require a cryptographic hash to verify that the fetched content matches what's expected, -ensuring reproducibility and security. - -When a fixed-output dependency changes, so will its hash, causing a hash mismatch error in Nix. -This generally happens in two scenarios: -1. Intentional updates: You upgrade a package version -2. Upstream changes: The upstream package was modified without a version change - -If you encounter a hash mismatch without updating anything it should probably be investigated: it means -the external source changed unexpectedly. - -#### pnpm Dependencies - -This only applies to the `frontend` package. - -The following error occurs when a dependency has changed but the Nix hash has not: -``` -> ERR_PNPM_NO_OFFLINE_TARBALL  A package is missing from the store but cannot download it in offline mode. The missing package may be downloaded from https://registry.npmjs.org/@automerge/prosemirror/-/prosemirror-0.2.0-alpha.0.tgz. -> ERROR: pnpm failed to install dependencies -``` - -In this case you can follow the instructions given below the error message: -``` -> If you see ERR_PNPM_NO_OFFLINE_TARBALL above this, follow these to fix the issue: -> 1. Set pnpmDeps.hash to "" (empty string) -> 2. Build the derivation and wait for it to fail with a hash mismatch -> 3. Copy the 'got: sha256-' value back into the pnpmDeps.hash field -``` - -The hash is located in `packages/frontend/default.nix` within the `pkgs.fetchPnpmDeps` block. -You can search for the text "hash" to find it quickly. - -The frontend package can be built by running the command `nix build .#frontend` in the repository root. -This will build the minimum needed to print the hash mismatch described in the instructions. - - -#### Other Dependencies - -Dependencies other than `pnpm` will have hash mismatch errors that look like this: -``` -error: hash mismatch in fixed-output derivation '/nix/store/9ydq26vqirys8i3p9yx2ljxj8l9ynlgs-wasm-bindgen-cli-0.2.105.tar.gz.drv': - specified: sha256-M6WuGl7EruNopHZbqBpucu4RWz44/MSdv6f0zkYw+44= - got: sha256-zLPFFgnqAWq5R2KkaTGAYqVQswfBEYm9x3OPjx8DJRY= -``` - -These can be fixed by finding the `specified` hash in the Nix configs and replacing it with the `got` hash. -Currently all hashes except for `pnpm` are defined in `flake.nix` in the repo root. - -##### Dependencies with Hash Mismatches - -You may encounter hash mismatches for these dependencies: -- wasm-bindgen-cli -- rust-toolchain diff --git a/dev-docs/typedoc.json b/dev-docs/typedoc.json index bdbdc0cc5..848e735a3 100644 --- a/dev-docs/typedoc.json +++ b/dev-docs/typedoc.json @@ -2,6 +2,5 @@ "entryPoints": ["./index.ts"], "out": "output", "name": "CatColab: for developers", - "readme": "../CONTRIBUTING.md", - "projectDocuments": ["fixing-hash-mismatches.md"] + "readme": "../CONTRIBUTING.md" } diff --git a/flake.lock b/flake.lock index 900ca5784..12b4e35b8 100644 --- a/flake.lock +++ b/flake.lock @@ -115,6 +115,24 @@ "type": "github" } }, + "flake-utils": { + "inputs": { + "systems": "systems_3" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -234,6 +252,27 @@ "type": "github" } }, + "pnpm2nix-nzbr": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1749022118, + "narHash": "sha256-7Qzmy1snKbxFBKoqUrfyxxmEB8rPxDdV7PQwRiAR01o=", + "owner": "FliegendeWurst", + "repo": "pnpm2nix-nzbr", + "rev": "35f88a41d29839b3989f31871263451c8e092cb1", + "type": "github" + }, + "original": { + "owner": "FliegendeWurst", + "repo": "pnpm2nix-nzbr", + "type": "github" + } + }, "root": { "inputs": { "agenix": "agenix", @@ -241,7 +280,8 @@ "deploy-rs": "deploy-rs", "fenix": "fenix", "nixos-generators": "nixos-generators", - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_4", + "pnpm2nix-nzbr": "pnpm2nix-nzbr" } }, "rust-analyzer-src": { @@ -291,6 +331,21 @@ "type": "github" } }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "utils": { "inputs": { "systems": "systems_2" diff --git a/flake.nix b/flake.nix index 92eddc969..d8288cd81 100644 --- a/flake.nix +++ b/flake.nix @@ -15,6 +15,11 @@ }; nixos-generators.url = "github:nix-community/nixos-generators"; + + pnpm2nix-nzbr = { + url = "github:FliegendeWurst/pnpm2nix-nzbr"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -70,19 +75,64 @@ pkgsLinux = nixpkgsFor linuxSystem; rustToolchainLinux = rustToolchainFor linuxSystem; - craneLib = (crane.mkLib pkgsLinux).overrideToolchain rustToolchainLinux; + # Per-system crane library + prebuilt cargo dependency layer. The + # frontend package is exposed for every system in `devShellSystems` so + # macOS developers can `nix build .#frontend` against the same wasm/api + # chain linux developers use; that requires per-system cargoArtifacts. + craneLibFor = + system: + let + pkgs = nixpkgsFor system; + in + (crane.mkLib pkgs).overrideToolchain (rustToolchainFor system); - cargoArtifacts = craneLib.buildDepsOnly { - src = craneLib.cleanCargoSource ./.; - strictDeps = true; - nativeBuildInputs = [ - pkgsLinux.pkg-config - ]; + cargoArtifactsFor = + system: + let + pkgs = nixpkgsFor system; + craneLib = craneLibFor system; + in + craneLib.buildDepsOnly { + src = craneLib.cleanCargoSource ./.; + strictDeps = true; + nativeBuildInputs = [ + pkgs.pkg-config + ]; - buildInputs = [ - pkgsLinux.openssl - ]; - }; + buildInputs = [ + pkgs.openssl + ]; + }; + + craneLib = craneLibFor linuxSystem; + cargoArtifacts = cargoArtifactsFor linuxSystem; + + # Minimal devShell for the ui-components Playwright/vitest tests. + # Provides node + pnpm and points Playwright at the nixpkgs-managed + # browser bundle so `pnpm install` can skip browser downloads in CI. + # Kept separate from the main devShell to avoid pulling + # `playwright-driver.browsers` (~hundreds of MB) into the default shell. + uiComponentsTestsShellForSystem = + system: + let + pkgs = nixpkgsFor system; + in + pkgs.mkShell { + name = "catcolab-ui-components-tests"; + + packages = with pkgs; [ + nodejs_24 + pnpm + playwright-driver.browsers + ]; + + # See https://wiki.nixos.org/wiki/Playwright. The npm `playwright` + # version (1.56.1, packages/ui-components/pnpm-lock.yaml) must match + # `pkgs.playwright-driver.version`; bump both together. + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + }; # Generate devShells for each system devShellForSystem = @@ -187,6 +237,7 @@ name = system; value = { default = devShellForSystem system; + ui-components-tests = uiComponentsTestsShellForSystem system; }; }) devShellSystems ); @@ -194,90 +245,103 @@ # Example of how to build and test individual package built by nix: # nix build .#packages.x86_64-linux.automerge # node ./result/main.cjs - packages = { - x86_64-linux = { - catcolabApi = pkgsLinux.callPackage ./infrastructure/catcolab-api.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - }; - - backend = pkgsLinux.callPackage ./packages/backend/default.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - }; - - migrator = pkgsLinux.callPackage ./packages/migrator/default.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - }; - - catlog-wasm-browser = pkgsLinux.callPackage ./packages/catlog-wasm/default.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - }; - - document-types-wasm = pkgsLinux.callPackage ./packages/document-types/default.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - }; + # + # The frontend and its rust-derived dependencies (catcolabApi, + # catlog-wasm-browser, document-types-wasm) are exposed for every + # `devShellSystems` entry so macOS developers can `nix build .#frontend` + # natively. Linux-only outputs (backend, migrator, julia-fhs, rust-docs, + # catcolab-vm) stay under packages.x86_64-linux only. + packages = + let + frontendChainFor = + system: + let + pkgs = nixpkgsFor system; + craneArgs = { + craneLib = craneLibFor system; + cargoArtifacts = cargoArtifactsFor system; + inherit pkgs; + }; + frontendPackage = pkgs.callPackage ./packages/frontend/default.nix { + inherit inputs self; + rustToolchain = rustToolchainFor system; + pnpm2nix = inputs.pnpm2nix-nzbr; + }; + in + { + catcolabApi = pkgs.callPackage ./infrastructure/catcolab-api.nix craneArgs; + catlog-wasm-browser = pkgs.callPackage ./packages/catlog-wasm/default.nix craneArgs; + document-types-wasm = pkgs.callPackage ./packages/document-types/default.nix craneArgs; + frontend = frontendPackage.package; + frontend-tests = frontendPackage.tests; + }; - julia-fhs = (pkgsLinux.callPackage ./infrastructure/julia.nix { }).julia-fhs; + linuxOnlyPackages = { + backend = pkgsLinux.callPackage ./packages/backend/default.nix { + inherit craneLib cargoArtifacts; + pkgs = pkgsLinux; + }; - frontend = - (pkgsLinux.callPackage ./packages/frontend/default.nix { - inherit inputs rustToolchainLinux self; - }).package; + migrator = pkgsLinux.callPackage ./packages/migrator/default.nix { + inherit craneLib cargoArtifacts; + pkgs = pkgsLinux; + }; - frontend-tests = - (pkgsLinux.callPackage ./packages/frontend/default.nix { - inherit inputs rustToolchainLinux self; - }).tests; + julia-fhs = (pkgsLinux.callPackage ./infrastructure/julia.nix { }).julia-fhs; - rust-docs = pkgsLinux.callPackage ./infrastructure/rust-docs.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - }; + rust-docs = pkgsLinux.callPackage ./infrastructure/rust-docs.nix { + inherit craneLib cargoArtifacts; + pkgs = pkgsLinux; + }; - rust-docs-check = pkgsLinux.callPackage ./infrastructure/rust-docs.nix { - inherit craneLib cargoArtifacts; - pkgs = pkgsLinux; - checkMode = true; - }; + rust-docs-check = pkgsLinux.callPackage ./infrastructure/rust-docs.nix { + inherit craneLib cargoArtifacts; + pkgs = pkgsLinux; + checkMode = true; + }; - # VMs built with `nixos-rebuild build-vm` (like `nix build - # .#nixosConfigurations.catcolab-vm.config.system.build.vm`) are not the same - # as "traditional" VMs, which causes deploy-rs to fail when deploying to them. - # https://github.com/serokell/deploy-rs/issues/85#issuecomment-885782350 - # - # This is worked around by creating a full featured VM image. - # - # use: - # nix build .#catcolab-vm - # cp result/catcolab-vm.qcow2 catcolab-vm.qcow2 - # db-utils vm start - # deploy -s .#catcolab-vm - catcolab-vm = pkgsLinux.stdenv.mkDerivation { - name = "catcolab-vm"; - src = nixos-generators.nixosGenerate { - system = "x86_64-linux"; - format = "qcow"; - - modules = [ - ./infrastructure/hosts/catcolab-vm - ]; - - specialArgs = { - inherit inputs self; - rustToolchain = rustToolchainLinux; + # VMs built with `nixos-rebuild build-vm` (like `nix build + # .#nixosConfigurations.catcolab-vm.config.system.build.vm`) are not the same + # as "traditional" VMs, which causes deploy-rs to fail when deploying to them. + # https://github.com/serokell/deploy-rs/issues/85#issuecomment-885782350 + # + # This is worked around by creating a full featured VM image. + # + # use: + # nix build .#catcolab-vm + # cp result/catcolab-vm.qcow2 catcolab-vm.qcow2 + # db-utils vm start + # deploy -s .#catcolab-vm + catcolab-vm = pkgsLinux.stdenv.mkDerivation { + name = "catcolab-vm"; + src = nixos-generators.nixosGenerate { + system = "x86_64-linux"; + format = "qcow"; + + modules = [ + ./infrastructure/hosts/catcolab-vm + ]; + + specialArgs = { + inherit inputs self; + rustToolchain = rustToolchainLinux; + }; }; + installPhase = '' + mkdir -p $out + cp $src/nixos.qcow2 $out/catcolab-vm.qcow2 + ''; }; - installPhase = '' - mkdir -p $out - cp $src/nixos.qcow2 $out/catcolab-vm.qcow2 - ''; }; - }; - }; + in + builtins.listToAttrs ( + map (system: { + name = system; + value = + frontendChainFor system + // pkgsLinux.lib.optionalAttrs (system == linuxSystem) linuxOnlyPackages; + }) devShellSystems + ); # Create a NixOS configuration for each host nixosConfigurations = { diff --git a/packages/algjulia-interop/README.md b/packages/algjulia-interop/README.md index dd37a4707..0517bb484 100644 --- a/packages/algjulia-interop/README.md +++ b/packages/algjulia-interop/README.md @@ -3,7 +3,7 @@ This small package makes functionality from [AlgebraicJulia](https://www.algebraicjulia.org/) available to CatColab. At this time, only a [Catlab.jl](https://github.com/AlgebraicJulia/Catlab.jl) service is -provided. Other packages (e.g. Decapodes.jl) will be added in the future. +provided. Other packages (e.g. [Decapodes.jl](https://github.com/AlgebraicJulia/Decapodes.jl))) will be added in the future. ## Usage diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 81a517a70..7dc548d7f 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -11,7 +11,7 @@ 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::{QualifiedLabel, name}; +use catlog::zero::name; use super::model_morphism::{MotifOccurrence, MotifsOptions, motifs}; use super::result::JsResult; @@ -94,19 +94,13 @@ impl ThSchema { pub fn render_sql(&self, model: &DblModel, backend: &str) -> JsResult { analyses::sql::SQLBackend::try_from(backend) .and_then(|backend| { - analyses::sql::SQLAnalysis::new(backend).render( - model.discrete()?, - |id| { - model - .ob_generator_label(id) - .unwrap_or_else(|| QualifiedLabel::single("".into())) - }, - |id| { - model - .mor_generator_label(id) - .unwrap_or_else(|| QualifiedLabel::single("".into())) - }, - ) + analyses::sql::SQLAnalysis::new(backend) + .render( + model.discrete()?, + |id| model.ob_namespace.label_string(id), + |id| model.mor_namespace.label_string(id), + ) + .map_err(|e| format!("{}", e)) }) .into() } diff --git a/packages/catlog/src/one/graph_algorithms.rs b/packages/catlog/src/one/graph_algorithms.rs index 3ef0d58ce..576e77570 100644 --- a/packages/catlog/src/one/graph_algorithms.rs +++ b/packages/catlog/src/one/graph_algorithms.rs @@ -1,6 +1,8 @@ //! Algorithms on graphs. use derivative::Derivative; +use derive_more::Constructor; +use indexmap::IndexMap; use std::collections::{HashMap, HashSet, VecDeque}; use std::hash::Hash; @@ -301,14 +303,44 @@ where } } +/// Contains both the topologically-sorted stack of vertices and feedback vertices. +#[derive(Debug, Clone, Constructor)] +pub struct ToposortData { + /// Stores an array of topologically-sorted vertices. + pub stack: Vec, + + /// Stores the feedback vertices with their outneighbors. + pub cycles: IndexMap>, +} + +type ToposortResult = Result, V>; + +/// Implementation of topological sort which returns an error when it encounters a cycle. +pub fn toposort_strict(graph: &G) -> Result, G::V> +where + G: FinGraph, + G::V: Hash + std::fmt::Debug, +{ + toposort_impl(graph, true).map(|t| t.stack) +} + +/// Implementation of topological sort which does not return an error when it encounters cycle. +pub fn toposort_lenient(graph: &G) -> ToposortData +where + G: FinGraph, + G::V: Hash + std::fmt::Debug, +{ + toposort_impl(graph, false).expect("toposort in lenient mode should return a valid result") +} + /// Computes a topological sorting for a given graph. /// /// This toposort algorithm was adapted from the crate `petgraph`, found /// [here](https://github.com/petgraph/petgraph/blob/4d807c19304c02c9dd687c68577f75aefcb98491/src/algo/mod.rs#L204). -pub fn toposort<'a, G>(graph: &'a G) -> Result, String> +fn toposort_impl(graph: &G, is_strict: bool) -> ToposortResult where G: FinGraph, - G::V: Hash + std::fmt::Debug + 'a, + G::V: Hash + std::fmt::Debug, { let mut finished = HashSet::new(); let mut finish_stack = Vec::new(); @@ -334,17 +366,23 @@ where // simply test directly by comparing positions of vertices. let position: HashMap = finish_stack.iter().enumerate().map(|(i, v)| (v.clone(), i)).collect(); + let mut cycles = IndexMap::new(); for e in graph.edges() { let s = graph.src(&e); let t = graph.tgt(&e); // Note that we did a DFS starting at every vertex, so it's impossible // that they don't appear _somewhere_ in our map. if position[&s] >= position[&t] { - return Err(format!("Cycle detected involving node {:#?}", s)); + if is_strict { + return Err(s); + } else { + let outs = graph.out_neighbors(&s).collect(); + cycles.insert(s, outs); + } } } - Ok(finish_stack) + Ok(ToposortData::new(finish_stack, cycles)) } #[cfg(test)] @@ -393,24 +431,26 @@ mod tests { #[test] fn toposorting() { let g = SkelGraph::path(5); - assert_eq!(toposort(&g), Ok(vec![0, 1, 2, 3, 4])); + let result = toposort_strict(&g); + assert_eq!(result.unwrap(), vec![0, 1, 2, 3, 4]); let mut g = SkelGraph::path(3); g.add_vertices(1); g.add_edge(2, 3); g.add_edge(3, 0); - expect_test::expect!["Cycle detected involving node 3"] - .assert_eq(&toposort(&g).unwrap_err()); + let t = &toposort_strict(&g).unwrap_err(); + expect_test::expect!["3"].assert_eq(&format!("{t}")); let g = SkelGraph::triangle(); - assert_eq!(toposort(&g), Ok(vec![0, 1, 2])); + assert_eq!(toposort_strict(&g).unwrap(), vec![0, 1, 2]); let mut g = SkelGraph::path(4); g.add_vertices(2); g.add_edge(1, 4); g.add_edge(4, 3); g.add_edge(5, 2); - assert_eq!(toposort(&g), Ok(vec![5, 0, 1, 2, 4, 3])); + + assert_eq!(toposort_strict(&g).unwrap(), vec![5, 0, 1, 2, 4, 3]); let mut g: HashGraph<_, _> = Default::default(); g.add_vertices(vec![0, 1, 2, 3, 4, 5]); @@ -420,9 +460,10 @@ mod tests { g.add_edge("1-4", 1, 4); g.add_edge("4-3", 4, 3); g.add_edge("5-2", 5, 2); - let sort = toposort(&g).unwrap(); - let (i0, i1) = (sort.iter().position(|&x| x == 5), sort.iter().position(|&x| x == 2)); - assert!(i0.unwrap() < i1.unwrap()); + if let Ok(sort) = toposort_strict(&g) { + let (i0, i1) = (sort.iter().position(|&x| x == 5), sort.iter().position(|&x| x == 2)); + assert!(i0.unwrap() < i1.unwrap()); + } } #[test] diff --git a/packages/catlog/src/stdlib/analyses/sql.rs b/packages/catlog/src/stdlib/analyses/sql.rs index 8bbc10760..587835670 100644 --- a/packages/catlog/src/stdlib/analyses/sql.rs +++ b/packages/catlog/src/stdlib/analyses/sql.rs @@ -1,14 +1,20 @@ //! Produces a valid SQL data manipulation script from a model in the theory of schemas. use crate::{ dbl::model::*, - one::{Path, graph::FinGraph, graph_algorithms::toposort}, - zero::{QualifiedLabel, QualifiedName, label, name}, + one::{ + Path, + graph::FinGraph, + graph_algorithms::{ToposortData, toposort_lenient}, + }, + zero::{QualifiedLabel, QualifiedName, name}, }; +use derive_more::Constructor; use indexmap::IndexMap; use itertools::Itertools; +use nonempty::nonempty; use sea_query::SchemaBuilder; use sea_query::{ - ColumnDef, ForeignKey, ForeignKeyCreateStatement, Iden, MysqlQueryBuilder, + Alias, ColumnDef, ForeignKey, ForeignKeyCreateStatement, Iden, MysqlQueryBuilder, PostgresQueryBuilder, SqliteQueryBuilder, Table, TableCreateStatement, prepare::Write, }; use sqlformat::{Dialect, format}; @@ -32,35 +38,188 @@ impl Iden for &QualifiedLabel { } } +/// Enum for specifying the behavior of a column. For example, an Ordinary column is simply +/// a foreign key constraint. +#[derive(Debug, Clone, PartialEq)] +pub enum ColumnType { + /// A foreign key constraint. The target is an entity. + Ordinary { + /// The name of the morphism. + mor: QualifiedName, + /// The name of the target entity. + tgt: QualifiedName, + }, + /// A deferrable key constraint. The target is an entity. + Deferrable { + /// The name of the morphism. + mor: QualifiedName, + /// The name of the target entity. + tgt: QualifiedName, + }, + /// An attribute column. The target is an attribute type. + Attribute { + /// The name of the morphism. + mor: QualifiedName, + /// The name of the target attribute. + tgt: QualifiedName, + }, +} + +impl ColumnType { + fn build( + model: &DiscreteDblModel, + cycles: &IndexMap>, + src: &QualifiedName, + mor: QualifiedName, + ) -> Self { + let tgt = model.get_cod(&mor).unwrap(); + match model.mor_generator_type(&mor) { + t if t == Path::Seq(nonempty![name("Attr")]) => { + ColumnType::Attribute { mor, tgt: tgt.clone() } + } + _ => { + if cycles.contains_key(src) || cycles.contains_key(&tgt.clone()) { + ColumnType::Deferrable { mor, tgt: tgt.clone() } + } else { + ColumnType::Ordinary { mor, tgt: tgt.clone() } + } + } + } + } + + fn mor(&self) -> &QualifiedName { + match self { + ColumnType::Ordinary { mor, tgt: _ } + | ColumnType::Deferrable { mor, tgt: _ } + | ColumnType::Attribute { mor, tgt: _ } => mor, + } + } + + fn tgt(&self) -> &QualifiedName { + match self { + ColumnType::Ordinary { mor: _, tgt } + | ColumnType::Deferrable { mor: _, tgt } + | ColumnType::Attribute { mor: _, tgt } => tgt, + } + } + + /// The function creates foreign key constraints for PostgresSQL. Here, deferrable key + /// constraints are special. + fn render_postgres_fk( + &self, + src: &QualifiedName, + ob_label: impl Fn(&QualifiedName) -> String, + mor_label: impl Fn(&QualifiedName) -> String, + ) -> String { + let fk = |src: String, mor: &String, tgt: &String| -> String { + format!( + r#"ALTER TABLE "{src}" + ADD CONSTRAINT fk_{mor}_{src}_{tgt} + FOREIGN KEY ({mor}) REFERENCES "{tgt}" (id)"# + ) + }; + match self { + ColumnType::Ordinary { mor, tgt } => { + fk(ob_label(src), &mor_label(mor), &ob_label(tgt)) + ";" + } + ColumnType::Deferrable { mor, tgt } => { + fk(ob_label(src), &mor_label(mor), &ob_label(tgt)) + + "\n" + + r#"DEFERRABLE INITIALLY DEFERRED;"# + } + // this is unreachable, since attributes cannot be foreign keys. + ColumnType::Attribute { mor: _, tgt: _ } => unreachable!(), + } + } +} + +/// Data containing foreign key constraints and their behavior, which are interpreted as +/// backend-specific attributes. +#[derive(Clone, Debug)] +pub struct ForeignKeyConstraints { + /// Foreign key constraints for every table. + fks: IndexMap>, +} + +impl ForeignKeyConstraints { + fn new(model: &DiscreteDblModel) -> Self { + let g = model.generating_graph(); + let toposort: ToposortData = toposort_lenient(g); + let cycles = toposort.cycles; + let fks = IndexMap::from_iter(toposort.stack.into_iter().rev().filter_map(|v| { + (name("Entity") == model.ob_generator_type(&v)).then_some(( + v.clone(), + g.out_edges(&v) + .map(|e| ColumnType::build(model, &cycles, &v, e)) + .collect::>(), + )) + })); + Self { fks } + } + + fn any_deferrable(&self) -> bool { + self.fks + .values() + .flatten() + .into_iter() + .any(|s| matches!(s, ColumnType::Deferrable { mor: _, tgt: _ })) + } +} + +/// Error thrown when the SQL Analysis fails. +#[derive(Clone, Debug, PartialEq)] +pub enum SQLAnalysisError { + /// Its possible that a SQL backend cannot support cyclic foreign key constraints. + CyclicForeignKeyError { + /// The SQL backend that fails. Of the supported SQL backends, MySQL is the only one which + /// does not support cyclic foreign key constraints. + backend: SQLBackend, + /// The tables which have failing foreign key constraints. + cycles: Vec<(QualifiedName, ColumnType)>, + }, +} + +impl std::fmt::Display for SQLAnalysisError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { + match self { + SQLAnalysisError::CyclicForeignKeyError { backend, cycles } => write!( + f, + "Cycle detected at tables {:#?}. {backend} cannot support cyclic foreign keys.", + cycles + ), + } + } +} + /// Struct for building a valid SQL DDL. +#[derive(Constructor)] pub struct SQLAnalysis { backend: SQLBackend, } impl SQLAnalysis { - /// Constructs a new SQLAnalysis instance. - pub fn new(backend: SQLBackend) -> Self { - Self { backend } + /// Returns formatted output. + pub fn format(&self, output: &str) -> String { + format( + output, + &sqlformat::QueryParams::None, + &sqlformat::FormatOptions { + lines_between_queries: 2, + dialect: self.backend.clone().into(), + ..Default::default() + }, + ) } - /// Consumes itself and a discrete double model to produce a SQL string. - pub fn render( + /// Builds table statements into valid SQL DML. + fn build( &self, - model: &DiscreteDblModel, - ob_label: impl Fn(&QualifiedName) -> QualifiedLabel, - mor_label: impl Fn(&QualifiedName) -> QualifiedLabel, - ) -> Result { - let g = model.generating_graph(); - let t = toposort(g).map_err(|e| format!("Topological sort failed: {}", e))?; - let morphisms: IndexMap<&QualifiedName, Vec> = - IndexMap::from_iter(t.iter().rev().filter_map(|v| { - (name("Entity") == model.ob_generator_type(v)) - .then_some((v, g.out_edges(v).collect::>())) - })); - - let tables = self.make_tables(model, morphisms, ob_label, mor_label); - - let output: String = tables + tables: Vec, + constraints: ForeignKeyConstraints, + ob_label: impl Fn(&QualifiedName) -> String, + mor_label: impl Fn(&QualifiedName) -> String, + ) -> String { + let table_def: String = tables .iter() .map(|table| match self.backend { SQLBackend::MySQL => table.to_string(MysqlQueryBuilder), @@ -70,80 +229,132 @@ impl SQLAnalysis { .join(";\n") + ";"; - // TODO SQL analysis should interface with this - let formatted_output = format( - &output, - &sqlformat::QueryParams::None, - &sqlformat::FormatOptions { - lines_between_queries: 2, - dialect: self.backend.clone().into(), - ..Default::default() - }, - ); + // for PostgresSQL only + let deferrable_fks: String = constraints + .fks + .iter() + .flat_map(|(ob, mors)| { + mors.iter() + .filter(|fkb| matches!(fkb, ColumnType::Deferrable { mor: _, tgt: _ })) + .map(|fkb| fkb.render_postgres_fk(ob, &ob_label, &mor_label)) + .collect::>() + }) + .join("\n"); - let result = match self.backend { - SQLBackend::SQLite => ["PRAGMA foreign_keys = ON", &formatted_output].join(";\n\n"), - _ => formatted_output, - }; - Ok(result) + table_def + &deferrable_fks } - fn fk( + fn validate_toposort( &self, - src_name: QualifiedLabel, - tgt_name: QualifiedLabel, - mor_name: QualifiedLabel, - ) -> ForeignKeyCreateStatement { + constraints: ForeignKeyConstraints, + ) -> Result { + // TODO: punting fixing SQLite cycles for now + if (self.backend == SQLBackend::MySQL || self.backend == SQLBackend::SQLite) + && constraints.any_deferrable() + { + let cycles = constraints + .fks + .into_iter() + .flat_map(|(k, v)| v.into_iter().map(move |e| (k.clone(), e))) + .filter(|(_, e)| matches!(e, ColumnType::Deferrable { mor: _, tgt: _ })) + .collect::>(); + Err(SQLAnalysisError::CyclicForeignKeyError { backend: self.backend.clone(), cycles }) + } else { + Ok(constraints) + } + } + + fn toposort_morphisms( + &self, + model: &DiscreteDblModel, + ) -> Result { + // if a morphism is a key in toposort.cycles, then its source and targets are deferrable. + let constraints = ForeignKeyConstraints::new(model); + self.validate_toposort(constraints) + } + + /// Consumes itself and a discrete double model to produce a SQL string. + pub fn render( + &self, + model: &DiscreteDblModel, + ob_label: impl Fn(&QualifiedName) -> String, + mor_label: impl Fn(&QualifiedName) -> String, + ) -> Result { + let constraints = self.toposort_morphisms(model); + let tables = self.make_tables(model, constraints.clone()?, &ob_label, &mor_label); + let output: String = self.build(tables, constraints.clone()?, ob_label, mor_label); + let formatted_output = self.format(&output); + // pragmas + match self.backend { + SQLBackend::SQLite => Ok(["PRAGMA foreign_keys = ON", &formatted_output].join(";\n\n")), + _ => Ok(formatted_output), + } + } + + fn fk(&self, src: &str, tgt: &str, mor: &str) -> ForeignKeyCreateStatement { ForeignKey::create() - .name(format!("FK_{}_{}_{}", mor_name, src_name, tgt_name)) - .from(src_name.clone(), mor_name) - .to(tgt_name.clone(), "id") + .name(format!("FK_{}_{}_{}", mor, src, tgt)) + .from(Alias::new(src), Alias::new(mor)) + .to(Alias::new(tgt), "id") .to_owned() } fn make_tables( &self, model: &DiscreteDblModel, - morphisms: IndexMap<&QualifiedName, Vec>, - ob_label: impl Fn(&QualifiedName) -> QualifiedLabel, - mor_label: impl Fn(&QualifiedName) -> QualifiedLabel, + constraints: ForeignKeyConstraints, + ob_label: impl Fn(&QualifiedName) -> String, + mor_label: impl Fn(&QualifiedName) -> String, ) -> Vec { - morphisms + constraints + .fks .into_iter() .map(|(ob, mors)| { let mut tbl = Table::create(); // the targets for arrows let table_column_defs = mors.iter().fold( - tbl.table(ob_label(ob)).if_not_exists().col( + tbl.table(Alias::new(ob_label(&ob))).if_not_exists().col( ColumnDef::new("id").integer().not_null().auto_increment().primary_key(), ), |acc, mor| { - let mor_name = mor_label(mor); + let mor_tgt = mor.tgt(); + let ob_name = ob_label(mor_tgt); + let mor_name = mor_label(mor.mor()); // if the Id of the name is an entity, it is assumed to be a column // which references the primary key of another table. - if model.mor_generator_type(mor) == Path::Id(name("Entity")) { - acc.col(ColumnDef::new(mor_name.clone()).integer().not_null()) + if model.mor_generator_type(mor.mor()) == Path::Id(name("Entity")) { + acc.col( + ColumnDef::new(Alias::new(mor_name.as_str())).integer().not_null(), + ) } else { - let tgt = - model.get_cod(mor).map(&ob_label).unwrap_or_else(|| label("")); - let mut col = ColumnDef::new(mor_name); + let mut col = ColumnDef::new(Alias::new(mor_name.as_str())); col.not_null(); - add_column_type(&mut col, &tgt); + add_column_type(&mut col, ob_name.as_str()); acc.col(col) } }, ); mors.iter() - .filter(|mor| model.mor_generator_type(mor) == Path::Id(name("Entity"))) + .filter(|mor| { + (model.mor_generator_type(mor.mor()) == Path::Id(name("Entity"))) + && (if self.backend == SQLBackend::PostgresSQL { + matches!(mor, ColumnType::Ordinary { mor: _, tgt: _ }) + } else { + true + }) + }) .fold( // TABLE AND COLUMN DEFS table_column_defs, |acc, mor| { - let tgt = - model.get_cod(mor).map(&ob_label).unwrap_or_else(|| label("")); - acc.foreign_key(&mut self.fk(ob_label(ob), tgt, mor_label(mor))) + // if there is a cyclic pattern, we want to add deferrable... + acc.foreign_key(&mut self.fk( + ob_label(&ob).as_str(), + ob_label(mor.tgt()).as_str(), + mor_label(mor.mor()).as_str(), + )) }, ) .to_owned() @@ -155,7 +366,7 @@ impl SQLAnalysis { /// Variants of SQL backends. Each correspond to types which implement the /// `SchemaBuilder` trait that is used to render into the correct backend. The `SchemaBuilder` and /// the types implementing that trait are owned by `sea_query`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum SQLBackend { /// The MySQL backend. MySQL, @@ -210,8 +421,8 @@ impl fmt::Display for SQLBackend { } } -fn add_column_type(col: &mut ColumnDef, name: &QualifiedLabel) { - match format!("{}", name).as_str() { +fn add_column_type(col: &mut ColumnDef, label: &str) { + match label { "Int" => col.integer(), "TinyInt" => col.tiny_integer(), "Bool" => col.boolean(), @@ -219,7 +430,7 @@ fn add_column_type(col: &mut ColumnDef, name: &QualifiedLabel) { "Time" => col.timestamp(), "Date" => col.date(), "DateTime" => col.date_time(), - _ => col.custom(name.clone()), + _ => col.custom(Alias::new(label)), }; } @@ -234,17 +445,17 @@ mod tests { #[test] fn sql_schema() { let th = Rc::new(th_schema()); - let model = tt::modelgen::Model::from_text( - &th.into(), - "[ + let source = "[ Person : Entity, Dog : Entity, walks : (Hom Entity)[Person, Dog], Hair : AttrType, has : Attr[Person, Hair], - ]", - ); - let model = model.unwrap().as_discrete().unwrap(); + ]"; + let model = tt::modelgen::Model::from_text(&th.clone().into(), source) + .ok() + .and_then(|m| m.as_discrete()) + .unwrap(); let expected = expect![[ r#"CREATE TABLE IF NOT EXISTS `Dog` (`id` int NOT NULL AUTO_INCREMENT PRIMARY KEY); @@ -265,4 +476,96 @@ CREATE TABLE IF NOT EXISTS `Person` ( .expect("SQL should render"); expected.assert_eq(&ddl); } + + #[test] + fn sql_postgres_cycles() { + let th = Rc::new(th_schema()); + let source = "[ + Refs : Entity, + Snapshots : Entity, + head : (Hom Entity)[Refs, Snapshots], + for_ref: (Hom Entity)[Snapshots, Refs], + Timestamp : AttrType, + created : Attr[Refs, Timestamp], + last_updated: Attr[Snapshots, Timestamp], + ]"; + let model = tt::modelgen::Model::from_text(&th.into(), source) + .ok() + .and_then(|m| m.as_discrete()) + .unwrap(); + + let expected = expect![[r#"CREATE TABLE IF NOT EXISTS "Snapshots" ( + "id" serial NOT NULL PRIMARY KEY, + "for_ref" integer NOT NULL, + "last_updated" Timestamp NOT NULL +); + +CREATE TABLE IF NOT EXISTS "Refs" ( + "id" serial NOT NULL PRIMARY KEY, + "head" integer NOT NULL, + "created" Timestamp NOT NULL +); + +ALTER TABLE + "Snapshots" +ADD + CONSTRAINT fk_for_ref_Snapshots_Refs FOREIGN KEY (for_ref) REFERENCES "Refs" (id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE + "Refs" +ADD + CONSTRAINT fk_head_Refs_Snapshots FOREIGN KEY (head) REFERENCES "Snapshots" (id) DEFERRABLE INITIALLY DEFERRED;"#]]; + let ddl = SQLAnalysis::new(SQLBackend::PostgresSQL) + .render( + &model, + |id| format!("{id}").as_str().into(), + |id| format!("{id}").as_str().into(), + ) + .expect("SQL should render"); + expected.assert_eq(&ddl); + } + + #[test] + fn sql_mysql_cycles() { + let th = Rc::new(th_schema()); + let source = "[ + Refs : Entity, + Snapshots : Entity, + head : (Hom Entity)[Refs, Snapshots], + for_ref: (Hom Entity)[Snapshots, Refs], + Timestamp : AttrType, + created : Attr[Refs, Timestamp], + last_updated: Attr[Snapshots, Timestamp], + ]"; + let model = tt::modelgen::Model::from_text(&th.into(), source) + .ok() + .and_then(|m| m.as_discrete()) + .unwrap(); + + let ddl = SQLAnalysis::new(SQLBackend::MySQL).render( + &model, + |id| format!("{id}").as_str().into(), + |id| format!("{id}").as_str().into(), + ); + let e = ddl.unwrap_err(); + assert_eq!( + e, + SQLAnalysisError::CyclicForeignKeyError { + backend: SQLBackend::MySQL, + cycles: vec![ + ( + name("Snapshots"), + ColumnType::Deferrable { mor: name("for_ref"), tgt: name("Refs") } + ), + ( + name("Refs"), + ColumnType::Deferrable { + mor: name("head"), + tgt: name("Snapshots") + } + ) + ] + } + ); + } } diff --git a/packages/document-types/src/common_test.rs b/packages/document-types/src/common_test.rs index 8cc6045d0..837b1f7ab 100644 --- a/packages/document-types/src/common_test.rs +++ b/packages/document-types/src/common_test.rs @@ -23,6 +23,7 @@ pub fn doc_to_json(doc: &Automerge) -> Value { } /// Roundtrip a JSON object through Automerge and back. +#[cfg(feature = "property-tests")] pub fn roundtrip_json(json: &Value) -> Value { let doc = doc_from_json(json); doc_to_json(&doc) diff --git a/packages/frontend/README.md b/packages/frontend/README.md index 1403ad7bd..16dd48bf3 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -31,16 +31,3 @@ where `$MODE` is replaced with one of the following: Running the command above builds the Wasm and other local dependencies (by running `pnpm run build:deps`) before launching the Vite preview server. - -## Troubleshooting - -### Nix Hash Mismatches - -If this package fails to build in Nix with the error: - -``` -> ERROR: pnpm failed to install dependencies -``` - -Refer to the "pnpm Dependencies" section in [Fixing Hash Mismatches in -Nix](../../dev-docs/fixing-hash-mismatches.md). diff --git a/packages/frontend/default.nix b/packages/frontend/default.nix index 79c6b963b..d898dffec 100644 --- a/packages/frontend/default.nix +++ b/packages/frontend/default.nix @@ -2,6 +2,7 @@ pkgs, self, lib, + pnpm2nix, ... }: let @@ -9,8 +10,61 @@ let name = packageJson.name; version = packageJson.version; + # pnpm2nix-nzbr exposes a `processLockfile` function in lockfile.nix that, given a + # pnpm-lock.yaml, produces: + # - dependencyTarballs: a list of FODs (fetchurl/fetchGit/...) for each + # dependency tarball referenced by the lockfile. + # - patchedLockfile: an in-memory copy of the lockfile with every + # `resolution.tarball` rewritten to point at the local nix store tarball. + # + # We use these to drive an offline `pnpm install --frozen-lockfile`, which + # avoids having to maintain a monolithic `pnpmDeps.hash` by hand: every + # individual tarball is hashed via the integrity field already present in + # the lockfile. + lockfileLib = pkgs.callPackage (pnpm2nix + "/lockfile.nix") { }; + + processLock = lockfile: lockfileLib.processLockfile { + registry = "https://registry.npmjs.org"; + noDevDependencies = false; + inherit lockfile; + }; + + # The frontend package and every sibling workspace member it pulls in via + # `link:` need their lockfiles processed: each lockfile is rewritten to + # reference nix-store tarballs, and the union of all dependency tarballs + # is fed to `pnpm store add` so the offline install can resolve everything. + # Because `.npmrc` sets `shared-workspace-lockfile=false`, each workspace + # member has its own pnpm-lock.yaml that must be processed independently. + lockfilesToProcess = { + "packages/frontend" = ../frontend/pnpm-lock.yaml; + "packages/ui-components" = ../ui-components/pnpm-lock.yaml; + "packages/document-methods" = ../document-methods/pnpm-lock.yaml; + "packages/backend/pkg" = ../backend/pkg/pnpm-lock.yaml; + "tools/vite-plugin-monorepo-dedupe" = ../../tools/vite-plugin-monorepo-dedupe/pnpm-lock.yaml; + }; + + processedLocks = lib.mapAttrs (_: processLock) lockfilesToProcess; + + yamlFormat = pkgs.formats.yaml { }; + + patchedLockfiles = lib.mapAttrs ( + relPath: processed: + yamlFormat.generate "pnpm-lock-${builtins.replaceStrings [ "/" ] [ "-" ] relPath}.yaml" + processed.patchedLockfile + ) processedLocks; + + allTarballs = lib.unique ( + lib.concatLists (lib.mapAttrsToList (_: p: p.dependencyTarballs) processedLocks) + ); + + # File containing the space-separated list of all dependency tarball paths, + # consumed by `pnpm store add` to seed the offline store. + dependencyTarballsFile = pkgs.runCommand "${name}-dependency-tarballs" { } '' + echo ${lib.concatStringsSep " " allTarballs} > $out + ''; + commonAttrs = { - version = version; + inherit version; # Filter source to only include packages needed for frontend build # This prevents unnecessary rebuilds when unrelated files change src = lib.fileset.toSource { @@ -22,45 +76,55 @@ let (lib.fileset.maybeMissing ../../patches) ../../packages/frontend ../../packages/ui-components + ../../tools/vite-plugin-monorepo-dedupe ../../packages/document-methods ../../packages/backend/pkg ]; }; nativeBuildInputs = with pkgs; [ - pnpm.configHook + nodejs_24 + pnpm ]; buildInputs = with pkgs; [ nodejs_24 ]; - pnpmDeps = pkgs.fetchPnpmDeps { - # see ../../dev-docs/fixing-hash-mismatches.md - hash = "sha256-tIcRNotCTct15zr6DypDALXS+tyQy4bstldHLX5HYys="; - - pname = name; - fetcherVersion = 2; - # Only includes package.json and pnpm-lock.yaml files to ensure consistent hashing in different - # environments - src = lib.fileset.toSource { - root = ../../.; - fileset = lib.fileset.unions [ - ../../.npmrc - ../../pnpm-workspace.yaml - ../../pnpm-lock.yaml - (lib.fileset.maybeMissing ../../patches) - ../../packages/frontend/package.json - ../../packages/frontend/pnpm-lock.yaml - ../../packages/ui-components/package.json - ../../packages/ui-components/pnpm-lock.yaml - ../../packages/document-methods/package.json - ../../packages/document-methods/pnpm-lock.yaml - ../../packages/backend/pkg/package.json - ../../packages/backend/pkg/pnpm-lock.yaml - ]; - }; - }; + # Drive pnpm install ourselves, using the lockfile-derived nix store + # tarballs from pnpm2nix-nzbr instead of pnpm.configHook + fetchPnpmDeps. + # This phase runs from the unpacked source root (monorepo subset). + configurePhase = '' + runHook preConfigure + + export HOME=$NIX_BUILD_TOP + export npm_config_nodedir=${pkgs.nodejs_24} + + # Replace each workspace member's lockfile with the version whose tarball + # references point at /nix/store paths so pnpm doesn't hit the network. + ${lib.concatStringsSep "\n" ( + lib.mapAttrsToList (relPath: patched: '' + cp -fv ${patched} ${relPath}/pnpm-lock.yaml + '') patchedLockfiles + )} + + # Pre-populate the local pnpm content-addressable store with every + # tarball referenced by the patched lockfiles. + store=$(pnpm store path) + mkdir -p "$(dirname "$store")" + pnpm store add $(cat ${dependencyTarballsFile}) + + # Install dependencies for every workspace member. Recursive install + # handles the `link:` deps between members correctly. + pnpm install \ + --recursive \ + --ignore-scripts \ + --force \ + --frozen-lockfile \ + --prefer-offline + + runHook postConfigure + ''; }; package = pkgs.stdenv.mkDerivation ( @@ -69,29 +133,37 @@ let pname = name; buildPhase = '' + runHook preBuild + # Set up catlog-wasm before TypeScript build needs it mkdir -p packages/catlog-wasm/dist/pkg-browser - cp -r ${self.packages.x86_64-linux.catlog-wasm-browser}/* packages/catlog-wasm/dist/pkg-browser/ + cp -r ${self.packages.${pkgs.stdenv.hostPlatform.system}.catlog-wasm-browser}/* packages/catlog-wasm/dist/pkg-browser/ # Set up document-types wasm output mkdir -p packages/document-types/pkg - cp -r ${self.packages.x86_64-linux.document-types-wasm}/* packages/document-types/pkg/ + cp -r ${self.packages.${pkgs.stdenv.hostPlatform.system}.document-types-wasm}/* packages/document-types/pkg/ # Set up generated API bindings mkdir -p packages/backend/pkg/src - cp -r ${self.packages.x86_64-linux.catcolabApi}/src packages/backend/pkg/ + cp -r ${self.packages.${pkgs.stdenv.hostPlatform.system}.catcolabApi}/src packages/backend/pkg/ cd packages/frontend # Generate CSS module type declarations - npm run build:tcm + pnpm run build:tcm # Build with development mode to use .env.development configuration - npm run build -- --mode development + pnpm run build -- --mode development cd - + + runHook postBuild ''; installPhase = '' + runHook preInstall + mkdir -p $out cp -r packages/frontend/dist/* $out + + runHook postInstall ''; } ); @@ -109,25 +181,32 @@ let dontStrip = true; dontPatchShebangs = true; + # No build step; we just package up the tree with node_modules. + dontBuild = true; + installPhase = '' + runHook preInstall + # for vitest to work we need to basically recreate the development environment. We achieve this # by setting of up a copy of the packages structure. mkdir -p $out/packages mkdir -p $out/packages/catlog-wasm/dist/pkg-browser - cp -r ${self.packages.x86_64-linux.catlog-wasm-browser}/* $out/packages/catlog-wasm/dist/pkg-browser/ + cp -r ${self.packages.${pkgs.stdenv.hostPlatform.system}.catlog-wasm-browser}/* $out/packages/catlog-wasm/dist/pkg-browser/ # Set up document-types wasm output before copying to $out mkdir -p packages/document-types/pkg - cp -r ${self.packages.x86_64-linux.document-types-wasm}/* packages/document-types/pkg/ + cp -r ${self.packages.${pkgs.stdenv.hostPlatform.system}.document-types-wasm}/* packages/document-types/pkg/ # Bindings must be copied into source tree BEFORE the cp below copies backend to $out mkdir -p packages/backend/pkg/src - cp -r ${self.packages.x86_64-linux.catcolabApi}/src packages/backend/pkg/ + cp -r ${self.packages.${pkgs.stdenv.hostPlatform.system}.catcolabApi}/src packages/backend/pkg/ cp -r packages/backend $out/packages/ cp -r packages/frontend $out/packages/ cp -r packages/ui-components $out/packages/ + mkdir -p $out/tools + cp -r tools/vite-plugin-monorepo-dedupe $out/tools/ cp -r packages/document-methods $out/packages/ mkdir -p $out/packages/document-types cp -r packages/document-types/pkg $out/packages/document-types/ @@ -183,6 +262,8 @@ let # Wrap the script to ensure nodejs is in PATH makeWrapper $out/bin/.${name}-tests-unwrapped $out/bin/${name}-tests \ --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.nodejs_24 ]} + + runHook postInstall ''; meta.mainProgram = "${name}-tests"; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 7ff9b4bc7..94d962a6b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -52,8 +52,8 @@ "catcolab-document-types": "link:../document-types/pkg", "catcolab-ui-components": "link:../ui-components", "catlog-wasm": "link:../catlog-wasm/dist/pkg-browser", - "echarts": "^5.5.1", - "echarts-solid": "^0.2.0", + "echarts": "^6.1.0", + "echarts-solid": "github:ToposInstitute/echarts-solid#kb/echarts-v6-dist", "elkjs": "^0.11.0", "fast-equals": "^5.2.2", "fast-json-patch": "^3.1.1", @@ -77,6 +77,7 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@catcolab-dev-tools/vite-plugin-monorepo-dedupe": "link:../../tools/vite-plugin-monorepo-dedupe", "@mdx-js/mdx": "^2.3.0", "@types/html-escaper": "^3.0.4", "@types/mdx": "^2.0.13", diff --git a/packages/frontend/pnpm-lock.yaml b/packages/frontend/pnpm-lock.yaml index f76fce41d..e8242be4c 100644 --- a/packages/frontend/pnpm-lock.yaml +++ b/packages/frontend/pnpm-lock.yaml @@ -93,11 +93,11 @@ importers: specifier: link:../catlog-wasm/dist/pkg-browser version: link:../catlog-wasm/dist/pkg-browser echarts: - specifier: ^5.5.1 - version: 5.5.1 + specifier: ^6.1.0 + version: 6.1.0 echarts-solid: - specifier: ^0.2.0 - version: 0.2.0(echarts@5.5.1)(solid-js@1.9.10) + specifier: github:ToposInstitute/echarts-solid#kb/echarts-v6-dist + version: https://codeload.github.com/ToposInstitute/echarts-solid/tar.gz/fa9fd59f51067c3ad3f6aaddb6c62eadad738f8a(echarts@6.1.0)(solid-js@1.9.10) elkjs: specifier: ^0.11.0 version: 0.11.0 @@ -162,6 +162,9 @@ importers: specifier: ^13.0.0 version: 13.0.0 devDependencies: + '@catcolab-dev-tools/vite-plugin-monorepo-dedupe': + specifier: link:../../tools/vite-plugin-monorepo-dedupe + version: link:../../tools/vite-plugin-monorepo-dedupe '@mdx-js/mdx': specifier: ^2.3.0 version: 2.3.0 @@ -1094,28 +1097,33 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/event-listener@2.4.5': + resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/map@0.7.2': resolution: {integrity: sha512-sXK/rS68B4oq3XXNyLrzVhLtT1pnimmMUahd2FqhtYUuyQsCfnW058ptO1s+lWc2k8F/3zQSNVkZ2ifJjlcNbQ==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/refs@1.0.8': - resolution: {integrity: sha512-+jIsWG8/nYvhaCoG2Vg6CJOLgTmPKFbaCrNQKWfChalgUf9WrVxWw0CdJb3yX15n5lUcQ0jBo6qYtuVVmBLpBw==} + '@solid-primitives/refs@1.1.3': + resolution: {integrity: sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/resize-observer@2.0.26': - resolution: {integrity: sha512-KbPhwal6ML9OHeUTZszBbt6PYSMj89d4wVCLxlvDYL4U0+p+xlCEaqz6v9dkCwm/0Lb+Wed7W5T1dQZCP3JUUw==} + '@solid-primitives/resize-observer@2.1.5': + resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/rootless@1.4.5': - resolution: {integrity: sha512-GFJE9GC3ojx0aUKqAUZmQPyU8fOVMtnVNrkdk2yS4kd17WqVSpXpoTmo9CnOwA+PG7FTzdIkogvfLQSLs4lrww==} + '@solid-primitives/rootless@1.5.3': + resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/static-store@0.0.8': - resolution: {integrity: sha512-ZecE4BqY0oBk0YG00nzaAWO5Mjcny8Fc06CdbXadH9T9lzq/9GefqcSe/5AtdXqjvY/DtJ5C6CkcjPZO0o/eqg==} + '@solid-primitives/static-store@0.1.3': + resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==} peerDependencies: solid-js: ^1.6.12 @@ -1129,13 +1137,13 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/utils@6.2.3': - resolution: {integrity: sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==} + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/utils@6.3.2': - resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + '@solid-primitives/utils@6.4.0': + resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} peerDependencies: solid-js: ^1.6.12 @@ -1185,6 +1193,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -1540,15 +1551,16 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - echarts-solid@0.2.0: - resolution: {integrity: sha512-ilGFJ1zOUza4asiBoNu47TQYtL1WlUwp/BL9TGoDwAXbZczyqUl5RaDEQHRHTZiMh/4t0lenXw0QHRnGToOYkg==} + echarts-solid@https://codeload.github.com/ToposInstitute/echarts-solid/tar.gz/fa9fd59f51067c3ad3f6aaddb6c62eadad738f8a: + resolution: {tarball: https://codeload.github.com/ToposInstitute/echarts-solid/tar.gz/fa9fd59f51067c3ad3f6aaddb6c62eadad738f8a} + version: 0.2.0 engines: {node: '>=18', pnpm: '>=8.6.0'} peerDependencies: - echarts: ^5.5.1 + echarts: ^6.1.0 solid-js: ^1.8.22 - echarts@5.5.1: - resolution: {integrity: sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} electron-to-chromium@1.5.33: resolution: {integrity: sha512-+cYTcFB1QqD4j4LegwLfpCNxifb6dDFUAwk6RsLusCwIaZI6or2f+q8rs5tTB2YC53HhOlIbEaqHMAAC8IOIwA==} @@ -3107,8 +3119,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zrender@5.6.0: - resolution: {integrity: sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4123,32 +4135,37 @@ snapshots: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) solid-js: 1.9.10 + '@solid-primitives/event-listener@2.4.5(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + '@solid-primitives/map@0.7.2(solid-js@1.9.10)': dependencies: '@solid-primitives/trigger': 1.2.2(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/refs@1.0.8(solid-js@1.9.10)': + '@solid-primitives/refs@1.1.3(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.2.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/resize-observer@2.0.26(solid-js@1.9.10)': + '@solid-primitives/resize-observer@2.1.5(solid-js@1.9.10)': dependencies: - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/rootless': 1.4.5(solid-js@1.9.10) - '@solid-primitives/static-store': 0.0.8(solid-js@1.9.10) - '@solid-primitives/utils': 6.2.3(solid-js@1.9.10) + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/rootless@1.4.5(solid-js@1.9.10)': + '@solid-primitives/rootless@1.5.3(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.2.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/static-store@0.0.8(solid-js@1.9.10)': + '@solid-primitives/static-store@0.1.3(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.2.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) solid-js: 1.9.10 '@solid-primitives/timer@1.4.2(solid-js@1.9.10)': @@ -4157,14 +4174,14 @@ snapshots: '@solid-primitives/trigger@1.2.2(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/utils@6.2.3(solid-js@1.9.10)': + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 - '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + '@solid-primitives/utils@6.4.0(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 @@ -4221,6 +4238,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.11 @@ -4582,17 +4601,17 @@ snapshots: eastasianwidth@0.2.0: {} - echarts-solid@0.2.0(echarts@5.5.1)(solid-js@1.9.10): + echarts-solid@https://codeload.github.com/ToposInstitute/echarts-solid/tar.gz/fa9fd59f51067c3ad3f6aaddb6c62eadad738f8a(echarts@6.1.0)(solid-js@1.9.10): dependencies: - '@solid-primitives/refs': 1.0.8(solid-js@1.9.10) - '@solid-primitives/resize-observer': 2.0.26(solid-js@1.9.10) - echarts: 5.5.1 + '@solid-primitives/refs': 1.1.3(solid-js@1.9.10) + '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.10) + echarts: 6.1.0 solid-js: 1.9.10 - echarts@5.5.1: + echarts@6.1.0: dependencies: tslib: 2.3.0 - zrender: 5.6.0 + zrender: 6.1.0 electron-to-chromium@1.5.33: {} @@ -4692,7 +4711,7 @@ snapshots: '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -6793,7 +6812,7 @@ snapshots: yocto-queue@0.1.0: {} - zrender@5.6.0: + zrender@6.1.0: dependencies: tslib: 2.3.0 diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 4c93e3437..df312edd4 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { Navigate, type RouteDefinition, Router, type RouteSectionProps } from " import { type FirebaseOptions, initializeApp } from "firebase/app"; import { getAuth, signOut } from "firebase/auth"; import { FirebaseProvider } from "solid-firebase"; -import { createResource, createSignal, ErrorBoundary, lazy, Show } from "solid-js"; +import { createResource, createSignal, ErrorBoundary, lazy, onMount, Show } from "solid-js"; import invariant from "tiny-invariant"; import * as uuid from "uuid"; @@ -31,20 +31,15 @@ const Root = (props: RouteSectionProps) => { const firebaseApp = initializeApp(firebaseOptions); const api = new Api({ serverUrl, repoUrl, firebaseApp }); - const [isSessionInvalid] = createResource( - // oxlint-disable-next-line solid/reactivity -- createResource fetcher - async () => { - const result = await api.rpc.validate_session.query(); - if (result.tag === "Err") { - await signOut(getAuth(firebaseApp)); - return true; - } - return false; - }, - { - initialValue: false, - }, - ); + const [isSessionInvalid, setIsSessionInvalid] = createSignal(false); + + onMount(async () => { + const result = await api.rpc.validate_session.query(); + if (result.tag === "Err") { + await signOut(getAuth(firebaseApp)); + setIsSessionInvalid(true); + } + }); const theories = stdTheories; const models = createModelLibraryWithApi(api, theories); diff --git a/packages/frontend/src/analysis/analysis_editor.tsx b/packages/frontend/src/analysis/analysis_editor.tsx index bbd48238c..74888b8ba 100644 --- a/packages/frontend/src/analysis/analysis_editor.tsx +++ b/packages/frontend/src/analysis/analysis_editor.tsx @@ -3,6 +3,7 @@ import { Dynamic } from "solid-js/web"; import invariant from "tiny-invariant"; import { Nb } from "catcolab-document-methods"; +import { type FocusHandle } from "catcolab-ui-components"; import { type CellConstructor, type FormalCellEditorProps, NotebookEditor } from "../notebook"; import type { AnalysisMeta, DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import { LiveAnalysisContext } from "./context"; @@ -16,7 +17,10 @@ import type { Analysis } from "./types"; /** Notebook editor for analyses of models of double theories. */ -export function AnalysisNotebookEditor(props: { liveAnalysis: LiveAnalysisDoc }) { +export function AnalysisNotebookEditor(props: { + liveAnalysis: LiveAnalysisDoc; + focus: FocusHandle; +}) { const liveDoc = () => props.liveAnalysis.liveDoc; const cellConstructors = () => { @@ -39,7 +43,7 @@ export function AnalysisNotebookEditor(props: { liveAnalysis: LiveAnalysisDoc }) changeNotebook={(f) => liveDoc().changeDoc((doc) => f(doc.notebook))} formalCellEditor={AnalysisCellEditor} cellConstructors={cellConstructors()} - noShortcuts={true} + focus={props.focus} /> ); diff --git a/packages/frontend/src/components/document_picker.tsx b/packages/frontend/src/components/document_picker.tsx index d049e07cb..ad976b452 100644 --- a/packages/frontend/src/components/document_picker.tsx +++ b/packages/frontend/src/components/document_picker.tsx @@ -55,6 +55,7 @@ export function DocumentPicker( "docType", "placeholder", "isActive", + "focus", "filterCompletions", ]); @@ -68,10 +69,13 @@ export function DocumentPicker( ); const [editMode, setEditMode] = createSignal(false); - const enableEditMode = () => setEditMode(true); + const enableEditMode = () => { + props.focus?.setFocused(true); + setEditMode(true); + }; const disableEditMode = () => setEditMode(false); - createEffect(() => setEditMode(props.isActive ?? false)); + createEffect(() => setEditMode(props.focus?.hasFocus() ?? props.isActive ?? false)); const DocLink = (linkProps: ComponentProps<"a">) => ( @@ -123,6 +127,7 @@ export function DocumentPicker( }> { props.setRefId(refId); diff --git a/packages/frontend/src/diagram/diagram_editor.tsx b/packages/frontend/src/diagram/diagram_editor.tsx index e4ded5756..12e3e93ab 100644 --- a/packages/frontend/src/diagram/diagram_editor.tsx +++ b/packages/frontend/src/diagram/diagram_editor.tsx @@ -4,6 +4,7 @@ import invariant from "tiny-invariant"; import { Diagram, Nb } from "catcolab-document-methods"; import type { DiagramJudgment, DiagramMorDecl, DiagramObDecl } from "catcolab-document-types"; +import { type FocusHandle } from "catcolab-ui-components"; import { LiveModelContext } from "../model"; import { type CellConstructor, type FormalCellEditorProps, NotebookEditor } from "../notebook"; import type { InstanceTypeMeta } from "../theory"; @@ -14,7 +15,7 @@ import { DiagramObjectCellEditor } from "./object_cell_editor"; /** Notebook editor for a diagram in a model. */ -export function DiagramNotebookEditor(props: { liveDiagram: LiveDiagramDoc }) { +export function DiagramNotebookEditor(props: { liveDiagram: LiveDiagramDoc; focus: FocusHandle }) { const liveDoc = () => props.liveDiagram.liveDoc; const liveModel = () => props.liveDiagram.liveModel; @@ -39,6 +40,7 @@ export function DiagramNotebookEditor(props: { liveDiagram: LiveDiagramDoc }) { cellConstructors={cellConstructors()} cellLabel={judgmentLabel} duplicateCell={Diagram.duplicateDiagramJudgment} + focus={props.focus} /> ); @@ -59,7 +61,7 @@ function DiagramCellEditor(props: FormalCellEditorProps) { modifyDecl={(f) => props.changeContent((content) => f(content as DiagramObDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> @@ -72,7 +74,7 @@ function DiagramCellEditor(props: FormalCellEditorProps) { modifyDecl={(f) => props.changeContent((content) => f(content as DiagramMorDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> diff --git a/packages/frontend/src/diagram/morphism_cell_editor.tsx b/packages/frontend/src/diagram/morphism_cell_editor.tsx index 481291f77..bd698c0d3 100644 --- a/packages/frontend/src/diagram/morphism_cell_editor.tsx +++ b/packages/frontend/src/diagram/morphism_cell_editor.tsx @@ -1,7 +1,8 @@ -import { createEffect, createSignal, useContext } from "solid-js"; +import { useContext } from "solid-js"; import invariant from "tiny-invariant"; import { v7 } from "uuid"; +import { type FocusHandle, useChildFocus } from "catcolab-ui-components"; import type { DiagramMorDecl } from "catlog-wasm"; import { BasicMorInput } from "../model/morphism_input"; import type { CellActions } from "../notebook"; @@ -16,21 +17,15 @@ import "./morphism_cell_editor.css"; export function DiagramMorphismCellEditor(props: { decl: DiagramMorDecl; modifyDecl: (f: (decl: DiagramMorDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }) { const liveDiagram = useContext(LiveDiagramContext); invariant(liveDiagram, "Live diagram should be provided as context"); - const [activeInput, setActiveInput] = createSignal("mor"); - - // Reset to default on deactivation so re-entry lands on the morphism input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("mor"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "mor" }); const domType = () => props.theory.theory.src(props.decl.morType); const codType = () => props.theory.theory.tgt(props.decl.morType); @@ -61,15 +56,11 @@ export function DiagramMorphismCellEditor(props: { obType={domType()} generateId={v7} isInvalid={domInvalid()} - isActive={props.isActive && activeInput() === "dom"} - deleteForward={() => setActiveInput("mor")} - exitBackward={() => setActiveInput("mor")} - exitForward={() => setActiveInput("cod")} - exitRight={() => setActiveInput("mor")} - hasFocused={() => { - setActiveInput("dom"); - props.actions.hasFocused?.(); - }} + focus={focus.childFocus("dom")} + deleteForward={() => focus.setActiveChild("mor")} + exitBackward={() => focus.setActiveChild("mor")} + exitForward={() => focus.setActiveChild("cod")} + exitRight={() => focus.setActiveChild("mor")} />

@@ -82,19 +73,15 @@ export function DiagramMorphismCellEditor(props: { }} morType={props.decl.morType} placeholder={props.theory.modelMorTypeMeta(props.decl.morType)?.name} - isActive={props.isActive && activeInput() === "mor"} + focus={focus.childFocus("mor")} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("dom")} + exitForward={() => focus.setActiveChild("dom")} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitLeft={() => setActiveInput("dom")} - exitRight={() => setActiveInput("cod")} - hasFocused={() => { - setActiveInput("mor"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("dom")} + exitRight={() => focus.setActiveChild("cod")} />
@@ -112,15 +99,11 @@ export function DiagramMorphismCellEditor(props: { obType={codType()} generateId={v7} isInvalid={codInvalid()} - isActive={props.isActive && activeInput() === "cod"} - deleteBackward={() => setActiveInput("mor")} - exitBackward={() => setActiveInput("dom")} + focus={focus.childFocus("cod")} + deleteBackward={() => focus.setActiveChild("mor")} + exitBackward={() => focus.setActiveChild("dom")} exitForward={props.actions.activateBelow} - exitLeft={() => setActiveInput("mor")} - hasFocused={() => { - setActiveInput("cod"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("mor")} />
); diff --git a/packages/frontend/src/diagram/object_cell_editor.tsx b/packages/frontend/src/diagram/object_cell_editor.tsx index 9fab4fe87..7f00403de 100644 --- a/packages/frontend/src/diagram/object_cell_editor.tsx +++ b/packages/frontend/src/diagram/object_cell_editor.tsx @@ -1,6 +1,4 @@ -import { createSignal } from "solid-js"; - -import { NameInput } from "catcolab-ui-components"; +import { NameInput, type FocusHandle, useChildFocus } from "catcolab-ui-components"; import type { DiagramObDecl } from "catlog-wasm"; import { ObInput } from "../model/object_input"; import type { CellActions } from "../notebook"; @@ -12,11 +10,12 @@ import "./object_cell_editor.css"; export function DiagramObjectCellEditor(props: { decl: DiagramObDecl; modifyDecl: (f: (decl: DiagramObDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }) { - const [activeInput, setActiveInput] = createSignal("name"); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "name" }); return (
@@ -32,13 +31,9 @@ export function DiagramObjectCellEditor(props: { deleteForward={props.actions.deleteForward} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitRight={() => setActiveInput("overOb")} - exitForward={() => setActiveInput("overOb")} - isActive={props.isActive && activeInput() === "name"} - hasFocused={() => { - setActiveInput("name"); - props.actions.hasFocused?.(); - }} + exitRight={() => focus.setActiveChild("overOb")} + exitForward={() => focus.setActiveChild("overOb")} + focus={focus.childFocus("name")} /> setActiveInput("name")} - exitBackward={() => setActiveInput("name")} - isActive={props.isActive && activeInput() === "overOb"} - hasFocused={() => { - setActiveInput("overOb"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} + focus={focus.childFocus("overOb")} />
); diff --git a/packages/frontend/src/model/contribution_cell_editor.tsx b/packages/frontend/src/model/contribution_cell_editor.tsx index 673b48434..7e5d35149 100644 --- a/packages/frontend/src/model/contribution_cell_editor.tsx +++ b/packages/frontend/src/model/contribution_cell_editor.tsx @@ -1,7 +1,7 @@ -import { createEffect, createMemo, createSignal, useContext, Switch, Match } from "solid-js"; +import { createMemo, useContext, Switch, Match } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput } from "catcolab-ui-components"; +import { NameInput, useChildFocus } from "catcolab-ui-components"; import type { Ob } from "catlog-wasm"; import { LiveModelContext } from "./context"; import { ContributionMonomialEditor } from "./contribution_monomial_editor"; @@ -32,14 +32,8 @@ export default function ContributionCellEditor( const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const [activeInput, setActiveInput] = createSignal("name"); - - // Reset to default on deactivation so re-entry lands on the name input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("name"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "name" }); const morTypeMeta = () => props.theory.modelMorTypeMeta(props.morphism.morType); @@ -104,19 +98,15 @@ export default function ContributionCellEditor( mor.name = name; }); }} - isActive={props.isActive && activeInput() === "name"} + focus={focus.childFocus("name")} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("cod")} + exitForward={() => focus.setActiveChild("cod")} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitLeft={() => setActiveInput("cod")} - exitRight={() => setActiveInput("dom")} - hasFocused={() => { - setActiveInput("name"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("cod")} + exitRight={() => focus.setActiveChild("dom")} />
:
@@ -138,15 +128,11 @@ export default function ContributionCellEditor( obType={codType()} applyOp={morTypeMeta()?.codomain?.apply} isInvalid={errors().some((err) => err.tag === "Cod" || err.tag === "CodType")} - isActive={props.isActive && activeInput() === "cod"} - deleteForward={() => setActiveInput("name")} + focus={focus.childFocus("cod")} + deleteForward={() => focus.setActiveChild("name")} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("dom")} - exitLeft={() => setActiveInput("name")} - hasFocused={() => { - setActiveInput("cod"); - props.actions.hasFocused?.(); - }} + exitForward={() => focus.setActiveChild("dom")} + exitLeft={() => focus.setActiveChild("name")} />
@@ -164,15 +150,11 @@ export default function ContributionCellEditor( setOb={setDomOb} obType={domType()} isInvalid={errors().some((err) => err.tag === "Dom" || err.tag === "DomType")} - isActive={props.isActive && activeInput() === "dom"} - deleteBackward={() => setActiveInput("name")} - exitBackward={() => setActiveInput("name")} + focus={focus.childFocus("dom")} + deleteBackward={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} exitForward={props.actions.activateBelow} exitRight={props.actions.activateBelow} - hasFocused={() => { - setActiveInput("dom"); - props.actions.hasFocused?.(); - }} />
diff --git a/packages/frontend/src/model/contribution_monomial_editor.tsx b/packages/frontend/src/model/contribution_monomial_editor.tsx index 25d3680da..0c2343637 100644 --- a/packages/frontend/src/model/contribution_monomial_editor.tsx +++ b/packages/frontend/src/model/contribution_monomial_editor.tsx @@ -59,11 +59,12 @@ export function ContributionMonomialEditor(props: ContributionMonomialEditorProp return ( ob === null)} + when={(props.focus?.hasFocus() ?? props.isActive) || obList().some((ob) => ob === null)} fallback={
{ + props.focus?.setFocused(true); props.hasFocused?.(); evt.preventDefault(); }} @@ -91,14 +92,13 @@ export function ContributionMonomialEditor(props: ContributionMonomialEditorProp obType={props.obType} placeholder={props.placeholder} isInvalid={props.isInvalid} - isActive={props.isActive} + focus={props.focus} deleteBackward={props.deleteBackward} deleteForward={props.deleteForward} exitBackward={props.exitBackward} exitForward={props.exitForward} exitLeft={props.exitLeft} exitRight={props.exitRight} - hasFocused={props.hasFocused} insertKey={props.insertKey ?? ","} startDelimiter={
{"["}
} endDelimiter={
{"]"}
} diff --git a/packages/frontend/src/model/editors.ts b/packages/frontend/src/model/editors.ts index 516f19850..131d8595a 100644 --- a/packages/frontend/src/model/editors.ts +++ b/packages/frontend/src/model/editors.ts @@ -1,5 +1,6 @@ import type { Component } from "solid-js"; +import type { FocusHandle } from "catcolab-ui-components"; import type { MorDecl, ObDecl } from "catlog-wasm"; import type { CellActions } from "../notebook"; import type { Theory } from "../theory"; @@ -19,7 +20,7 @@ export type EditorVariantOverrides = { export type ObjectEditorProps = { object: ObDecl; modifyObject: (f: (decl: ObDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }; @@ -28,7 +29,7 @@ export type ObjectEditorProps = { export type MorphismEditorProps = { morphism: MorDecl; modifyMorphism: (f: (decl: MorDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }; diff --git a/packages/frontend/src/model/instantiation_cell_editor.tsx b/packages/frontend/src/model/instantiation_cell_editor.tsx index d76331cc9..09f4c1916 100644 --- a/packages/frontend/src/model/instantiation_cell_editor.tsx +++ b/packages/frontend/src/model/instantiation_cell_editor.tsx @@ -1,17 +1,13 @@ import type { DocInfo } from "catcolab-api/src/user_state"; -import { - batch, - createEffect, - createSignal, - Index, - Show, - splitProps, - untrack, - useContext, -} from "solid-js"; +import { batch, createEffect, Index, Show, splitProps, untrack, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput, type TextInputOptions } from "catcolab-ui-components"; +import { + type FocusHandle, + NameInput, + type TextInputOptions, + useChildFocus, +} from "catcolab-ui-components"; import type { DblModel, InstantiatedModel, Ob, SpecializeModel } from "catlog-wasm"; import { useApi } from "../api"; import { DocumentPicker, IdInput, IdInputPlaceholder } from "../components"; @@ -27,7 +23,7 @@ import "./instantiation_cell_editor.css"; export function InstantiationCellEditor(props: { instantiation: InstantiatedModel; modifyInstantiation: (f: (inst: InstantiatedModel) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; }) { const api = useApi(); @@ -67,26 +63,19 @@ export function InstantiationCellEditor(props: { invariant(models); const instantiated = models.useElaboratedModel(refId); - const [activeComponent, setActiveComponent] = createSignal( - refId() == null ? "model" : "name", - ); - const [activeIndex, setActiveIndex] = createSignal(0); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { + default: refId() == null ? "model" : "name", + }); + const specializationFocus = useChildFocus(focus.childFocus("specializations"), { + default: 0, + }); const activateIndex = (index: number) => batch(() => { - setActiveComponent("specializations"); - setActiveIndex(index); + focus.setActiveChild("specializations"); + specializationFocus.setActiveChild(index); }); - // Reset to default on deactivation so re-entry lands on the name input. - createEffect(() => { - if (!props.isActive) { - batch(() => { - setActiveComponent("name"); - setActiveIndex(0); - }); - } - }); - const insertSpecializationAtTop = () => { props.modifyInstantiation((inst) => { inst.specializations.unshift({ id: null, ob: null }); @@ -156,7 +145,7 @@ export function InstantiationCellEditor(props: { // Clean up empty rows when the cell becomes inactive. createEffect(() => { - if (!props.isActive) { + if (!props.focus.hasFocus()) { untrack(() => pruneEmptySpecializations()); } }); @@ -185,13 +174,9 @@ export function InstantiationCellEditor(props: { deleteForward={props.actions.deleteForward} exitUp={props.actions.activateAbove} exitDown={exitDownFromTop} - exitRight={() => setActiveComponent("model")} - exitForward={() => setActiveComponent("model")} - isActive={props.isActive && activeComponent() === "name"} - hasFocused={() => { - setActiveComponent("name"); - props.actions.hasFocused(); - }} + exitRight={() => focus.setActiveChild("model")} + exitForward={() => focus.setActiveChild("model")} + focus={focus.childFocus("name")} /> setActiveComponent("name")} + deleteBackward={() => focus.setActiveChild("name")} exitUp={props.actions.activateAbove} exitDown={exitDownFromTop} - exitLeft={() => setActiveComponent("name")} - exitBackward={() => setActiveComponent("name")} - isActive={props.isActive && activeComponent() === "model"} - hasFocused={() => { - setActiveComponent("model"); - props.actions.hasFocused(); - }} + exitLeft={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} + focus={focus.childFocus("model")} />
    idInputTexts.set(i, text)} instantiatedModel={instantiated()?.validatedModel.model ?? null} - isActive={ - props.isActive && - activeComponent() === "specializations" && - activeIndex() === i - } - hasFocused={() => { - activateIndex(i); - props.actions.hasFocused(); - }} + focus={specializationFocus.childFocus(i)} createBelow={() => { props.modifyInstantiation((inst) => { const spec = { id: null, ob: null }; @@ -262,7 +235,7 @@ export function InstantiationCellEditor(props: { props.modifyInstantiation((inst) => inst.specializations.splice(i, 1), ); - i === 0 ? setActiveComponent("name") : activateIndex(i - 1); + i === 0 ? focus.setActiveChild("name") : activateIndex(i - 1); }} exitDown={() => { if (i >= props.instantiation.specializations.length - 1) { @@ -272,7 +245,7 @@ export function InstantiationCellEditor(props: { } }} exitUp={() => { - i === 0 ? setActiveComponent("name") : activateIndex(i - 1); + i === 0 ? focus.setActiveChild("name") : activateIndex(i - 1); }} /> @@ -283,7 +256,7 @@ export function InstantiationCellEditor(props: { class="model-specialization-add" onMouseDown={(evt) => { appendSpecialization(); - props.actions.hasFocused(); + props.focus.setFocused(true); evt.preventDefault(); }} > @@ -308,21 +281,13 @@ function SpecializationEditor( instantiatedModel: DblModel | null; /** Called when the displayed text of the id input changes. */ onIdTextChange?: (text: string) => void; - } & Pick< - TextInputOptions, - "isActive" | "hasFocused" | "createBelow" | "deleteBackward" | "exitDown" | "exitUp" - >, + focus: FocusHandle; + } & Pick, ) { const [inputProps, props] = splitProps(allProps, ["createBelow", "exitDown", "exitUp"]); - const [activeInput, setActiveInput] = createSignal("id"); - - // Reset to default on deactivation so re-entry lands on the id input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("id"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted row. + const focus = useChildFocus(props.focus, { default: "id" }); const obType = () => { const id = props.specialization.id; @@ -348,14 +313,10 @@ function SpecializationEditor( completions={props.instantiatedModel?.obGenerators()} idToLabel={(id) => props.instantiatedModel?.obGeneratorLabel(id)} labelToId={(label) => props.instantiatedModel?.obGeneratorWithLabel(label)} - isActive={props.isActive && activeInput() === "id"} - hasFocused={() => { - setActiveInput("id"); - props.hasFocused?.(); - }} + focus={focus.childFocus("id")} deleteBackward={props.deleteBackward} - exitForward={() => setActiveInput("ob")} - exitRight={() => setActiveInput("ob")} + exitForward={() => focus.setActiveChild("ob")} + exitRight={() => focus.setActiveChild("ob")} {...inputProps} /> @@ -370,14 +331,10 @@ function SpecializationEditor( }); }} obType={obType()} - isActive={props.isActive && activeInput() === "ob"} - hasFocused={() => { - setActiveInput("ob"); - props.hasFocused?.(); - }} - deleteBackward={() => setActiveInput("id")} - exitBackward={() => setActiveInput("id")} - exitLeft={() => setActiveInput("id")} + focus={focus.childFocus("ob")} + deleteBackward={() => focus.setActiveChild("id")} + exitBackward={() => focus.setActiveChild("id")} + exitLeft={() => focus.setActiveChild("id")} {...inputProps} /> )} diff --git a/packages/frontend/src/model/model_editor.tsx b/packages/frontend/src/model/model_editor.tsx index f5d4250a9..8236b2e21 100644 --- a/packages/frontend/src/model/model_editor.tsx +++ b/packages/frontend/src/model/model_editor.tsx @@ -4,6 +4,7 @@ import invariant from "tiny-invariant"; import { Model, Nb } from "catcolab-document-methods"; import type { InstantiatedModel, ModelJudgment, MorDecl, ObDecl } from "catcolab-document-types"; +import { type FocusHandle } from "catcolab-ui-components"; import { type CellConstructor, type FormalCellEditorProps, NotebookEditor } from "../notebook"; import { TheoryLibraryContext, type ModelTypeMeta, type Theory } from "../theory"; import { LiveModelContext } from "./context"; @@ -12,7 +13,7 @@ import { InstantiationCellEditor } from "./instantiation_cell_editor"; /** Notebook editor for a model of a double theory. */ -export function ModelNotebookEditor(props: { liveModel: LiveModelDoc }) { +export function ModelNotebookEditor(props: { liveModel: LiveModelDoc; focus: FocusHandle }) { const liveDoc = () => props.liveModel.liveDoc; const cellConstructors = () => { @@ -34,6 +35,7 @@ export function ModelNotebookEditor(props: { liveModel: LiveModelDoc }) { cellConstructors={cellConstructors()} cellLabel={judgmentLabel} duplicateCell={Model.duplicateModelJudgment} + focus={props.focus} /> ); @@ -65,7 +67,7 @@ export function ModelCellEditor(props: FormalCellEditorProps) { modifyObject={(f: (decl: ObDecl) => void) => props.changeContent((content) => f(content as ObDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> @@ -85,7 +87,7 @@ export function ModelCellEditor(props: FormalCellEditorProps) { modifyMorphism={(f: (decl: MorDecl) => void) => props.changeContent((content) => f(content as MorDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> @@ -98,7 +100,7 @@ export function ModelCellEditor(props: FormalCellEditorProps) { modifyInstantiation={(f) => props.changeContent((content) => f(content as InstantiatedModel)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} /> diff --git a/packages/frontend/src/model/morphism_cell_editor.tsx b/packages/frontend/src/model/morphism_cell_editor.tsx index a48d12f21..b7d459044 100644 --- a/packages/frontend/src/model/morphism_cell_editor.tsx +++ b/packages/frontend/src/model/morphism_cell_editor.tsx @@ -1,7 +1,7 @@ -import { createEffect, createMemo, createSignal, useContext } from "solid-js"; +import { createMemo, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput } from "catcolab-ui-components"; +import { NameInput, useChildFocus } from "catcolab-ui-components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; import { LiveModelContext } from "./context"; import type { MorphismEditorProps } from "./editors"; @@ -16,14 +16,8 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const [activeInput, setActiveInput] = createSignal("name"); - - // Reset to default on deactivation so re-entry lands on the name input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("name"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "name" }); const morTypeMeta = () => props.theory.modelMorTypeMeta(props.morphism.morType); @@ -81,15 +75,11 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { obType={domType()} applyOp={morTypeMeta()?.domain?.apply} isInvalid={errors().some((err) => err.tag === "Dom" || err.tag === "DomType")} - isActive={props.isActive && activeInput() === "dom"} - deleteForward={() => setActiveInput("name")} - exitBackward={() => setActiveInput("name")} - exitForward={() => setActiveInput("cod")} - exitRight={() => setActiveInput("name")} - hasFocused={() => { - setActiveInput("dom"); - props.actions.hasFocused?.(); - }} + focus={focus.childFocus("dom")} + deleteForward={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} + exitForward={() => focus.setActiveChild("cod")} + exitRight={() => focus.setActiveChild("name")} />
    @@ -102,19 +92,15 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { mor.name = name; }); }} - isActive={props.isActive && activeInput() === "name"} + focus={focus.childFocus("name")} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("dom")} + exitForward={() => focus.setActiveChild("dom")} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitLeft={() => setActiveInput("dom")} - exitRight={() => setActiveInput("cod")} - hasFocused={() => { - setActiveInput("name"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("dom")} + exitRight={() => focus.setActiveChild("cod")} />
    @@ -133,15 +119,11 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { obType={codType()} applyOp={morTypeMeta()?.codomain?.apply} isInvalid={errors().some((err) => err.tag === "Cod" || err.tag === "CodType")} - isActive={props.isActive && activeInput() === "cod"} - deleteBackward={() => setActiveInput("name")} - exitBackward={() => setActiveInput("dom")} + focus={focus.childFocus("cod")} + deleteBackward={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("dom")} exitForward={props.actions.activateBelow} - exitLeft={() => setActiveInput("name")} - hasFocused={() => { - setActiveInput("cod"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("name")} />
    diff --git a/packages/frontend/src/model/object_cell_editor.tsx b/packages/frontend/src/model/object_cell_editor.tsx index 46c5e1520..c1e2e92d0 100644 --- a/packages/frontend/src/model/object_cell_editor.tsx +++ b/packages/frontend/src/model/object_cell_editor.tsx @@ -23,14 +23,13 @@ export default function ObjectCellEditor(props: ObjectEditorProps) { ob.name = name; }); }} - isActive={props.isActive} + focus={props.focus} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} exitForward={props.actions.activateBelow} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - hasFocused={props.actions.hasFocused} /> ); diff --git a/packages/frontend/src/model/object_list_editor.css b/packages/frontend/src/model/object_list_editor.css deleted file mode 100644 index f03ded083..000000000 --- a/packages/frontend/src/model/object_list_editor.css +++ /dev/null @@ -1,29 +0,0 @@ -.object-list { - display: flex; - flex-direction: row; - align-items: center; - list-style: none; - padding: 0; - - li { - display: flex; - flex-direction: row; - } - - .default-delimiter, - .default-separator { - color: var(--color-gray-800); - } - .default-delimiter { - transform: scale(1, 1.5); - } - - .empty-list-input { - background: transparent; - border: none; - outline: none; - width: 0.5ex; - margin: 0; - padding: 0; - } -} diff --git a/packages/frontend/src/model/object_list_editor.tsx b/packages/frontend/src/model/object_list_editor.tsx index 73975ab4f..81224e7a5 100644 --- a/packages/frontend/src/model/object_list_editor.tsx +++ b/packages/frontend/src/model/object_list_editor.tsx @@ -1,17 +1,7 @@ -import { - batch, - createEffect, - createSignal, - Index, - type JSX, - mergeProps, - Show, - untrack, - useContext, -} from "solid-js"; +import { createEffect, type JSX, splitProps, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import type { TextInputOptions } from "catcolab-ui-components"; +import { InlineListEditor, type TextInputOptions } from "catcolab-ui-components"; import type { Ob, QualifiedName } from "catlog-wasm"; import { ObIdInput } from "../components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; @@ -19,8 +9,6 @@ import { LiveModelContext } from "./context"; import { buildObList, extractObList } from "./ob_operations"; import type { ObInputProps } from "./object_input"; -import "./object_list_editor.css"; - type ObListEditorProps = ObInputProps & TextInputOptions & { insertKey?: string; @@ -30,25 +18,12 @@ type ObListEditorProps = ObInputProps & }; /** Edits a list of objects of given type. */ -export function ObListEditor(originalProps: ObListEditorProps) { - const props = mergeProps( - { - insertKey: ",", - startDelimiter:
    {"["}
    , - endDelimiter:
    {"]"}
    , - separator: () =>
    {","}
    , - }, - originalProps, - ); +export function ObListEditor(allProps: ObListEditorProps) { + const [props, listProps] = splitProps(allProps, ["ob", "setOb", "obType", "placeholder"]); const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const [activeIndex, setActiveIndex] = createSignal(0); - - // Track which indices have non-empty text (including incomplete input). - const inputTexts = new Map(); - const modeAppType = () => { if (props.obType.tag !== "ModeApp") { throw new Error(`Object type should be a list modality, received: ${props.obType}`); @@ -59,22 +34,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { const obList = (): Array => extractObList(props.ob); const setObList = (objects: Array) => { - props.setOb(buildObList(modeAppType().content.modality, objects)); - }; - - const updateObList = (f: (objects: Array) => void) => { - const objects = removeProxyAndCopy(obList()); - f(objects); - setObList(objects); - }; - - const insertNewOb = (i: number) => { - batch(() => { - updateObList((objects) => { - objects.splice(i, 0, null); - }); - setActiveIndex(i); - }); + props.setOb(buildObList(modeAppType().content.modality, removeProxyAndCopy(objects))); }; const completions = (): QualifiedName[] | undefined => @@ -87,118 +47,21 @@ export function ObListEditor(originalProps: ObListEditorProps) { } }); - // Insert into new object into empty list when focus is gained. - createEffect(() => { - if (props.isActive && untrack(obList).length === 0) { - insertNewOb(0); - } - }); - - /** Clean up null placeholders that have no user-entered text. */ - const deactivate = () => { - const objects = obList().filter((ob, i) => ob !== null || (inputTexts.get(i) ?? "") !== ""); - if (objects.length !== obList().length) { - setObList(objects); - } - }; - - // Clean up when the component becomes inactive. - createEffect(() => { - if (!props.isActive) { - untrack(() => deactivate()); - } - }); - return ( -
      { - if (obList().length === 0) { - insertNewOb(0); - props.hasFocused?.(); - evt.preventDefault(); - } - }} - > - {props.startDelimiter} - }> - {(ob, i) => ( -
    • - 0 && props.separator}>{(sep) => sep()(i)} - { - updateObList((objects) => { - objects[i] = ob; - }); - }} - onTextChange={(text) => inputTexts.set(i, text)} - placeholder={props.placeholder} - idToLabel={(id) => liveModel().elaboratedModel()?.obGeneratorLabel(id)} - labelToId={(label) => - liveModel().elaboratedModel()?.obGeneratorWithLabel(label) - } - completions={completions()} - isActive={props.isActive && activeIndex() === i} - deleteBackward={() => - batch(() => { - updateObList((objects) => { - objects.splice(i, 1); - }); - if (i === 0) { - props.deleteBackward?.(); - } else { - setActiveIndex(i - 1); - } - }) - } - deleteForward={() => { - batch(() => { - updateObList((objects) => { - objects.splice(i, 1); - }); - if (i === 0) { - props.deleteForward?.(); - } - }); - }} - exitBackward={() => props.exitBackward?.()} - exitForward={() => props.exitForward?.()} - exitLeft={() => { - if (i === 0) { - props.exitLeft?.(); - } else { - setActiveIndex(i - 1); - } - }} - exitRight={() => { - if (i === obList().length - 1) { - props.exitRight?.(); - } else { - setActiveIndex(i + 1); - } - }} - interceptKeyDown={(evt) => { - if (evt.key === props.insertKey) { - insertNewOb(i + 1); - return true; - } else if (evt.key === "Home" && !evt.shiftKey) { - // TODO: Should move to beginning of input. - setActiveIndex(0); - } else if (evt.key === "End" && !evt.shiftKey) { - setActiveIndex(obList().length - 1); - } - return false; - }} - hasFocused={() => { - setActiveIndex(i); - props.hasFocused?.(); - }} - /> -
    • - )} -
      - {props.endDelimiter} -
    + + {(ob, setOb, options) => ( + liveModel().elaboratedModel()?.obGeneratorLabel(id)} + labelToId={(label) => + liveModel().elaboratedModel()?.obGeneratorWithLabel(label) + } + completions={completions()} + {...options} + /> + )} + ); } diff --git a/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx b/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx index d777367b0..9731338aa 100644 --- a/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx +++ b/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx @@ -1,7 +1,7 @@ import { Index, createEffect, createMemo, createSignal, untrack, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput } from "catcolab-ui-components"; +import { type FocusHandle, NameInput } from "catcolab-ui-components"; import type { Ob, ObOp, ObType, QualifiedName } from "catlog-wasm"; import { ObIdInput } from "../components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; @@ -34,7 +34,7 @@ function WireColumn(props: { exitFirstBackward: (() => void) | undefined; /** Called when tabbing forward from the last wire. */ exitLastForward: (() => void) | undefined; - hasFocused: (() => void) | undefined; + setFocused: () => void; }) { const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); @@ -90,7 +90,7 @@ function WireColumn(props: { }} hasFocused={() => { props.activateWire(i); - props.hasFocused?.(); + props.setFocused(); }} /> ); @@ -110,7 +110,7 @@ function WireColumn(props: { class={`${styles.wire} ${styles.addWire}`} onMouseDown={(evt) => { props.insertWire(props.obs.length); - props.hasFocused?.(); + props.setFocused(); evt.preventDefault(); }} > @@ -136,7 +136,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro // Reset to default on deactivation so re-entry lands on the name input. createEffect(() => { - if (!props.isActive) { + if (!props.focus.hasFocus()) { setActive({ zone: "name" }); } }); @@ -241,13 +241,23 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro // Clean up when the cell becomes inactive. createEffect(() => { - if (!props.isActive) { + if (!props.focus.hasFocus()) { untrack(() => deactivate()); } }); const completions = () => liveModel().elaboratedModel()?.obGeneratorsWithType(elementObType()); + const nameFocus: FocusHandle = { + hasFocus: () => props.focus.hasFocus() && active()?.zone === "name", + setFocused: (focused) => { + if (focused) { + setActive({ zone: "name" }); + props.focus.setFocused(true); + } + }, + }; + const errors = () => { const validated = liveModel().validatedModel(); if (validated?.tag !== "Invalid") { @@ -265,7 +275,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro completions={completions()} isActive={(i) => { const a = active(); - return props.isActive && a?.zone === "dom" && a.index === i; + return props.focus.hasFocus() && a?.zone === "dom" && a.index === i; }} onTextChange={(i, text) => domInputTexts.set(i, text)} insertWire={insertDom} @@ -285,7 +295,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro insertCod(0); } }} - hasFocused={props.actions.hasFocused} + setFocused={() => props.focus.setFocused(true)} />
    { - setActive({ zone: "name" }); - props.actions.hasFocused?.(); - }} />
    { const a = active(); - return props.isActive && a?.zone === "cod" && a.index === i; + return props.focus.hasFocus() && a?.zone === "cod" && a.index === i; }} onTextChange={(i, text) => codInputTexts.set(i, text)} insertWire={insertCod} @@ -356,7 +362,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro } }} exitLastForward={props.actions.activateBelow} - hasFocused={props.actions.hasFocused} + setFocused={() => props.focus.setFocused(true)} /> ); diff --git a/packages/frontend/src/notebook/notebook_cell.tsx b/packages/frontend/src/notebook/notebook_cell.tsx index 07b6cf7db..f6794ba07 100644 --- a/packages/frontend/src/notebook/notebook_cell.tsx +++ b/packages/frontend/src/notebook/notebook_cell.tsx @@ -17,7 +17,7 @@ import type { EditorView } from "prosemirror-view"; import { createEffect, createSignal, type JSX, onCleanup, Show } from "solid-js"; import type { Uuid } from "catcolab-document-types"; -import { type Completion, Completions, IconButton } from "catcolab-ui-components"; +import { type Completion, Completions, type FocusHandle, IconButton } from "catcolab-ui-components"; import { RichTextEditor } from "../components"; import { CellTypePopover } from "./notebook_editor"; @@ -25,11 +25,8 @@ import "./notebook_cell.css"; /** Props available to all notebook cell editors. */ export type CellEditorProps = { - /** Is the cell requested to be active? - - When this prop changes to `true`, the cell is authorizeed to grab the focus. - */ - isActive: boolean; + /** Focus state for this cell. */ + focus: FocusHandle; /** Actions invokable within the cell. */ actions: CellActions; @@ -61,9 +58,6 @@ export type CellActions = { /** Move this cell down, if possible. */ moveDown: () => void; - - /** The cell has received focus. */ - hasFocused: () => void; }; const cellDragDataKey = Symbol("notebook-cell"); @@ -99,6 +93,7 @@ the cell is rendered by its children. export function NotebookCell(props: { cellId: Uuid; index: number; + focus: FocusHandle; actions: CellActions; children: JSX.Element; tag?: string; @@ -235,6 +230,7 @@ export function NotebookCell(props: {
    { const view = editorView(); - if (props.isActive && view) { + if (props.focus.hasFocus() && view) { view.focus(); } }); @@ -315,7 +311,7 @@ export function RichTextCellEditor( deleteForward={props.actions.deleteForward} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - onFocus={props.actions.hasFocused} + onFocus={() => props.focus.setFocused(true)} /> ); } diff --git a/packages/frontend/src/notebook/notebook_editor.tsx b/packages/frontend/src/notebook/notebook_editor.tsx index 6187fbd67..b53d281f3 100644 --- a/packages/frontend/src/notebook/notebook_editor.tsx +++ b/packages/frontend/src/notebook/notebook_editor.tsx @@ -18,15 +18,17 @@ import { import invariant from "tiny-invariant"; import { Nb } from "catcolab-document-methods"; -import type { Cell, Notebook } from "catcolab-document-types"; +import type { Cell, Notebook, Uuid } from "catcolab-document-types"; import { type Completion, Completions, type CompletionsRef, + type FocusHandle, IconButton, type KbdKey, keyEventHasModifier, type ModifierKey, + useChildFocus, } from "catcolab-ui-components"; import { materializeFromAutomerge } from "../util/materialize_from_automerge"; import { @@ -42,10 +44,10 @@ import "./notebook_editor.css"; /** Identifies which create-cell popover, if any, the editor wants open. -Either an index of an existing cell (open the "create below" popover anchored -to that cell) or `"append"` (open the popover for the end-of-notebook button). +Either the id of an existing cell (open the "create below" popover anchored to +that cell) or `"append"` (open the popover for the end-of-notebook button). */ -type CreatePopoverTarget = number | "append" | null; +type CreatePopoverTarget = Uuid | "append" | null; /** Constructor for a cell in a notebook. @@ -86,17 +88,16 @@ export function NotebookEditor(props: { formalCellEditor: Component>; cellConstructors?: CellConstructor[]; cellLabel?: (content: T) => string | undefined; + focus: FocusHandle; /** Called to duplicate an existing cell. If omitted, a deep copy is performed. */ duplicateCell?: (content: T) => T; - - // FIXME: Remove this option once we fix focus management. - noShortcuts?: boolean; }) { - const [activeCell, setActiveCell] = createSignal(null); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted notebook. + const cellFocus = useChildFocus(props.focus); const [currentDropTarget, setCurrentDropTarget] = createSignal(null); // Which create-cell popover (if any) the editor has requested to open. @@ -113,13 +114,20 @@ export function NotebookEditor(props: { description, shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { - const [i, n] = [activeCell(), props.notebook.cellOrder.length]; - const cellIndex = i != null ? Math.min(i + 1, n) : n; + const activeCellId = cellFocus.activeChild(); + const activeIndex = activeCellId + ? props.notebook.cellOrder.indexOf(activeCellId) + : -1; + const cellIndex = + activeIndex >= 0 + ? Math.min(activeIndex + 1, props.notebook.cellOrder.length) + : props.notebook.cellOrder.length; + const newCell = cc.construct(); props.changeNotebook((nb) => { - Nb.insertCellAtIndex(nb, cc.construct(), cellIndex); + Nb.insertCellAtIndex(nb, newCell, cellIndex); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => setActiveCell(cellIndex)); + requestAnimationFrame(() => cellFocus.setActiveChild(newCell.id)); }, }; }); @@ -144,11 +152,12 @@ export function NotebookEditor(props: { shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { const index = i + 1; + const newCell = cc.construct(); props.changeNotebook((nb) => { - Nb.insertCellAtIndex(nb, cc.construct(), index); + Nb.insertCellAtIndex(nb, newCell, index); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => setActiveCell(index)); + requestAnimationFrame(() => cellFocus.setActiveChild(newCell.id)); }, }; }); @@ -162,19 +171,18 @@ export function NotebookEditor(props: { description, shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { + const newCell = cc.construct(); props.changeNotebook((nb) => { - Nb.appendCell(nb, cc.construct()); + Nb.appendCell(nb, newCell); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => { - setActiveCell(Nb.numCells(props.notebook) - 1); - }); + requestAnimationFrame(() => cellFocus.setActiveChild(newCell.id)); }, }; }); makeEventListener(window, "keydown", (evt) => { - if (props.noShortcuts) { + if (!props.focus.hasFocus()) { return; } if (keyEventHasModifier(evt, cellShortcutModifier)) { @@ -195,8 +203,8 @@ export function NotebookEditor(props: { // create-cell popover. The popover is rendered by that anchor, so // it is positioned automatically and doesn't require any DOM // queries here. - const cellIndex = activeCell(); - setCreatePopoverTarget(cellIndex != null ? cellIndex : "append"); + const cellId = cellFocus.activeChild(); + setCreatePopoverTarget(cellId ?? "append"); evt.preventDefault(); // Stop the same event from reaching `CellTypePopover`'s window // keydown listener, which would otherwise see the popover as @@ -255,21 +263,12 @@ export function NotebookEditor(props: { }); return ( -
    { - const container = evt.currentTarget; - setTimeout(() => { - if (!container.contains(document.activeElement)) { - setActiveCell(null); - } - }, 0); - }} - > +
    setCreatePopoverTarget(open ? "append" : null)} > @@ -281,32 +280,41 @@ export function NotebookEditor(props: {
      {(cellId, i) => { - const isActive = () => activeCell() === i(); + const focus = () => cellFocus.childFocus(cellId); const cellActions: CellActions = { activateAbove() { if (i() > 0) { - setActiveCell(i() - 1); + cellFocus.setActiveChild( + props.notebook.cellOrder[i() - 1] ?? null, + ); } }, activateBelow() { if (i() < Nb.numCells(props.notebook) - 1) { - setActiveCell(i() + 1); + cellFocus.setActiveChild( + props.notebook.cellOrder[i() + 1] ?? null, + ); } }, deleteBackward() { const index = i(); + const nextActiveId = props.notebook.cellOrder[index - 1] ?? null; props.changeNotebook((nb) => { Nb.deleteCellAtIndex(nb, index); }); - setActiveCell(index - 1); + cellFocus.setActiveChild(nextActiveId); }, deleteForward() { const index = i(); + const nextActiveId = + props.notebook.cellOrder[index + 1] ?? + props.notebook.cellOrder[index - 1] ?? + null; props.changeNotebook((nb) => { Nb.deleteCellAtIndex(nb, index); }); - setActiveCell(index); + cellFocus.setActiveChild(nextActiveId); }, moveUp() { // oxlint-disable-next-line solid/reactivity -- event handler @@ -320,9 +328,6 @@ export function NotebookEditor(props: { Nb.moveCellDown(nb, i()); }); }, - hasFocused() { - setActiveCell(i()); - }, }; const cell = props.notebook.cellContents[cellId]; @@ -345,7 +350,7 @@ export function NotebookEditor(props: { props.changeNotebook((nb) => { Nb.insertCellAtIndex(nb, newCell, index + 1); }); - setActiveCell(index + 1); + cellFocus.setActiveChild(newCell.id); }; } @@ -354,6 +359,7 @@ export function NotebookEditor(props: { (props: { : undefined } createCompletions={createBelowCommands(i())} - popoverOpen={createPopoverTarget() === i()} + popoverOpen={createPopoverTarget() === cellId} setPopoverOpen={(open) => - setCreatePopoverTarget(open ? i() : null) + setCreatePopoverTarget(open ? cellId : null) } currentDropTarget={currentDropTarget()} setCurrentDropTarget={setCurrentDropTarget} @@ -374,7 +380,7 @@ export function NotebookEditor(props: { cellId={cell.id} handle={props.handle} path={[...props.path, "cellContents", cell.id]} - isActive={isActive()} + focus={focus()} actions={cellActions} /> @@ -391,7 +397,7 @@ export function NotebookEditor(props: { ), ) } - isActive={isActive()} + focus={focus()} actions={cellActions} /> )} @@ -407,6 +413,7 @@ export function NotebookEditor(props: {
      setCreatePopoverTarget(open ? "append" : null)} @@ -428,6 +435,7 @@ Up/Down to move, Enter to select, Escape to close). */ export function CellTypePopover(props: { completions: Completion[]; + focus?: FocusHandle; tooltip?: string; /** Whether the button is visible. Defaults to `true`. The button always remains visible while the popover is open. */ @@ -454,6 +462,9 @@ export function CellTypePopover(props: { if (!isOpen()) { return; } + if (props.focus && !props.focus.hasFocus()) { + return; + } const ref = completionsRef(); if (evt.key === "ArrowDown") { ref?.nextPresumptive(); diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 441734f45..5ea18b825 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -1,4 +1,5 @@ import Resizable, { type ContextValue } from "@corvu/resizable"; +import { makeEventListener } from "@solid-primitives/event-listener"; import { Title } from "@solidjs/meta"; import { useNavigate, useParams } from "@solidjs/router"; import ChevronsRight from "lucide-solid/icons/chevrons-right"; @@ -17,7 +18,17 @@ import { } from "solid-js"; import invariant from "tiny-invariant"; -import { Button, IconButton, ResizableHandle, WarningBanner } from "catcolab-ui-components"; +import { + Button, + type FocusHandle, + IconButton, + keyEventHasModifier, + primaryModifier, + ResizableHandle, + rootFocus, + useChildFocus, + WarningBanner, +} from "catcolab-ui-components"; import { getLiveAnalysis, type LiveAnalysisDoc } from "../analysis"; import { AnalysisNotebookEditor } from "../analysis/analysis_editor"; import { AnalysisInfo } from "../analysis/analysis_info"; @@ -63,6 +74,7 @@ export default function DocumentPage() { const params = useParams(); const navigate = useNavigate(); const isSidePanelOpen = () => !!params.subkind && !!params.subref; + const paneFocus = useChildFocus<"primary" | "secondary">(rootFocus, { default: "primary" }); // Redirect if primary and secondary refs match createEffect(() => { @@ -101,6 +113,7 @@ export default function DocumentPage() { ); const closeSidePanel = () => { + paneFocus.setActiveChild("primary"); navigate(`/${params.kind}/${params.ref}`); }; @@ -120,6 +133,7 @@ export default function DocumentPage() { // expand the second panel context?.expand(1); } else { + paneFocus.setActiveChild("primary"); // collapse the second panel context?.collapse(1); // Set the first panel to be the full size @@ -161,6 +175,8 @@ export default function DocumentPage() { closeSidePanel={closeSidePanel} togglePrimaryHistorySidebar={togglePrimaryHistorySidebar} toggleSecondaryHistorySidebar={toggleSecondaryHistorySidebar} + primaryPaneFocus={paneFocus.childFocus("primary")} + secondaryPaneFocus={paneFocus.childFocus("secondary")} /> } sidebarContents={ @@ -178,6 +194,8 @@ export default function DocumentPage() { } : undefined; })()} + primaryPaneFocus={paneFocus.childFocus("primary")} + secondaryPaneFocus={paneFocus.childFocus("secondary")} refetchPrimaryDoc={refetchPrimaryDoc} refetchSecondaryDoc={refetchSecondaryDoc} /> @@ -194,6 +212,8 @@ export default function DocumentPage() { setResizableContext={setResizableContext} primaryHistoryOpen={primaryHistoryOpen()} secondaryHistoryOpen={secondaryHistoryOpen()} + primaryPaneFocus={paneFocus.childFocus("primary")} + secondaryPaneFocus={paneFocus.childFocus("secondary")} /> )} @@ -212,6 +232,8 @@ function SplitPaneToolbar(props: { maximizeSidePanel: () => void; togglePrimaryHistorySidebar: () => void; toggleSecondaryHistorySidebar: () => void; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; }) { const secondaryPanelSize = () => props.panelSizes?.[1]; const primaryPanelSize = () => props.panelSizes?.[0]; @@ -221,7 +243,13 @@ function SplitPaneToolbar(props: { - + { + props.primaryPaneFocus.setFocused(true); + props.togglePrimaryHistorySidebar(); + }} + tooltip="Toggle history" + > @@ -232,7 +260,10 @@ function SplitPaneToolbar(props: { style={{ left: `${(primaryPanelSize() ?? 0) * 100}%` }} > { + props.primaryPaneFocus.setFocused(true); + props.togglePrimaryHistorySidebar(); + }} tooltip="Toggle history" > @@ -249,6 +280,7 @@ function SplitPaneToolbar(props: { closeSidePanel={props.closeSidePanel} maximizeSidePanel={props.maximizeSidePanel} toggleHistorySidebar={props.toggleSecondaryHistorySidebar} + secondaryPaneFocus={props.secondaryPaneFocus} /> )} @@ -263,6 +295,7 @@ function SecondaryToolbar(props: { closeSidePanel: () => void; maximizeSidePanel: () => void; toggleHistorySidebar: () => void; + secondaryPaneFocus: FocusHandle; }) { return ( <> @@ -288,7 +321,13 @@ function SecondaryToolbar(props: { > {(secondary) => (
      - + { + props.secondaryPaneFocus.setFocused(true); + props.toggleHistorySidebar(); + }} + tooltip="Toggle history" + > void; primaryHistoryOpen: boolean; secondaryHistoryOpen: boolean; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; }) { return ( @@ -329,6 +370,7 @@ function ResizablePanels(props: { refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} historySidebarOpen={props.primaryHistoryOpen} + focus={props.primaryPaneFocus} /> @@ -347,6 +389,7 @@ function ResizablePanels(props: { refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} historySidebarOpen={props.secondaryHistoryOpen} + focus={props.secondaryPaneFocus} /> )} @@ -365,6 +408,7 @@ export function DocumentPane(props: { refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; historySidebarOpen: boolean; + focus: FocusHandle; }) { const api = useApi(); const [isDeleted, setIsDeleted] = createSignal(false); @@ -401,10 +445,33 @@ export function DocumentPane(props: { const history = useSnapshotHistory(() => props.docRef.refId); + makeEventListener(window, "keydown", (evt) => { + if (!props.focus.hasFocus()) { + return; + } + const key = evt.key.toUpperCase(); + const hasPrimary = keyEventHasModifier(evt, primaryModifier); + if (!hasPrimary || evt.altKey) { + return; + } + + if (key === "Z" && !evt.shiftKey && history.canUndo()) { + history.onUndo(); + return evt.preventDefault(); + } + if ( + ((key === "Z" && evt.shiftKey) || (key === "Y" && !evt.shiftKey)) && + history.canRedo() + ) { + history.onRedo(); + return evt.preventDefault(); + } + }); + // oxlint-disable solid/reactivity -- Context.Provider value getter is reactive return ( props.docRef.refId}> -
      +
      props.focus.setFocused(true)}>
      - {(liveModel) => } + {(liveModel) => ( + + )} {(liveDiagram) => ( - + )} {(liveAnalysis) => ( - + )} diff --git a/packages/frontend/src/page/document_page_sidebar.tsx b/packages/frontend/src/page/document_page_sidebar.tsx index 29c34f6b7..64f249fe9 100644 --- a/packages/frontend/src/page/document_page_sidebar.tsx +++ b/packages/frontend/src/page/document_page_sidebar.tsx @@ -2,7 +2,7 @@ import { useNavigate } from "@solidjs/router"; import { createMemo, createResource, For, Show, useContext } from "solid-js"; import { stringify as uuidStringify } from "uuid"; -import { DocumentTypeIcon } from "catcolab-ui-components"; +import { DocumentTypeIcon, type FocusHandle } from "catcolab-ui-components"; import type { Document, Link } from "catlog-wasm"; import { type Api, type LiveDocWithRef, useApi } from "../api"; import { TheoryLibraryContext } from "../theory"; @@ -12,6 +12,8 @@ import { DocumentMenu } from "./document_menu"; export function DocumentSidebar(props: { primaryDoc?: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -21,6 +23,8 @@ export function DocumentSidebar(props: { @@ -59,6 +63,8 @@ async function getDocParent(doc: Document, api: Api): Promise void; refetchSecondaryDoc: () => void; }) { @@ -78,6 +84,8 @@ function RelatedDocuments(props: { indent={1} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} + primaryPaneFocus={props.primaryPaneFocus} + secondaryPaneFocus={props.secondaryPaneFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -92,6 +100,8 @@ function DocumentsTreeNode(props: { indent: number; primaryDoc: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -114,12 +124,22 @@ function DocumentsTreeNode(props: { .map((rel) => uuidStringify(rel.refId)); }); - // oxlint-disable-next-line solid/reactivity -- createResource fetcher - const [childDocs] = createResource(childRefIds, async (refIds) => { + const childDocSource = createMemo(() => { + const refIds = childRefIds(); + return { + createdAtByRefId: Object.fromEntries( + refIds.map((refId) => [refId, userState.documents[refId]?.createdAt ?? 0]), + ), + isParentOwnerless: props.doc.docRef.permissions.anyone === "Own", + refIds, + }; + }); + + const [childDocs] = createResource(childDocSource, async (source) => { // Individual failures are skipped to prevent one corrupt document // from crashing the entire sidebar. const childDocs = await Promise.all( - refIds.map(async (refId) => { + source.refIds.map(async (refId) => { try { return await api.getLiveDoc(refId); } catch (e) { @@ -132,21 +152,18 @@ function DocumentsTreeNode(props: { (doc): doc is NonNullable => doc !== null, ); - const isParentOwnerless = props.doc.docRef.permissions.anyone === "Own"; - // Don't show ownerless children or deleted documents const filtered = loadedChildDocs.filter( (childDoc) => !childDoc.docRef.isDeleted && - (isParentOwnerless || childDoc.docRef.permissions.anyone !== "Own"), + (source.isParentOwnerless || childDoc.docRef.permissions.anyone !== "Own"), ); // Sort by createdAt descending (newest first) - const docs = userState.documents; filtered.sort((a, b) => { - const aInfo = a.docRef.refId ? docs[a.docRef.refId] : undefined; - const bInfo = b.docRef.refId ? docs[b.docRef.refId] : undefined; - return (bInfo?.createdAt ?? 0) - (aInfo?.createdAt ?? 0); + const aCreatedAt = a.docRef.refId ? (source.createdAtByRefId[a.docRef.refId] ?? 0) : 0; + const bCreatedAt = b.docRef.refId ? (source.createdAtByRefId[b.docRef.refId] ?? 0) : 0; + return bCreatedAt - aCreatedAt; }); return filtered; @@ -159,6 +176,8 @@ function DocumentsTreeNode(props: { indent={props.indent} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} + primaryPaneFocus={props.primaryPaneFocus} + secondaryPaneFocus={props.secondaryPaneFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -169,6 +188,8 @@ function DocumentsTreeNode(props: { indent={props.indent + 1} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} + primaryPaneFocus={props.primaryPaneFocus} + secondaryPaneFocus={props.secondaryPaneFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -183,6 +204,8 @@ function DocumentsTreeLeaf(props: { indent: number; primaryDoc: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -193,6 +216,11 @@ function DocumentsTreeLeaf(props: { const clickedRefId = createMemo(() => props.doc.docRef.refId); const primaryRefId = createMemo(() => props.primaryDoc.docRef.refId); const secondaryRefId = createMemo(() => props.secondaryDoc?.docRef.refId); + const isPrimary = () => clickedRefId() === primaryRefId(); + const isSecondary = () => clickedRefId() === secondaryRefId(); + const isFocused = () => + (isPrimary() && props.primaryPaneFocus.hasFocus()) || + (isSecondary() && props.secondaryPaneFocus.hasFocus()); const iconLetters = createMemo(() => { const doc = props.doc.liveDoc.doc; @@ -211,14 +239,17 @@ function DocumentsTreeLeaf(props: { const handleClick = async () => { // If clicking on primary or secondary doc, navigate to just that doc if (clickedRefId() === primaryRefId() || clickedRefId() === secondaryRefId()) { + props.primaryPaneFocus.setFocused(true); navigate(`/${createLinkPart(props.doc)}`); } else { // Otherwise, open it as a side panel or put on the left if it is a parent doc const clickedDoc = props.doc; const parentOfPrimary = await getDocParent(props.primaryDoc.liveDoc.doc, api); if (parentOfPrimary && clickedDoc.docRef.refId === parentOfPrimary.docRef.refId) { + props.primaryPaneFocus.setFocused(true); navigate(`/${createLinkPart(clickedDoc)}/${createLinkPart(props.primaryDoc)}`); } else { + props.secondaryPaneFocus.setFocused(true); navigate(`/${createLinkPart(props.primaryDoc)}/${createLinkPart(clickedDoc)}`); } } @@ -229,7 +260,8 @@ function DocumentsTreeLeaf(props: { onClick={handleClick} class="related-document" classList={{ - active: props.doc.docRef.refId === props.primaryDoc.docRef.refId, + active: isPrimary() || isSecondary(), + focused: isFocused(), }} style={{ "padding-left": `${props.indent * 16}px` }} > diff --git a/packages/frontend/src/page/history_sidebar.tsx b/packages/frontend/src/page/history_sidebar.tsx index 50b1db463..de635b1da 100644 --- a/packages/frontend/src/page/history_sidebar.tsx +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -1,3 +1,4 @@ +import { formatShortcut, primaryModifier } from "catcolab-ui-components"; import { HistoryNavigator } from "catcolab-ui-components"; import type { SnapshotHistory } from "./use_snapshot_history"; @@ -10,8 +11,8 @@ export function HistorySidebar(props: { history: SnapshotHistory }) { onUndo={props.history.onUndo} onRedo={props.history.onRedo} onSelect={props.history.navigate} - undoTooltip="Undo" - redoTooltip="Redo" + undoTooltip={`Undo (${formatShortcut([primaryModifier, "Z"])})`} + redoTooltip={`Redo (${formatShortcut([primaryModifier, "Shift", "Z"])} or ${formatShortcut([primaryModifier, "Y"])})`} /> ); } diff --git a/packages/frontend/src/page/home_page.css b/packages/frontend/src/page/home_page.css index 61ca7a2df..3e0af0ace 100644 --- a/packages/frontend/src/page/home_page.css +++ b/packages/frontend/src/page/home_page.css @@ -65,13 +65,7 @@ .home-nav-button { margin-top: 20px; padding: 10px 20px; - background: var(--color-topos-primary); - border: none; - border-radius: 5px; - font-family: var(--main-font); font-size: 18px; - color: var(--color-background); - cursor: pointer; } .quick-actions { @@ -205,31 +199,5 @@ } .home-nav-button.get-started { - border: 1px solid var(--color-background); - border-radius: 5px; - cursor: pointer; font-weight: bold; - color: var(--color-background); -} - -.home-nav-button.outline { - background: var(--color-background); - color: var(--color-gray-850); - border: 1px solid var(--color-gray-700); - border-radius: 5px; - cursor: pointer; -} - -.home-nav-button.outline:hover:not(:disabled) { - background-color: var(--color-hover-button-light); -} - -.home-nav-button:hover:not(:disabled) { - background-color: var(--color-topos-primary-hover); -} - -/* stylelint-disable-next-line no-descending-specificity -- :disabled and :hover:not(:disabled) are mutually exclusive */ -.home-nav-button:disabled { - opacity: 0.6; - cursor: not-allowed; } diff --git a/packages/frontend/src/page/home_page.tsx b/packages/frontend/src/page/home_page.tsx index a14b6e3e5..75b504d1e 100644 --- a/packages/frontend/src/page/home_page.tsx +++ b/packages/frontend/src/page/home_page.tsx @@ -1,5 +1,6 @@ import { useNavigate } from "@solidjs/router"; import { getAuth } from "firebase/auth"; +import ArrowLeftIcon from "lucide-solid/icons/arrow-left"; import Binoculars from "lucide-solid/icons/binoculars"; import Bird from "lucide-solid/icons/bird"; import ExternalLink from "lucide-solid/icons/external-link"; @@ -11,6 +12,7 @@ import LogInIcon from "lucide-solid/icons/log-in"; import { useAuth, useFirebaseApp } from "solid-firebase"; import { createSignal, Match, Show, Switch } from "solid-js"; +import { Button } from "catcolab-ui-components"; import { useApi } from "../api"; import { createModel } from "../model/document"; import { stdTheories } from "../stdlib"; @@ -57,12 +59,10 @@ export default function HomePage() {
      - +
      @@ -74,28 +74,34 @@ export default function HomePage() {
      - + - + - +
      diff --git a/packages/frontend/src/page/sidebar_layout.css b/packages/frontend/src/page/sidebar_layout.css index a97e7344f..beb0e063b 100644 --- a/packages/frontend/src/page/sidebar_layout.css +++ b/packages/frontend/src/page/sidebar_layout.css @@ -122,7 +122,7 @@ .related-document { padding: 5px 8px; height: 30px; - border-radius: 6px; + border-left: 3px solid transparent; transition: background 20ms; display: flex; justify-content: space-between; @@ -173,6 +173,10 @@ background: var(--color-hover-bg-dark); } } + + &.focused { + border-left-color: var(--color-indicator); + } } .resizeable-handle { diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 645e8e44c..bc45e2db6 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -407,7 +407,7 @@ export function renderSQL( help, component: (props) => , initialContent: () => ({ - backend: SQLBackend.MySQL, + backend: SQLBackend.PostgresSQL, filename: "schema.sql", }), }; diff --git a/packages/frontend/src/stdlib/analyses/sql.module.css b/packages/frontend/src/stdlib/analyses/sql.module.css new file mode 100644 index 000000000..83698f2af --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/sql.module.css @@ -0,0 +1,9 @@ +.headerContainer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + flex-wrap: nowrap; + white-space: nowrap; + flex-direction: row; +} diff --git a/packages/frontend/src/stdlib/analyses/sql.tsx b/packages/frontend/src/stdlib/analyses/sql.tsx index 0ae68af1c..c8c8e1c18 100644 --- a/packages/frontend/src/stdlib/analyses/sql.tsx +++ b/packages/frontend/src/stdlib/analyses/sql.tsx @@ -4,10 +4,12 @@ import Copy from "lucide-solid/icons/copy"; import Download from "lucide-solid/icons/download"; import { For, Match, Show, Switch } from "solid-js"; -import { BlockTitle, ErrorAlert, IconButton } from "catcolab-ui-components"; +import { CodeView, BlockTitle, ErrorAlert, IconButton } from "catcolab-ui-components"; import type { ModelAnalysisProps } from "../../analysis"; import * as SQL from "./sql_types.ts"; +import styles from "./sql.module.css"; + const copyToClipboard = (text: string) => navigator.clipboard.writeText(text); const tooltip = () => ( @@ -31,14 +33,7 @@ const tooltip = () => ( export function SQLHeader(sql: string) { return ( -
      +
      copyToClipboard(sql)} disabled={false} @@ -105,15 +100,20 @@ export default function SQLSchemaAnalysis( actions={SQLHeader(sql())} settingsPane={BackendConfig()} /> -
      {sql()}
      + + +
      )} - -

      {"The model failed to compile into a SQL script."}

      -

      {"Check for cycles in foreign key constraints."}

      -
      +
      + + +

      {"The model failed to compile into a SQL script."}

      +

      {"Check for cycles in foreign key constraints."}

      +
      +
      )} diff --git a/packages/frontend/src/visualization/elk_svg.tsx b/packages/frontend/src/visualization/elk_svg.tsx index e98bf9e21..d5b9a8e2b 100644 --- a/packages/frontend/src/visualization/elk_svg.tsx +++ b/packages/frontend/src/visualization/elk_svg.tsx @@ -40,14 +40,20 @@ export function ElkLayout(props: { () => { const elk = elkResource(); const graph = props.graph; + const args = props.args; + const elkToLayout = props.elkToLayout; if (elk && graph) { - return [elk, graph] as const; + return [elk, graph, args, elkToLayout] as const; } }, - // oxlint-disable-next-line solid/reactivity -- createResource fetcher - async ([elk, graph]: readonly [ELK, ElkNode]): Promise => { - const elkNode = await elk.layout(graph, props.args); - return props.elkToLayout(elkNode); + async ([elk, graph, args, elkToLayout]: readonly [ + ELK, + ElkNode, + ElkLayoutArguments | undefined, + (e: ElkNode) => T, + ]): Promise => { + const elkNode = await elk.layout(graph, args); + return elkToLayout(elkNode); }, ); diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 8aafe4976..dd0570286 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -1,6 +1,4 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { monorepoDedupe } from "@catcolab-dev-tools/vite-plugin-monorepo-dedupe"; import mdx from "@mdx-js/rollup"; import rehypeKatex from "rehype-katex"; import remarkMath from "remark-math"; @@ -8,13 +6,9 @@ import solid from "vite-plugin-solid"; import wasm from "vite-plugin-wasm"; import { defineConfig } from "vitest/config"; -// __dirname is not available in ES modules. The test:ci script uses --configLoader=runner -// (required for readonly (Nix) environments), which runs Vite in ESM mode. -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - export default defineConfig({ plugins: [ + monorepoDedupe(), wasm(), mdx({ // https://mdxjs.com/docs/getting-started/#solid @@ -30,9 +24,6 @@ export default defineConfig({ sourcemap: true, target: "es2022", }, - resolve: { - dedupe: getCommonDependencies(), - }, test: { // Run test files sequentially to prevent cross-test contamination via // the server's shared user state. @@ -52,24 +43,3 @@ export default defineConfig({ }, }, }); - -/** - * Get common dependencies between frontend and ui-components packages. - * Needed to link other packages that use Solid.js: - * https://github.com/solidjs/solid/issues/1472 - */ -function getCommonDependencies(): string[] { - const frontendPkg = JSON.parse(readFileSync(resolve(__dirname, "./package.json"), "utf-8")); - const uiComponentsPkg = JSON.parse( - readFileSync(resolve(__dirname, "../ui-components/package.json"), "utf-8"), - ); - - const frontendDeps = new Set(Object.keys(frontendPkg.dependencies || {})); - const uiComponentsDeps = new Set(Object.keys(uiComponentsPkg.dependencies || {})); - - // @ts-expect-error: intersection method does exist on Set in our - // vite.config target i.e. NodeJS - const commonDeps = frontendDeps.intersection(uiComponentsDeps); - - return Array.from(commonDeps); -} diff --git a/packages/gaios/package.json b/packages/gaios/package.json index 97102b6df..ea3269b05 100644 --- a/packages/gaios/package.json +++ b/packages/gaios/package.json @@ -32,6 +32,7 @@ "uuid": "^11.0.3" }, "devDependencies": { + "@catcolab-dev-tools/vite-plugin-monorepo-dedupe": "link:../../tools/vite-plugin-monorepo-dedupe", "@types/react": "^18.3.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/packages/gaios/pnpm-lock.yaml b/packages/gaios/pnpm-lock.yaml index a23fbaa4e..95e60342f 100644 --- a/packages/gaios/pnpm-lock.yaml +++ b/packages/gaios/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: ^11.0.3 version: 11.1.0 devDependencies: + '@catcolab-dev-tools/vite-plugin-monorepo-dedupe': + specifier: link:../../tools/vite-plugin-monorepo-dedupe + version: link:../../tools/vite-plugin-monorepo-dedupe '@types/react': specifier: ^18.3.3 version: 18.3.18 @@ -992,7 +995,6 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@11.12.0: diff --git a/packages/gaios/src/model_tool.tsx b/packages/gaios/src/model_tool.tsx index cc04d9d8c..4d14fcfe0 100644 --- a/packages/gaios/src/model_tool.tsx +++ b/packages/gaios/src/model_tool.tsx @@ -11,6 +11,7 @@ import { ModelNotebookEditor } from "../../frontend/src/model/model_editor"; import { ModelDocumentHead } from "../../frontend/src/model/model_info"; import { stdTheories } from "../../frontend/src/stdlib"; import { TheoryLibraryContext } from "../../frontend/src/theory"; +import { rootFocus } from "../../ui-components/src/util/focus"; import type { ModelDoc } from "./model_datatype"; import "../../ui-components/src/global.css"; @@ -54,7 +55,10 @@ export function renderModelTool(handle: DocHandle, element: ToolElemen - + )} diff --git a/packages/gaios/vite.config.ts b/packages/gaios/vite.config.ts index a7e7f7889..404d933ab 100644 --- a/packages/gaios/vite.config.ts +++ b/packages/gaios/vite.config.ts @@ -1,21 +1,12 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { monorepoDedupe } from "@catcolab-dev-tools/vite-plugin-monorepo-dedupe"; import { defineConfig } from "vite"; import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; import solid from "vite-plugin-solid"; import wasm from "vite-plugin-wasm"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - export default defineConfig({ base: "./", - plugins: [wasm(), solid(), cssInjectedByJsPlugin()], - - resolve: { - dedupe: getCommonDependencies(), - }, + plugins: [monorepoDedupe(), wasm(), solid(), cssInjectedByJsPlugin()], build: { minify: false, @@ -40,30 +31,3 @@ export default defineConfig({ }, }, }); - -/** - * Get common dependencies between gaios, frontend, and ui-components packages. - * Needed to link other packages that use Solid.js: - * https://github.com/solidjs/solid/issues/1472 - */ -function getCommonDependencies(): string[] { - const gaiosPkg = JSON.parse(readFileSync(resolve(__dirname, "./package.json"), "utf-8")); - const frontendPkg = JSON.parse( - readFileSync(resolve(__dirname, "../frontend/package.json"), "utf-8"), - ); - const uiComponentsPkg = JSON.parse( - readFileSync(resolve(__dirname, "../ui-components/package.json"), "utf-8"), - ); - - const gaiosDeps = new Set(Object.keys(gaiosPkg.dependencies || {})); - const frontendDeps = new Set(Object.keys(frontendPkg.dependencies || {})); - const uiComponentsDeps = new Set(Object.keys(uiComponentsPkg.dependencies || {})); - - // @ts-expect-error: intersection method does exist on Set in our - // vite.config target i.e. NodeJS - const commonDeps = gaiosDeps - .intersection(frontendDeps) - .union(gaiosDeps.intersection(uiComponentsDeps)); - - return Array.from(commonDeps); -} diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 2c695c0d4..bc8d301fd 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -27,9 +27,11 @@ "@solid-primitives/destructure": "^0.2.1", "katex": "^0.16.22", "lucide-solid": "^0.471.0", + "shiki": "^4.0.2", "solid-js": "^1.9.10" }, "devDependencies": { + "@catcolab-dev-tools/vite-plugin-monorepo-dedupe": "link:../../tools/vite-plugin-monorepo-dedupe", "@chromatic-com/storybook": "^4.1.2", "@storybook/addon-a11y": "^10.0.2", "@storybook/addon-docs": "^10.0.2", diff --git a/packages/ui-components/pnpm-lock.yaml b/packages/ui-components/pnpm-lock.yaml index 57b133330..3a3ac31d7 100644 --- a/packages/ui-components/pnpm-lock.yaml +++ b/packages/ui-components/pnpm-lock.yaml @@ -38,10 +38,16 @@ importers: lucide-solid: specifier: ^0.471.0 version: 0.471.0(solid-js@1.9.10) + shiki: + specifier: ^4.0.2 + version: 4.0.2 solid-js: specifier: ^1.9.10 version: 1.9.10 devDependencies: + '@catcolab-dev-tools/vite-plugin-monorepo-dedupe': + specifier: link:../../tools/vite-plugin-monorepo-dedupe + version: link:../../tools/vite-plugin-monorepo-dedupe '@chromatic-com/storybook': specifier: ^4.1.2 version: 4.1.2(storybook@10.0.2(@testing-library/dom@10.4.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.2.2(@types/node@24.10.0))) @@ -634,6 +640,37 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@solid-primitives/active-element@2.1.3': resolution: {integrity: sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A==} peerDependencies: @@ -778,12 +815,18 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -793,6 +836,9 @@ packages: '@types/react@19.2.2': resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/project-service@8.57.0': resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -830,6 +876,9 @@ packages: resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@vitest/browser-playwright@4.0.8': resolution: {integrity: sha512-MUi0msIAPXcA2YAuVMcssrSYP/yylxLt347xyTC6+ODl0c4XQFs0d2AN3Pc3iTa0pxIGmogflUV6eogXpPbJeA==} peerDependencies: @@ -1012,6 +1061,9 @@ packages: caniuse-lite@1.0.30001752: resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1024,6 +1076,12 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -1047,6 +1105,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -1090,6 +1151,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1267,6 +1331,12 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} @@ -1277,6 +1347,9 @@ packages: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1426,10 +1499,28 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + merge-anything@5.1.7: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1467,6 +1558,12 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1549,6 +1646,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1578,6 +1678,15 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1617,6 +1726,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1667,6 +1780,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1701,6 +1817,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1753,6 +1872,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1778,6 +1900,21 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1795,6 +1932,12 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-solid@2.11.10: resolution: {integrity: sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==} peerDependencies: @@ -1931,6 +2074,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -2381,6 +2527,46 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true + '@shikijs/core@4.0.2': + dependencies: + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@solid-primitives/active-element@2.1.3(solid-js@1.9.10)': dependencies: '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) @@ -2542,10 +2728,18 @@ snapshots: '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} '@types/katex@0.16.7': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/mdx@2.0.13': {} '@types/node@24.10.0': @@ -2556,6 +2750,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@3.0.3': {} + '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) @@ -2607,6 +2803,8 @@ snapshots: '@typescript-eslint/types': 8.57.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.1': {} + '@vitest/browser-playwright@4.0.8(playwright@1.56.1)(vite@7.2.2(@types/node@24.10.0))(vitest@4.0.8)': dependencies: '@vitest/browser': 4.0.8(vite@7.2.2(@types/node@24.10.0))(vitest@4.0.8) @@ -2821,6 +3019,8 @@ snapshots: caniuse-lite@1.0.30001752: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2836,6 +3036,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + check-error@2.1.1: {} chromatic@12.2.0: {} @@ -2846,6 +3050,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@8.3.0: {} concat-map@0.0.1: {} @@ -2874,6 +3080,10 @@ snapshots: dequal@2.0.3: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -3073,12 +3283,32 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + html-entities@2.3.3: {} html-escaper@2.0.2: {} html-tags@3.3.1: {} + html-void-elements@3.0.0: {} + ignore@5.3.2: {} import-fresh@3.3.1: @@ -3212,10 +3442,39 @@ snapshots: dependencies: semver: 7.7.3 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + merge-anything@5.1.7: dependencies: is-what: 4.1.16 + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + min-indent@1.0.1: {} minimatch@10.2.4: @@ -3242,6 +3501,14 @@ snapshots: node-releases@2.0.27: {} + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3319,6 +3586,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + property-information@7.1.0: {} + punycode@2.3.1: {} react-docgen-typescript@2.4.0(typescript@6.0.3): @@ -3347,6 +3616,16 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + resolve-from@4.0.0: {} rollup@4.52.5: @@ -3395,6 +3674,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@4.0.2: + dependencies: + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -3446,6 +3736,8 @@ snapshots: source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -3502,6 +3794,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3543,6 +3840,8 @@ snapshots: totalist@3.0.1: {} + trim-lines@3.0.1: {} + ts-api-utils@2.4.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -3559,6 +3858,29 @@ snapshots: undici-types@7.16.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} unplugin@2.3.10: @@ -3578,6 +3900,16 @@ snapshots: dependencies: punycode: 2.3.1 + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.2.2(@types/node@24.10.0)): dependencies: '@babel/core': 7.28.5 @@ -3678,3 +4010,5 @@ snapshots: yallist@3.1.1: {} yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/packages/ui-components/src/button.tsx b/packages/ui-components/src/button.tsx index 80561c110..62f6ef3ef 100644 --- a/packages/ui-components/src/button.tsx +++ b/packages/ui-components/src/button.tsx @@ -12,7 +12,7 @@ export function Button( children: JSX.Element; } & ComponentProps<"button">, ) { - const [props, buttonProps] = splitProps(allProps, ["variant", "children"]); + const [props, buttonProps] = splitProps(allProps, ["variant", "children", "class"]); const variantClass = () => { switch (props.variant) { @@ -29,9 +29,9 @@ export function Button( return ( diff --git a/packages/ui-components/src/code_view.stories.tsx b/packages/ui-components/src/code_view.stories.tsx new file mode 100644 index 000000000..98fd56d3c --- /dev/null +++ b/packages/ui-components/src/code_view.stories.tsx @@ -0,0 +1,91 @@ +import { bundledLanguagesInfo, bundledThemesInfo } from "shiki"; +import { For } from "solid-js"; +import type { Meta, StoryObj } from "storybook-solidjs-vite"; + +import { CodeView } from "./code_view"; + +const langs = bundledLanguagesInfo.map((l) => l.id); +const themes = bundledThemesInfo.map((t) => t.id); + +const meta: Meta = { + title: "Misc/CodeView", + component: CodeView, + tags: ["autodocs"], + argTypes: { + text: { + control: "text", + description: "The source code to display", + }, + lang: { + control: "select", + options: langs, + description: "The lang for syntax highlighting", + }, + theme: { + control: "select", + options: themes, + description: "The theme for syntax highlighting", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const SQL: Story = { + // excluding from autodocs and dev seems to be the way to have this + // component as the first thing in the docs and only there + tags: ["!autodocs", "!dev"], + args: { + lang: "sql", + text: `SELECT p.name, COUNT(o.id) AS order_count +FROM products p +LEFT JOIN orders o ON o.product_id = p.id +WHERE p.active = TRUE +GROUP BY p.name +ORDER BY order_count DESC;`, + }, +}; + +export const Typescript: Story = { + args: { + lang: "typescript", + text: `function greet(name: string): string { + return \`Hello, \${name}!\`; +}`, + }, +}; + +const sampleTs = `function greet(name: string): string { + return \`Hello, \${name}!\`; +}`; + +export const Themes: Story = { + render: () => ( +
      + + {(theme) => ( +
      +

      {theme}

      + +
      + )} +
      +
      + ), +}; diff --git a/packages/ui-components/src/code_view.tsx b/packages/ui-components/src/code_view.tsx new file mode 100644 index 000000000..c1c5fb69c --- /dev/null +++ b/packages/ui-components/src/code_view.tsx @@ -0,0 +1,26 @@ +import { type BundledLanguage, type BundledTheme, codeToHtml } from "shiki"; +import { createResource } from "solid-js"; + +export type CodeViewProps = { + text: string; + lang: BundledLanguage; + theme?: BundledTheme; +}; + +export const CodeView = (props: CodeViewProps) => { + const [html] = createResource( + () => ({ text: props.text, lang: props.lang, theme: props.theme }), + ({ text, lang, theme }) => + codeToHtml(text, { + lang, + theme: theme ?? "min-light", + }), + ); + + return ( + // shiki uses hast-util-to-html which escapes html entities so no need + // for extra sanitization and setting innerHTML should be safe + // oxlint-disable-next-line solid/no-innerhtml +
      + ); +}; diff --git a/packages/ui-components/src/colors.css b/packages/ui-components/src/colors.css index 01847c714..fb8387c83 100644 --- a/packages/ui-components/src/colors.css +++ b/packages/ui-components/src/colors.css @@ -78,12 +78,12 @@ * Positive Colors * From lightest to darkest * ======================================== */ - --color-icon-button-positive-hover: #efe; - --color-icon-button-positive-active: #dfd; - --color-button-positive-hover: #3cb371; - --color-button-positive-base: #2e8b57; - --color-icon-button-positive-text: #0a0; - --color-button-positive-border: darkgreen; + --color-icon-button-positive-hover: #e8f7f6; + --color-icon-button-positive-active: #d4efed; + --color-button-positive-hover: var(--color-topos-primary-hover); + --color-button-positive-base: var(--color-topos-primary); + --color-icon-button-positive-text: var(--color-topos-primary); + --color-button-positive-border: var(--color-topos-primary); /* ======================================== * Danger Colors @@ -111,8 +111,9 @@ /* ======================================== * Information Colors * ======================================== */ - --color-alert-question: cornflowerblue; - --color-alert-note: teal; + --color-indicator: cornflowerblue; + --color-alert-question: var(--color-indicator); + --color-alert-note: var(--color-topos-primary); /* ======================================== * Rich Text Editor Colors diff --git a/packages/ui-components/src/colors.stories.tsx b/packages/ui-components/src/colors.stories.tsx index 10cf413d7..9019b66a0 100644 --- a/packages/ui-components/src/colors.stories.tsx +++ b/packages/ui-components/src/colors.stories.tsx @@ -456,7 +456,7 @@ export const AllColors: Story = {
      - @@ -2635,8 +2620,8 @@ export const PositiveColorUsage: Story = {

      Positive icon buttons use transparent background by default. On hover, they - use --color-icon-button-positive-hover for background and - --color-icon-button-positive-text for color. On active, they use + use Topos-primary-tinted --color-icon-button-positive-hover for background + and --color-icon-button-positive-text for color. On active, they use --color-icon-button-positive-active.

      - Alert Question Variant (alert.css) + Indicator (alert.css, history_navigator.module.css)

      - Question alerts use --color-alert-question (cornflowerblue) for the accent - color and heading text. + Indicators use --color-indicator (cornflowerblue), with + --color-alert-question as the alert-specific alias for question headings.

      @@ -3189,8 +3174,8 @@ export const InformationColorUsage: Story = { Alert Note Variant (alert.css)

      - Note alerts use --color-alert-note (teal) for the accent color and heading - text. + Note alerts use --color-alert-note (Topos primary) for the accent color and + heading text.

      @@ -3231,6 +3216,7 @@ export const InformationColorUsage: Story = { gap: "1.5rem", }} > +

      diff --git a/packages/ui-components/src/foldable.stories.tsx b/packages/ui-components/src/foldable.stories.tsx index 48ef5a003..5759f8623 100644 --- a/packages/ui-components/src/foldable.stories.tsx +++ b/packages/ui-components/src/foldable.stories.tsx @@ -7,7 +7,7 @@ import { Foldable } from "./foldable"; import { IconButton } from "./icon_button"; const meta = { - title: "Foldable", + title: "Layout/Foldable", component: Foldable, } satisfies Meta; diff --git a/packages/ui-components/src/history_navigator.module.css b/packages/ui-components/src/history_navigator.module.css index 71197aa97..ee5f1b1b7 100644 --- a/packages/ui-components/src/history_navigator.module.css +++ b/packages/ui-components/src/history_navigator.module.css @@ -58,7 +58,7 @@ width: 10px; height: 10px; border-radius: 50%; - background-color: var(--color-alert-question); + background-color: var(--color-indicator); } .timeCell { diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index de93d87b7..fe1609ddb 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -2,6 +2,7 @@ export * from "./alert"; export * from "./block_title"; export * from "./settings_disclosure"; export * from "./button"; +export * from "./code_view"; export * from "./completions"; export * from "./dialog"; export * from "./document_type_icon"; @@ -13,6 +14,7 @@ export * from "./form"; export * from "./history_navigator"; export * from "./icon_button"; export * from "./inline_input"; +export * from "./inline_list_editor"; export * from "./input_options"; export * from "./katex_display"; export * from "./model_file_icon"; @@ -22,6 +24,7 @@ export * from "./relative_time"; export * from "./resizable"; export * from "./spinner"; export * from "./text_input"; +export * from "./util/focus"; export * from "./util/keyboard"; export * from "./virtual_list"; export * from "./warning_banner"; diff --git a/packages/ui-components/src/inline_list_editor.module.css b/packages/ui-components/src/inline_list_editor.module.css new file mode 100644 index 000000000..fae302910 --- /dev/null +++ b/packages/ui-components/src/inline_list_editor.module.css @@ -0,0 +1,30 @@ +.inlineList { + display: flex; + flex-direction: row; + align-items: center; + list-style: none; + padding: 0; + + li { + display: flex; + flex-direction: row; + } +} + +.defaultDelimiter, +.defaultSeparator { + color: var(--color-gray-800); +} + +.defaultDelimiter { + transform: scale(1, 1.5); +} + +.emptyListInput { + background: transparent; + border: none; + outline: none; + width: 0.5ex; + margin: 0; + padding: 0; +} diff --git a/packages/ui-components/src/inline_list_editor.stories.tsx b/packages/ui-components/src/inline_list_editor.stories.tsx new file mode 100644 index 000000000..053c1fa73 --- /dev/null +++ b/packages/ui-components/src/inline_list_editor.stories.tsx @@ -0,0 +1,130 @@ +import { createSignal, splitProps } from "solid-js"; +import type { Meta, StoryObj } from "storybook-solidjs-vite"; + +import { InlineInput } from "./inline_input"; +import { InlineListEditor, type InlineListItemOptions } from "./inline_list_editor"; +import { rootFocus, useChildFocus } from "./util/focus"; + +const meta = { + title: "Forms & Inputs/InlineListEditor", + component: InlineListEditor, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Item input for a list of plain strings. */ +function StringItemInput( + allProps: { + item: string | null; + setItem: (item: string | null) => void; + } & InlineListItemOptions, +) { + const [props, inputOptions] = splitProps(allProps, ["item", "setItem", "onTextChange"]); + + const setText = (text: string) => { + props.onTextChange(text); + props.setItem(text === "" ? null : text); + }; + + return ( + + ); +} + +export const Summary: Story = { + render: () => { + const [items, setItems] = createSignal>(["alice", "bob"]); + const focus = useChildFocus<"list" | "outside">(rootFocus); + + return ( +
      + + {(item, setItem, options) => ( + + )} + +
      + ); + }, + tags: ["!autodocs", "!dev"], +}; + +export const Basic: Story = { + render: () => { + const [items, setItems] = createSignal>(["alice", "bob"]); + const focus = useChildFocus<"list" | "outside">(rootFocus); + + return ( +
      +

      + Press , to insert an item, Backspace/Delete in + an empty item to remove it, and arrow keys or Home/End to + navigate. Empty items are pruned when focus moves elsewhere. +

      + + {(item, setItem, options) => ( + + )} + +
      +

      Focus here to deactivate the list:

      + {}} + placeholder="Outside input" + focus={focus.childFocus("outside")} + /> +
      +

      + Items: {JSON.stringify(items().map((item) => item ?? "·"))} +

      +
      + ); + }, +}; + +export const CustomDelimiters: Story = { + render: () => { + const [items, setItems] = createSignal>(["x", "y", "z"]); + const focus = useChildFocus<"list" | "outside">(rootFocus); + + return ( +
      +

      + Tuple-style notation with parentheses, semicolon separators, and ; as + the insert key: +

      + "; "} + > + {(item, setItem, options) => ( + + )} + +
      + {}} + placeholder="Outside input" + focus={focus.childFocus("outside")} + /> +
      +
      + ); + }, +}; diff --git a/packages/ui-components/src/inline_list_editor.tsx b/packages/ui-components/src/inline_list_editor.tsx new file mode 100644 index 000000000..28683d906 --- /dev/null +++ b/packages/ui-components/src/inline_list_editor.tsx @@ -0,0 +1,234 @@ +import { + type Accessor, + batch, + createEffect, + Index, + type JSX, + mergeProps, + Show, + untrack, +} from "solid-js"; + +import type { TextInputOptions } from "./text_input"; +import { type FocusHandle, useChildFocus } from "./util/focus"; + +import styles from "./inline_list_editor.module.css"; + +/** Options passed to each item input rendered by an `InlineListEditor`. + +These options should be spread onto a `TextInput`-like input component +rendering the item. They wire the item input into the list's focus management +and keyboard navigation. + */ +export type InlineListItemOptions = Pick< + TextInputOptions, + | "focus" + | "deleteBackward" + | "deleteForward" + | "exitBackward" + | "exitForward" + | "exitLeft" + | "exitRight" + | "interceptKeyDown" +> & { + /** Called when the displayed text of the item input changes. + + Tracking the text allows the list editor to avoid pruning empty placeholder + items that have incomplete, user-entered text. + */ + onTextChange: (text: string) => void; +}; + +type InlineListEditorProps = TextInputOptions & { + /** Items in the list, where `null` is an empty placeholder item. */ + items: Array; + + /** Handler to set a new list of items. */ + setItems: (items: Array) => void; + + /** Renders the input for a single item in the list. + + The supplied options should be spread onto the item's input component. + */ + children: ( + item: Accessor, + setItem: (item: T | null) => void, + options: InlineListItemOptions, + index: number, + ) => JSX.Element; + + /** Key that inserts a new item after the current one. Defaults to `","`. */ + insertKey?: string; + + /** Element displayed before the first item. */ + startDelimiter?: JSX.Element | string; + + /** Element displayed after the last item. */ + endDelimiter?: JSX.Element | string; + + /** Element displayed between consecutive items. */ + separator?: (index: number) => JSX.Element | string; +}; + +/** Edits an inline list of items. + +Items are rendered horizontally, surrounded by delimiters and punctuated by +separators, like elements of a list or tuple in mathematical notation. The +rendering of each item is delegated to the `children` render prop. + +The component manages focus across the item inputs and provides editing +actions: inserting an item with the insert key, deleting items with focus +repair, navigating with arrow keys and `Home`/`End`, and pruning empty +placeholder items when focus is lost. + */ +export function InlineListEditor(originalProps: InlineListEditorProps) { + const props = mergeProps( + { + insertKey: ",", + startDelimiter:
      {"["}
      , + endDelimiter:
      {"]"}
      , + separator: () =>
      {","}
      , + }, + originalProps, + ); + + const parentFocus: FocusHandle = { + hasFocus: () => props.focus?.hasFocus() ?? !!props.isActive, + setFocused: (focused) => { + if (props.focus) { + props.focus.setFocused(focused); + } else if (focused) { + props.hasFocused?.(); + } + }, + }; + const focus = useChildFocus(parentFocus, { default: 0 }); + + // Track which indices have non-empty text (including incomplete input). + const inputTexts = new Map(); + + const updateItems = (f: (items: Array) => void) => { + const items = [...props.items]; + f(items); + props.setItems(items); + }; + + const insertNewItem = (i: number) => { + batch(() => { + updateItems((items) => { + items.splice(i, 0, null); + }); + focus.setActiveChild(i); + }); + }; + + // Insert a new item into an empty list when focus is gained. + createEffect(() => { + if (parentFocus.hasFocus() && untrack(() => props.items).length === 0) { + insertNewItem(0); + } + }); + + /** Clean up null placeholders that have no user-entered text. */ + const deactivate = () => { + const items = props.items.filter( + (item, i) => item !== null || (inputTexts.get(i) ?? "") !== "", + ); + if (items.length !== props.items.length) { + props.setItems(items); + } + }; + + // Clean up when the component becomes inactive. + createEffect(() => { + if (!parentFocus.hasFocus()) { + untrack(() => deactivate()); + } + }); + + const itemOptions = (i: number): InlineListItemOptions => ({ + onTextChange: (text) => inputTexts.set(i, text), + focus: focus.childFocus(i), + deleteBackward: () => + batch(() => { + updateItems((items) => { + items.splice(i, 1); + }); + if (i === 0) { + props.deleteBackward?.(); + } else { + focus.setActiveChild(i - 1); + } + }), + deleteForward: () => + batch(() => { + updateItems((items) => { + items.splice(i, 1); + }); + if (i === 0) { + props.deleteForward?.(); + } + }), + exitBackward: () => props.exitBackward?.(), + exitForward: () => props.exitForward?.(), + exitLeft: () => { + if (i === 0) { + props.exitLeft?.(); + } else { + focus.setActiveChild(i - 1); + } + }, + exitRight: () => { + if (i === props.items.length - 1) { + props.exitRight?.(); + } else { + focus.setActiveChild(i + 1); + } + }, + interceptKeyDown: (evt) => { + if (evt.key === props.insertKey) { + insertNewItem(i + 1); + return true; + } else if (evt.key === "Home" && !evt.shiftKey) { + // TODO: Should move to beginning of input. + focus.setActiveChild(0); + } else if (evt.key === "End" && !evt.shiftKey) { + focus.setActiveChild(props.items.length - 1); + } + return false; + }, + }); + + return ( +
        { + if (props.items.length === 0) { + insertNewItem(0); + parentFocus.setFocused(true); + evt.preventDefault(); + } + }} + > + {props.startDelimiter} + }> + {(item, i) => ( +
      • + 0 && props.separator}>{(sep) => sep()(i)} + {props.children( + item, + (newItem) => { + updateItems((items) => { + items[i] = newItem; + }); + }, + itemOptions(i), + i, + )} +
      • + )} +
        + {props.endDelimiter} +
      + ); +} diff --git a/packages/ui-components/src/panel.css b/packages/ui-components/src/panel.css index d166b0998..2ec4c8a7f 100644 --- a/packages/ui-components/src/panel.css +++ b/packages/ui-components/src/panel.css @@ -3,6 +3,7 @@ flex-direction: row; align-items: center; width: 100%; + gap: 4px; & .filler { flex: 1; diff --git a/packages/ui-components/src/text_input.tsx b/packages/ui-components/src/text_input.tsx index 0d70d21cf..c3aeaef2b 100644 --- a/packages/ui-components/src/text_input.tsx +++ b/packages/ui-components/src/text_input.tsx @@ -5,6 +5,7 @@ import { type ComponentProps, createEffect, createSignal, type JSX, splitProps } void focus; import { type Completion, Completions, type CompletionsRef } from "./completions"; +import type { FocusHandle } from "./util/focus"; import { assertTypelevel } from "./util/types"; /** Props for `TextInput` component. */ @@ -16,6 +17,9 @@ type TextInputProps = Omit, "onKeyDown"> & /** Optional props available to a `TextInput` component. */ export type TextInputOptions = TextInputActions & { + /** Focus state for this input. */ + focus?: FocusHandle; + /** Whether the input is active: allowed to the grab the focus. */ isActive?: boolean; @@ -103,6 +107,7 @@ type TextInputActions = { // XXX: Need the list of options as a *value* to split props. const TEXT_INPUT_OPTIONS = [ + "focus", "isActive", "hasFocused", "hasBlurred", @@ -143,7 +148,7 @@ export function TextInput(allProps: TextInputProps) { let ref!: HTMLInputElement; createEffect(() => { - if (options.isActive && document.activeElement !== ref) { + if ((options.focus?.hasFocus() ?? options.isActive) && document.activeElement !== ref) { ref.focus(); // Move cursor to end of input. ref.selectionStart = ref.selectionEnd = ref.value.length; @@ -156,7 +161,10 @@ export function TextInput(allProps: TextInputProps) { const onKeyDown: JSX.EventHandler = (evt) => { const remaining = completionsRef()?.remainingCompletions() ?? []; const value = evt.currentTarget.value; - if (options.interceptKeyDown?.(evt)) { + if (evt.key === "Escape" && isCompletionsOpen()) { + setCompletionsOpen(false); + evt.stopPropagation(); + } else if (options.interceptKeyDown?.(evt)) { } else if (options.deleteBackward && evt.key === "Backspace" && !value) { options.deleteBackward(); } else if (options.deleteForward && evt.key === "Delete" && !value) { @@ -224,6 +232,7 @@ export function TextInput(allProps: TextInputProps) { value={props.text} use:focus={(isFocused) => { if (isFocused) { + options.focus?.setFocused(true); options.hasFocused?.(); if ( options.completions != null && diff --git a/packages/ui-components/src/util/focus.ts b/packages/ui-components/src/util/focus.ts new file mode 100644 index 000000000..556a5c6c3 --- /dev/null +++ b/packages/ui-components/src/util/focus.ts @@ -0,0 +1,45 @@ +import { createEffect, createSignal, type Accessor } from "solid-js"; + +/** Focus state passed down a component tree. */ +export type FocusHandle = { + hasFocus: Accessor; + setFocused: (focused: boolean) => void; +}; + +/** Root focus handle for a tree that should always remember its last focus. */ +export const rootFocus: FocusHandle = { + hasFocus: () => true, + setFocused: () => {}, +}; + +/** Track which immediate child of a focused parent has focus. */ +export function useChildFocus( + parent: FocusHandle, + options?: { default?: K }, +): { + activeChild: Accessor; + setActiveChild: (child: K | null) => void; + childFocus: (child: K) => FocusHandle; +} { + const [activeChild, setActiveChild] = createSignal(options?.default ?? null); + + createEffect(() => { + if (!parent.hasFocus()) { + setActiveChild(() => options?.default ?? null); + } + }); + + const childFocus = (child: K): FocusHandle => ({ + hasFocus: () => parent.hasFocus() && activeChild() === child, + setFocused: (focused) => { + if (focused) { + setActiveChild(() => child); + parent.setFocused(true); + } else if (activeChild() === child) { + setActiveChild(() => options?.default ?? null); + } + }, + }); + + return { activeChild, setActiveChild, childFocus }; +} diff --git a/packages/ui-components/src/util/keyboard.ts b/packages/ui-components/src/util/keyboard.ts index 0f44b3807..23762a188 100644 --- a/packages/ui-components/src/util/keyboard.ts +++ b/packages/ui-components/src/util/keyboard.ts @@ -9,6 +9,23 @@ The types `ModifierKey` and `KbdKey` are borrowed from export type ModifierKey = "Alt" | "Control" | "Meta" | "Shift"; export type KbdKey = ModifierKey | (string & {}); +/** Platform-appropriate primary modifier key for editor shortcuts. + +Uses Meta (Cmd) on Mac and Control elsewhere, matching native app convention. + */ +export const primaryModifier: ModifierKey = navigator.userAgent.includes("Mac") + ? "Meta" + : "Control"; + +/** Platform-appropriate secondary modifier key for editor shortcuts. + +Uses Control on Mac, where Alt/Option remaps keys, and Alt elsewhere, where +Control tends to already be bound. + */ +export const secondaryModifier: ModifierKey = navigator.userAgent.includes("Mac") + ? "Control" + : "Alt"; + /** Returns whether the modifier key is active in the keyboard event. */ export function keyEventHasModifier(evt: KeyboardEvent, key: ModifierKey): boolean { switch (key) { @@ -24,3 +41,36 @@ export function keyEventHasModifier(evt: KeyboardEvent, key: ModifierKey): boole throw new Error(`Key is not a modifier: ${key}`); } } + +/** Format a modifier key for display to the user (e.g. "Cmd" on Mac, "Ctrl" elsewhere). */ +function formatModifierKey(key: ModifierKey, isMac: boolean): string { + switch (key) { + case "Meta": + return isMac ? "Cmd" : "Win"; + case "Control": + return isMac ? "⌃" : "Ctrl"; + case "Shift": + return "Shift"; + case "Alt": + return isMac ? "⌥" : "Alt"; + } +} + +/** Format a keyboard shortcut for display to the user. + * + * Takes an array of keys (modifiers followed by the main key) and returns + * a human-readable string like "Cmd+Z" or "Ctrl+Shift+Z". + */ +export function formatShortcut(keys: KbdKey[]): string { + if (keys.length === 0) { + return ""; + } + const isMac = navigator.userAgent.includes("Mac"); + const parts = keys.map((key) => { + if (key === "Meta" || key === "Control" || key === "Alt" || key === "Shift") { + return formatModifierKey(key as ModifierKey, isMac); + } + return key; + }); + return parts.join("+"); +} diff --git a/packages/ui-components/vite.config.ts b/packages/ui-components/vite.config.ts index 0c065616d..3aad71bd0 100644 --- a/packages/ui-components/vite.config.ts +++ b/packages/ui-components/vite.config.ts @@ -1,16 +1,16 @@ /// import path from "node:path"; import { fileURLToPath } from "node:url"; +import { monorepoDedupe } from "@catcolab-dev-tools/vite-plugin-monorepo-dedupe"; import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vite"; -const dirname = - typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url)); +const configDir = path.dirname(fileURLToPath(import.meta.url)); // https://vite.dev/config/ export default defineConfig({ - plugins: [], + plugins: [monorepoDedupe()], define: { "process.env": {}, }, @@ -22,7 +22,7 @@ export default defineConfig({ // The plugin will run tests for the stories defined in your Storybook config // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest storybookTest({ - configDir: path.join(dirname, ".storybook"), + configDir: path.join(configDir, ".storybook"), }), ], test: { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9942e7d55..f9a2a19e9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ minimumReleaseAge: 12960 minimumReleaseAgeExclude: - wasm-pack + - echarts packages: - "dev-docs" - "packages/backend/pkg" @@ -9,4 +10,4 @@ packages: - "packages/document-methods" - "packages/ui-components" - "packages/gaios" - + - "tools/vite-plugin-monorepo-dedupe" diff --git a/rfc/0001.md b/rfc/0001.md index 536413edd..d38d3be91 100644 --- a/rfc/0001.md +++ b/rfc/0001.md @@ -185,7 +185,7 @@ Consider the double theory freely generated by \begin{tikzcd} {\mathrm{List}(\mathrm{State})} & {\mathrm{State}} \\ 1 & 1 - \arrow[""{name=0, anchor=center, inner sep=0}, "{\mathrm{Term}}"{inner sep=.8ex}, "\shortmid"{marking}, from=1-1, to=1-2] + \arrow[""{name=0, anchor=center, inner sep=0}, "{\mathrm{Contrib}}"{inner sep=.8ex}, "\shortmid"{marking}, from=1-1, to=1-2] \arrow["{!}"', from=1-1, to=2-1] \arrow["{!}", from=1-2, to=2-2] \arrow[""{name=1, anchor=center, inner sep=0}, "{\mathrm{Param}}"'{inner sep=.8ex}, "\shortmid"{marking}, from=2-1, to=2-2] @@ -370,7 +370,6 @@ DOTS [@libkind-myers-2025]. ### Instances -\taxon{alternative} My original vision for "set-theoretic models" in CatColab is that they would exist one level lower in the system: as [instances](double-instances-2026) over diff --git a/rfc/0004.md b/rfc/0004.md index 61a8bac9f..03ee0e896 100644 --- a/rfc/0004.md +++ b/rfc/0004.md @@ -1205,7 +1205,7 @@ having a cell ```tikz % https://q.uiver.app/#q=WzAsNSxbMCwwLCJcXExpc3RcXExpc3RcXGNhbHtYfSJdLFsxLDAsIlxcTGlzdFxcY2Fse1h9Il0sWzIsMCwiXFxjYWx7WH0iXSxbMCwxLCJcXExpc3RcXGNhbHtYfSJdLFsyLDEsIlxcY2Fse1h9Il0sWzAsMSwiXFxMaXN0IFAiLDAseyJzdHlsZSI6eyJib2R5Ijp7Im5hbWUiOiJiYXJyZWQifX19XSxbMSwyLCJQIiwwLHsic3R5bGUiOnsiYm9keSI6eyJuYW1lIjoiYmFycmVkIn19fV0sWzIsNCwiIiwwLHsibGV2ZWwiOjIsInN0eWxlIjp7ImhlYWQiOnsibmFtZSI6Im5vbmUifX19XSxbMyw0LCJQIiwyLHsic3R5bGUiOnsiYm9keSI6eyJuYW1lIjoiYmFycmVkIn19fV0sWzAsMywiXFxtdV97XFxjYWx7WH19IiwyXSxbMSw4LCJcXHRleHR7Y29tcH0iLDEseyJsYWJlbF9wb3NpdGlvbiI6NDAsInNob3J0ZW4iOnsidGFyZ2V0IjoyMH0sInN0eWxlIjp7ImJvZHkiOnsibmFtZSI6Im5vbmUifSwiaGVhZCI6eyJuYW1lIjoibm9uZSJ9fX1dXQ== -\begin{tikzcd} +\[\begin{tikzcd} {\List\List\cal{X}} & {\List\cal{X}} & {\cal{X}} \\ {\List\cal{X}} && {\cal{X}} \arrow["{\List P}"{inner sep=.8ex}, "\shortmid"{marking}, from=1-1, to=1-2] @@ -1214,7 +1214,7 @@ having a cell \arrow[equals, from=1-3, to=2-3] \arrow[""{name=0, anchor=center, inner sep=0}, "P"'{inner sep=.8ex}, "\shortmid"{marking}, from=2-1, to=2-3] \arrow["{\text{comp}}"{description, pos=0.4}, draw=none, from=1-2, to=0] -\end{tikzcd} +\end{tikzcd}\] ``` giving the composition operation in the multicategory. Recall, however, that @@ -1225,7 +1225,7 @@ normalization axiom ```tikz % https://q.uiver.app/#q=WzAsNCxbMCwwLCJcXGNhbHtYfSJdLFsxLDAsIlxcY2Fse1h9Il0sWzAsMSwiXFxMaXN0XFxjYWx7WH0iXSxbMSwxLCJcXGNhbHtYfSJdLFswLDEsIlxcSG9tX3tcXGNhbHtYfX0iLDAseyJzdHlsZSI6eyJib2R5Ijp7Im5hbWUiOiJiYXJyZWQifX19XSxbMCwyLCJcXGV0YV97XFxjYWx7WH19IiwyXSxbMSwzLCIiLDAseyJsZXZlbCI6Miwic3R5bGUiOnsiaGVhZCI6eyJuYW1lIjoibm9uZSJ9fX1dLFsyLDMsIlAiLDIseyJzdHlsZSI6eyJib2R5Ijp7Im5hbWUiOiJiYXJyZWQifX19XSxbNCw3LCJcXHRleHR7cmVzfSIsMSx7InNob3J0ZW4iOnsic291cmNlIjoyMCwidGFyZ2V0IjoyMH0sInN0eWxlIjp7ImJvZHkiOnsibmFtZSI6Im5vbmUifSwiaGVhZCI6eyJuYW1lIjoibm9uZSJ9fX1dXQ== -\begin{tikzcd} +\[\begin{tikzcd} {\cal{X}} & {\cal{X}} \\ {\List\cal{X}} & {\cal{X}} \arrow[""{name=0, anchor=center, inner sep=0}, "{\Hom_{\cal{X}}}"{inner sep=.8ex}, "\shortmid"{marking}, from=1-1, to=1-2] @@ -1233,7 +1233,7 @@ normalization axiom \arrow[equals, from=1-2, to=2-2] \arrow[""{name=1, anchor=center, inner sep=0}, "P"'{inner sep=.8ex}, "\shortmid"{marking}, from=2-1, to=2-2] \arrow["{\text{res}}"{description}, draw=none, from=0, to=1] -\end{tikzcd} +\end{tikzcd}\] ``` says that the unary multimorphisms of $P$ are in natural bijection with the @@ -1557,13 +1557,11 @@ $\cal{X}$, a proarrow $M: \List_{\bij} \cal{X} \proto \List_{\bij} \cal{X}$, and a cell ```tikz -\newcommand{\bij}{\mathrm{bij}} - % https://q.uiver.app/#q=WzAsNCxbMCwwLCJcXExpc3Rfe1xcYmlqfV4yIFxcY2Fse1h9Il0sWzEsMCwiXFxMaXN0X3tcXGJpan1eMiBcXGNhbHtYfSJdLFswLDEsIlxcTGlzdF97XFxiaWp9IFxcY2Fse1h9Il0sWzEsMSwiXFxMaXN0X3tcXGJpan0gXFxjYWx7WH0iXSxbMCwyLCJcXG11X3tcXGNhbHtYfX0iLDJdLFswLDEsIlxcTGlzdF97XFxiaWp9IE0iLDAseyJzdHlsZSI6eyJib2R5Ijp7Im5hbWUiOiJiYXJyZWQifX19XSxbMSwzLCJcXG11X1giXSxbMiwzLCJNIiwyLHsic3R5bGUiOnsiYm9keSI6eyJuYW1lIjoiYmFycmVkIn19fV0sWzUsNywiXFxvdGltZXMiLDEseyJzaG9ydGVuIjp7InNvdXJjZSI6MjAsInRhcmdldCI6MjB9LCJzdHlsZSI6eyJib2R5Ijp7Im5hbWUiOiJub25lIn0sImhlYWQiOnsibmFtZSI6Im5vbmUifX19XV0= \[\begin{tikzcd}[column sep=large] - {\List_{\bij}^2 \cal{X}} & {\List_{\bij}^2 \cal{X}} \\ - {\List_{\bij} \cal{X}} & {\List_{\bij} \cal{X}} - \arrow[""{name=0, anchor=center, inner sep=0}, "{\List_{\bij} M}"{inner sep=.8ex}, "\shortmid"{marking}, from=1-1, to=1-2] + {\List_{\mathrm{bij}}^2 \cal{X}} & {\List_{\mathrm{bij}}^2 \cal{X}} \\ + {\List_{\mathrm{bij}} \cal{X}} & {\List_{\mathrm{bij}} \cal{X}} + \arrow[""{name=0, anchor=center, inner sep=0}, "{\List_{\mathrm{bij}} M}"{inner sep=.8ex}, "\shortmid"{marking}, from=1-1, to=1-2] \arrow["{\mu_{\cal{X}}}"', from=1-1, to=2-1] \arrow["{\mu_X}", from=1-2, to=2-2] \arrow[""{name=1, anchor=center, inner sep=0}, "M"'{inner sep=.8ex}, "\shortmid"{marking}, from=2-1, to=2-2] diff --git a/rfc/_macros.qmd b/rfc/_macros.qmd index ba611574c..517d710c8 100644 --- a/rfc/_macros.qmd +++ b/rfc/_macros.qmd @@ -1,4 +1,6 @@ +\renewcommand{\cal}[1]{\mathcal{#1}} \renewcommand{\vec}[1]{\underline{#1}} +\newcommand{\vecvec}[1]{\underline{\underline{#1}}} \newcommand{\Ob}{\operatorname{Ob}} diff --git a/rfc/filters.lua b/rfc/filters.lua index 11093eda1..6c8886b94 100644 --- a/rfc/filters.lua +++ b/rfc/filters.lua @@ -98,6 +98,9 @@ end local function handle_codeblock(el) local tikz_template = tikz_templates[el.classes[1]] if tikz_template ~= nil then + if FORMAT:match "latex" then + return pandoc.RawBlock("latex", el.text) + end local before = tikz_template[1]:gsub("@OPTIONAL_PREAMBLE", tikz_user_preamble) return pandoc.Div( memoize_svg(el.text, tikz2image(tikz_template), before .. tikz_template[2]), diff --git a/rfc/nonactionable/0005.md b/rfc/nonactionable/0005.md index 4ff12d204..491e7ea83 100644 --- a/rfc/nonactionable/0005.md +++ b/rfc/nonactionable/0005.md @@ -9,8 +9,11 @@ aliases: tikz-preamble: | \DeclareMathOperator{\Mnd}{\textsf{Mnd}} \DeclareMathOperator{\Cat}{\textsf{Cat}} - \DeclareMathOperator{\Set}{\textsf{Set}} + \renewcommand{\Set}{\operatorname{\textsf{Set}}} \DeclareMathOperator{\Maybe}{Maybe} + \DeclareMathOperator{\Psh}{Psh} + \DeclareMathOperator{\eel}{\mathbb{E}l} + \DeclareMathOperator{\Mor}{Mor} \newlength{\ProArLineHeight}\setlength{\ProArLineHeight}{1.25ex} @@ -49,18 +52,17 @@ tikz-preamble: | \draw ({#1}.center) ++(360-\pullbackangle:\pullbackradius) -- +(0,-\pullbackmarkerlength); \draw ({#1}.center) ++(270+\pullbackangle:\pullbackradius) -- +(\pullbackmarkerlength,0); } - --- {{< include ../_macros.qmd >}} -\DeclareMathOperator{\Mnd}{\textsf{Mnd}} -\DeclareMathOperator{\Cat}{\textsf{Cat}} -\DeclareMathOperator{\Set}{\textsf{Set}} -\DeclareMathOperator{\Maybe}{Maybe} -\DeclareMathOperator{\Psh}{Psh} -\DeclareMathOperator{\eel}{\mathbb{E}l} -\DeclareMathOperator{\Mor}{Mor} +\newcommand{\Mnd}{\operatorname{\textsf{Mnd}}} +\newcommand{\Cat}{\operatorname{\textsf{Cat}}} +\renewcommand{\Set}{\operatorname{\textsf{Set}}} +\newcommand{\Maybe}{\operatorname{Maybe}} +\newcommand{\Psh}{\operatorname{Psh}} +\newcommand{\eel}{\operatorname{\mathbb{E}l}} +\newcommand{\Mor}{\operatorname{Mor}} ::: {.callout-warning} ### RFC status diff --git a/tools/vite-plugin-monorepo-dedupe/package.json b/tools/vite-plugin-monorepo-dedupe/package.json new file mode 100644 index 000000000..2d0ce1824 --- /dev/null +++ b/tools/vite-plugin-monorepo-dedupe/package.json @@ -0,0 +1,23 @@ +{ + "name": "@catcolab-dev-tools/vite-plugin-monorepo-dedupe", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "format": "oxfmt .", + "lint": "oxlint --fix && oxfmt .", + "check": "tsc && pnpm run lint", + "ci": "tsc && oxlint --deny-warnings && oxfmt --check ." + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^6.0.3", + "vite": "^7.2.2" + } +} diff --git a/tools/vite-plugin-monorepo-dedupe/pnpm-lock.yaml b/tools/vite-plugin-monorepo-dedupe/pnpm-lock.yaml new file mode 100644 index 000000000..2a13776f5 --- /dev/null +++ b/tools/vite-plugin-monorepo-dedupe/pnpm-lock.yaml @@ -0,0 +1,680 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.12.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^7.2.2 + version: 7.3.3(@types/node@24.12.4) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@types/estree@1.0.8': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + vite@7.3.3(@types/node@24.12.4): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 diff --git a/tools/vite-plugin-monorepo-dedupe/src/index.ts b/tools/vite-plugin-monorepo-dedupe/src/index.ts new file mode 100644 index 000000000..bd80697ef --- /dev/null +++ b/tools/vite-plugin-monorepo-dedupe/src/index.ts @@ -0,0 +1,291 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import type { Plugin } from "vite"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const dependencyFields = ["dependencies", "devDependencies", "peerDependencies"] as const; + +type DependencyField = (typeof dependencyFields)[number]; + +type PackageJson = { + name?: string; +} & Partial>>; + +/** + * Dedupe dependencies from linked monorepo packages, recursively. + * + * This prevents linked packages from loading separate framework/runtime + * copies (e.g. Solid.js, see https://github.com/solidjs/solid/issues/1472). + * + * - The recursive crawl through `link:`/`file:`/`workspace:` edges is used only + * to *discover* which package names matter; the final dedupe list is + * intersected with the consumer package's own direct dependencies so that + * packages the consumer doesn't import are never forced to a single copy. + * - When the same dependency appears with different version ranges across the + * crawled packages, the plugin throws to fail the build. + */ +export function monorepoDedupe(): Plugin { + let intersectingDeps: string[] = []; + let isBuild = false; + return { + name: "@catcolab-dev-tools/vite-plugin-monorepo-dedupe", + // Use `config()` (not `configResolved()`) so the dedupe list is part of + // the user config Vite sees *before* it constructs its resolver. + config(_userConfig, env) { + // npm scripts invoke vite from the package directory, so cwd is the + // consumer package root. + const packageDir = process.cwd(); + intersectingDeps = getIntersectingDeps(packageDir); + isBuild = env.command === "build"; + return { + resolve: { + dedupe: intersectingDeps, + }, + }; + }, + generateBundle(_options, bundle) { + // Only enforce during `vite build`. Skip dev server and vitest, which + // have their own resolution graphs and may legitimately surface + // multiple install roots for test-only modules. + if (!isBuild) { + return; + } + const duplicates = findDuplicateInstallRoots(bundle, new Set(intersectingDeps)); + if (duplicates.length === 0) { + return; + } + const formatted = duplicates + .map( + ({ name, roots }) => + ` - ${name}:\n${roots.map((r) => ` ${r}`).join("\n")}`, + ) + .join("\n"); + this.error({ + message: + "[@catcolab-dev-tools/vite-plugin-monorepo-dedupe] dependencies bundled " + + `from multiple install roots:\n${formatted}\n` + + "This usually indicates that `resolve.dedupe` failed to collapse a linked " + + "workspace dependency into a single copy. See " + + "https://github.com/solidjs/solid/issues/1472 for one common cause.", + }); + }, + }; +} + +type DuplicateReport = { name: string; roots: string[] }; + +/** + * Inspect every emitted chunk's module ids and group them by the + * `node_modules/` install root. If any watched dep is sourced from more + * than one root, the build has produced duplicate copies of that package. + */ +function findDuplicateInstallRoots( + bundle: Record, + watched: Set, +): DuplicateReport[] { + const rootsByDep = new Map>(); + + // Matches the install root (group 1) and dep name (group 2) for a module id + // such as `/x/node_modules/.pnpm/solid-js@1.9.10/node_modules/solid-js/dist/solid.js`. + // Picks the *last* `node_modules/` segment so .pnpm-virtualised paths + // resolve to the per-version directory rather than the .pnpm root. + const installRootRegex = /^(.*\/node_modules\/(@[^/]+\/[^/]+|[^/]+))(?:\/|$)/; + + for (const chunk of Object.values(bundle)) { + if ( + chunk === null || + typeof chunk !== "object" || + !("modules" in chunk) || + chunk.modules === null || + typeof chunk.modules !== "object" + ) { + continue; + } + for (const id of Object.keys(chunk.modules as Record)) { + // Find the *last* node_modules/ segment. + const lastIdx = id.lastIndexOf("/node_modules/"); + if (lastIdx === -1) { + continue; + } + const tail = id.slice(lastIdx); + const match = tail.match(installRootRegex); + if (match === null) { + throw new Error( + "[@catcolab-dev-tools/vite-plugin-monorepo-dedupe] could not parse " + + `install root from module id: ${id}`, + ); + } + const installRootSuffix = match[1]; + const depName = match[2]; + if (installRootSuffix === undefined || depName === undefined) { + throw new Error( + "[@catcolab-dev-tools/vite-plugin-monorepo-dedupe] install-root regex " + + `matched without capture groups for module id: ${id}`, + ); + } + if (!watched.has(depName)) { + continue; + } + const installRoot = id.slice(0, lastIdx) + installRootSuffix; + let roots = rootsByDep.get(depName); + if (roots === undefined) { + roots = new Set(); + rootsByDep.set(depName, roots); + } + roots.add(installRoot); + } + } + + const duplicates: DuplicateReport[] = []; + for (const [name, roots] of rootsByDep) { + if (roots.size > 1) { + duplicates.push({ name, roots: Array.from(roots).toSorted() }); + } + } + duplicates.sort((a, b) => a.name.localeCompare(b.name)); + return duplicates; +} + +function getIntersectingDeps(packageDir: string): string[] { + const packageByName = getWorkspacePackages(); + const dedupeVersions = new Map>(); + const visitedPackages = new Set(); + + const consumerPackage = readPackageJson(resolve(packageDir, "package.json")); + const consumerDepNames = new Set(getPackageDependencies(consumerPackage).map(([name]) => name)); + + addLinkedPackageDependencies(packageDir); + + throwOnVersionConflicts(dedupeVersions); + + const crawledDepNames = new Set(dedupeVersions.keys()); + // @ts-expect-error: Set.prototype.intersection exists in our Node target + const dedupeNames: Set = crawledDepNames.intersection(consumerDepNames); + + return Array.from(dedupeNames).toSorted(); + + function addLinkedPackageDependencies(currentPackageDir: string) { + const currentPackage = readPackageJson(resolve(currentPackageDir, "package.json")); + + for (const [dependencyName, dependencyVersion] of getPackageDependencies(currentPackage)) { + const linkedPackageDir = getLinkedPackageDir( + currentPackageDir, + packageByName, + dependencyName, + dependencyVersion, + ); + + if (linkedPackageDir === undefined) { + continue; + } + + if (addPackageDependencyNames(linkedPackageDir)) { + addLinkedPackageDependencies(linkedPackageDir); + } + } + } + + function addPackageDependencyNames(linkedPackageDir: string): boolean { + const realPackageDir = resolve(linkedPackageDir); + const packageJsonPath = resolve(realPackageDir, "package.json"); + if (!existsSync(packageJsonPath)) { + return false; + } + + if (visitedPackages.has(realPackageDir)) { + return false; + } + visitedPackages.add(realPackageDir); + + for (const [dependencyName, dependencyVersion] of getPackageDependencies( + readPackageJson(packageJsonPath), + )) { + let versions = dedupeVersions.get(dependencyName); + if (versions === undefined) { + versions = new Set(); + dedupeVersions.set(dependencyName, versions); + } + versions.add(dependencyVersion); + } + + return true; + } +} + +function throwOnVersionConflicts(dedupeVersions: Map>): void { + const conflicts: string[] = []; + for (const [name, versions] of dedupeVersions) { + const realVersions = Array.from(versions).filter((v) => !isLinkSpecifier(v)); + if (realVersions.length > 1) { + conflicts.push(` - ${name}: ${realVersions.join(", ")}`); + } + } + if (conflicts.length > 0) { + throw new Error( + "[@catcolab-dev-tools/vite-plugin-monorepo-dedupe] conflicting version ranges across linked workspace " + + `packages:\n${conflicts.join("\n")}\n` + + "Align the versions before deduping.", + ); + } +} + +function isLinkSpecifier(version: string): boolean { + return ( + version.startsWith("link:") || + version.startsWith("file:") || + version.startsWith("workspace:") + ); +} + +function getWorkspacePackages(): Map { + const packages = new Map(); + + for (const workspaceRoot of ["packages", "tools"]) { + const workspaceDir = resolve(repoRoot, workspaceRoot); + if (!existsSync(workspaceDir)) { + continue; + } + + for (const packageName of readdirSync(workspaceDir)) { + const packageDir = resolve(workspaceDir, packageName); + const packageJsonPath = resolve(packageDir, "package.json"); + if (!existsSync(packageJsonPath)) { + continue; + } + + const packageJson = readPackageJson(packageJsonPath); + if (packageJson.name !== undefined) { + packages.set(packageJson.name, packageDir); + } + } + } + + return packages; +} + +function getPackageDependencies(packageJson: PackageJson): [string, string][] { + return dependencyFields.flatMap((field) => Object.entries(packageJson[field] ?? {})); +} + +function getLinkedPackageDir( + packageDir: string, + packageByName: Map, + dependencyName: string, + dependencyVersion: string, +): string | undefined { + if (dependencyVersion.startsWith("link:") || dependencyVersion.startsWith("file:")) { + return resolve(packageDir, dependencyVersion.replace(/^(link|file):/, "")); + } + + if (dependencyVersion.startsWith("workspace:")) { + return packageByName.get(dependencyName); + } + + return undefined; +} + +function readPackageJson(packageJsonPath: string): PackageJson { + return JSON.parse(readFileSync(packageJsonPath, "utf-8")); +} diff --git a/tools/vite-plugin-monorepo-dedupe/tsconfig.json b/tools/vite-plugin-monorepo-dedupe/tsconfig.json new file mode 100644 index 000000000..0f042fadf --- /dev/null +++ b/tools/vite-plugin-monorepo-dedupe/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "isolatedModules": true, + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} From 29a8b4e16ffe3c7d93852a3a6187aff3a7c8b181 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 26 Jun 2026 12:58:02 +0100 Subject: [PATCH 37/39] 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 bc45e2db6..a657f7bc5 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 & { @@ -433,12 +432,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 c0847f4e431f3dc2496785e1272aeedbfcf233c2 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 26 Jun 2026 15:58:23 +0100 Subject: [PATCH 38/39] fix bad merge --- .../stdlib/analyses/linear_ode_equations.tsx | 36 ------------------- .../analyses/lotka_volterra_equations.tsx | 36 ------------------- 2 files changed, 72 deletions(-) delete mode 100644 packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx delete mode 100644 packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx diff --git a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx deleted file mode 100644 index d0e65d2db..000000000 --- a/packages/frontend/src/stdlib/analyses/linear_ode_equations.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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"; - -import "./simulation.css"; - -/** Display the symbolic mass-action dynamics equations for a model. */ -export default function LinearODEEquationsDisplay( - props: ModelAnalysisProps & { - content: LinearODEEquationsData; - getEquations: LinearODEEquations; - 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_equations.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx deleted file mode 100644 index dcb6271d4..000000000 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra_equations.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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) => }, - ]} - /> -
      - ); -} From 65d9ac5a1fe28b0936a5b95b128d39a1c5dcff96 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 26 Jun 2026 18:53:22 +0100 Subject: [PATCH 39/39] FIX: Dynamically load analysis --- 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 a657f7bc5..d10fccd7f 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 => ({