From 9c6a5bee463ffac75a330ebdc6e56d9f66e1f39f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 7 May 2026 00:24:57 +0100 Subject: [PATCH 01/83] 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 WIP: Removing the pretend declarative migration; starting again WIP: Starting to meet in the middle [skip-ci] WIP: tests running (but failing, of course) WIP: Fixed Lotka-Volterra and LCC WIP: More documentation [skip-ci] WIP: mass-action for Petri nets [skip-ci] WIP: Fix all tests! [no-ci] ENH: Documentation WIP: LaTeX traits ENH: Documentation WIP: Failing tests (but in a good way, I promise) WIP: Passing catlog tests; failing catlog-wasm tests (expected behaviour) WIP: Thoughts [skip-ci] WIP: ToLatexWithMap (all tests passing!) ENH: Combined latex_ob_names and latex_mor_names Documentation WIP: More latex frontend tests WIP: Refactor catlog-wasm/src/analyses WIP: More tests for frontend ODE analyses Latex WIP: Failing tests (but, again, that's good and intended I promise) WIP: Simplify some of the repetition while we're here WIP: Deleting lots of (now) redundant code FIX: Move front-end ODE equation tests to analyses.rs ENH: Documentation WIP: Tests revealing error in latex_names() for objects that are lists FIX: Passing all Latex tests WIP: Fix frontend FIX: Fix (??) frontend WIP: Here's the problem FIX: Working reusable mass-action config form ENH: Add (failing) test for polynomial ODE; simplify some types WIP: Namespace problems FIX: Passing all Rust tests merge main FIX: Single ode_semantics_equations.tsx; fix reactivity in mass_action_config_form.tsx FIX: Errant bad rebase line FIX: Dynamically load ODESemanticsEquationsDisplay one question answered TODO WIP: What a mess --- CHANGELOG.md | 5 +- package.json | 3 +- packages/catlog-wasm/src/analyses.rs | 546 ++++++++++--- packages/catlog-wasm/src/latex.rs | 449 ++++++----- packages/catlog-wasm/src/model.rs | 32 +- packages/catlog-wasm/src/theories.rs | 132 +-- packages/catlog/src/latex.rs | 122 +++ packages/catlog/src/lib.rs | 1 + .../catlog/src/simulate/ode/polynomial.rs | 68 +- packages/catlog/src/stdlib/analyses/mod.rs | 1 + .../src/stdlib/analyses/ode/linear_ode.rs | 346 +++++--- .../src/stdlib/analyses/ode/lotka_volterra.rs | 383 ++++++--- .../src/stdlib/analyses/ode/mass_action.rs | 749 ++++++++++-------- .../catlog/src/stdlib/analyses/ode/mod.rs | 4 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 309 ++++++++ .../src/stdlib/analyses/ode/polynomial_ode.rs | 276 +++++-- .../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 + packages/catlog/src/zero/alg.rs | 30 +- packages/catlog/src/zero/qualified.rs | 7 + packages/catlog/src/zero/rig.rs | 26 +- .../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 | 28 +- .../src/stdlib/analyses/lotka_volterra.tsx | 28 +- .../src/stdlib/analyses/mass_action.tsx | 10 +- .../analyses/mass_action_config_form.tsx | 56 +- ...ations.tsx => ode_semantics_equations.tsx} | 13 +- .../src/stdlib/analyses/simulator_types.ts | 26 +- .../src/stdlib/theories/causal-loop.ts | 10 + .../src/stdlib/theories/polynomial-ode.ts | 4 +- .../theories/primitive-signed-stock-flow.ts | 16 - .../frontend/src/stdlib/theories/reg-net.ts | 12 +- .../stdlib/theories/signed-polynomial-ode.ts | 4 +- .../src/inline_list_editor.module.css | 9 + 41 files changed, 2721 insertions(+), 1325 deletions(-) create mode 100644 packages/catlog/src/latex.rs 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 rename packages/frontend/src/stdlib/analyses/{polynomial_ode_equations.tsx => ode_semantics_equations.tsx} (70%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b83334d79..052745e765 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/package.json b/package.json index cd55175081..1fcc13797b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build:deps": "pnpm --filter ./packages/frontend run build:deps", - "dev": "pnpm --filter ./packages/frontend run dev" + "dev": "pnpm --filter ./packages/frontend run dev", + "check": "cargo +nightly fmt && cargo clippy && pnpm --filter ./packages/frontend run check" }, "engines": { "node": "^24.4.0" diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 57fee9811d..c38fdf168e 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -1,13 +1,14 @@ //! Auxiliary structs and glue code for data passed to/from analyses. +use catlog::latex::LatexEquations; use serde::{Deserialize, Serialize}; use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; -use catlog::stdlib::analyses::ode; +use catlog::stdlib::analyses::ode::{self, ODESemantics, Parameter}; use catlog::zero::QualifiedName; -use super::latex::{LatexEquations, latex_mor_names, latex_mor_names_mass_action, latex_ob_names}; +use super::latex::latex_names; use super::model::DblModel; use super::result::JsResult; @@ -27,119 +28,464 @@ 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( - model: &DblModel, -) -> Result, i8>, String> { - let realised_model = model.modal_nonunital()?; - let analysis = ode::PolynomialODEAnalysis::default(); - Ok(analysis.build_system(realised_model)) -} - -/// 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? - .map_variables(latex_ob_names(model)) - .extend_scalars(|param| param.map_variables(latex_mor_names(model))) - .to_latex_equations(); - Ok(LatexEquations(equations)) -} - -/// Simulates mass-action ODEs. -pub(crate) fn polynomial_ode_simulation( +/// Simulate specific ODE semantics on a model, for use in a simulation analysis. +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_ob_names(model)).to_latex_equations(); - let analysis = ode::polynomial_ode_analysis(sys_extended_scalars, data); + 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: LatexEquations(latex_equations), + latex_equations, }) } -/// 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. -pub enum MassActionAnalysisLogic { - /// The modal theory of Petri nets. - PetriNet, - /// The discrete tabulator theory of stock-flow diagrams. - StockFlow, +/// 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>, +) -> Result { + Ok(system.to_latex_equations_with_map(|param| latex_names(model)(param))) } -/// Generates the PolynomialSystem for mass-action dynamics. -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::default(); - Ok(analysis.build_system(realised_model, mass_conservation_type)) +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + 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, ModeApp}; + use catlog::dbl::model::{ModalDblModel, MutDblModel}; + use catlog::latex::{Latex, LatexEquation, LatexEquations}; + use catlog::stdlib::{ + analyses::ode::{self, MassConservationType, ODESemanticsAnalysis}, + theories, + }; + use catlog::zero::{LabelSegment, Namespace, QualifiedName}; + use std::rc::Rc; + use uuid::Uuid; + + #[test] + fn signed_polynomial_ode_latex_equations() { + // 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", "yum", "z", "", "", "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 \\text{yum} \\cdot z" + .to_string(), + ), + }, + LatexEquation { + 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} z".to_string()), + rhs: Latex( + "\\lambda_{[x, \\text{yum}] \\to z} \\cdot x \\cdot \\text{yum}".to_string(), + ), + }, + ]); + + assert_eq!(equations, expected); + } + + #[test] + fn cld_lotka_volterra_latex_equations() { + let model = parallel_negative_cld("x", "yellow", "f", ""); + let system = ode::LotkaVolterraAnalysis::default().build_system(model.discrete().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( + "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 = ode::LinearODEAnalysis::default().build_system(model.discrete().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("xylophone", "y", "fff"); + let system = ode::StockFlowMassActionAnalysis { + mass_conservation_type: MassConservationType::Balanced, + ..ode::StockFlowMassActionAnalysis::default() } - MassActionAnalysisLogic::StockFlow => { - let realised_model = model.discrete_tab()?; - let analysis = ode::StockFlowMassActionAnalysis::default(); - Ok(analysis.build_system(realised_model, mass_conservation_type)) + .build_system(model.discrete_tab().unwrap()); + let equations = + ode_semantics_equations::(&model, system).unwrap(); + + let expected = LatexEquations(vec![ + LatexEquation { + 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} y".to_string()), + rhs: Latex("r_{\\text{fff}} \\cdot \\text{xylophone} \\cdot y".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + #[test] + fn stock_flow_unbalanced_mass_action_latex_equations() { + let model = backward_link("xylophone", "y", "fff"); + 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 { + 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} y".to_string()), + rhs: Latex("\\rho_{\\text{fff}} \\cdot \\text{xylophone} \\cdot y".to_string()), + }, + ]); + assert_eq!(equations, expected); } -} -/// 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, -} + #[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]`. + let model = catalytic_petri_net("liquid", "solid", "c", ""); + 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(); -/// 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? - .map_variables(latex_ob_names(model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(model))) - .to_latex_equations(); - Ok(LatexEquations(equations)) -} + 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]} \\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]} \\cdot \\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); + } -/// 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 = ode::extend_mass_action_scalars(sys?, &data); - 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 solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); - Ok(ODEResultWithEquations { - solution: ODEResult(solution.into()), - latex_equations: LatexEquations(latex_equations), - }) + #[test] + 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", "transition"); + 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 { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{liquid}".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{transition}} \\cdot \\text{liquid} \\cdot c".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), + rhs: Latex("(\\rho_{\\text{transition}} - \\kappa_{\\text{transition}}) \\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 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(); + + 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 \\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}} \\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]}^{c} - \\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{c}) \\cdot \\text{liquid} \\cdot c".to_string()), + }, + ]); + assert_eq!(equations, expected); + } + + /// Construct a causal loop diagram with objects x, y and negative links f, g : x -> y. + pub(crate) fn parallel_negative_cld( + source_name: &str, + target_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: source_name.into(), + id: x, + ob_type: ObType::Basic("Object".into()) + }) + .is_ok() + ); + assert!( + model + .add_ob(&ObDecl { + name: target_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 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`. + pub(crate) 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.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.into(), + ModalOb::List(List::Symmetric, vec![ModalOb::Generator(z.into())]), + ModalOb::Generator(y.into()), + pos_mor_type.clone(), + ); + inner.add_mor( + n.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())); + + 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, + } + } + + /// Construct a Petri net representing a catalytic transition [x,c] -> [y,c]. + pub(crate) fn catalytic_petri_net( + source_name: &str, + target_name: &str, + catalyst_name: &str, + transition_name: &str, + ) -> DblModel { + let th = Rc::new(theories::th_sym_monoidal_category()); + let ob_type = ModalObType::new(QualifiedName::from("Object")); + let op = QualifiedName::from("tensor"); + + 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()), + ); + + 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())); + + 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, + } + } } diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index 3478a64776..b9c462bb85 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -1,242 +1,293 @@ //! 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 catlog::{ + latex::{list_object_as_latex, wrap_with_backslash_text}, + 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 { +/// 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}}}") + if let Some(ob_label) = model.ob_namespace.label(id) { + wrap_with_backslash_text(ob_label.to_string()) + } else if let Some(mor_label) = model.mor_namespace.label(id) { + wrap_with_backslash_text(mor_label.to_string()) } else { - name + let (dom, cod) = model + .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) + ) } } } -/// 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 { - // 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. - let morphism_subscript = |morphism: &QualifiedName| -> String { - if let Some(label) = model.mor_namespace.label(morphism) { - format!("\\text{{{label}}}") - } else { - let (dom, cod) = model - .mor_generator_dom_cod_label_strings(morphism) - .expect("Morphism in equation system should have domain and codomain"); - format!("\\text{{{dom}}} \\to \\text{{{cod}}}") - } +#[cfg(test)] +mod tests { + use catlog::latex::{Latex, LatexEquation, LatexEquations}; + use catlog::stdlib::analyses::ode; + use catlog::stdlib::analyses::ode::{ + LinearODEAnalysis, LotkaVolterraAnalysis, PetriNetMassActionAnalysis, + StockFlowMassActionAnalysis, ode_semantics::*, }; - move |id: &QualifiedName| { - let sub = morphism_subscript(id); - format!("\\lambda_{{{sub}}}") + use super::*; + use crate::analyses::tests::{catalytic_petri_net, parallel_negative_cld}; + use crate::model::tests::backward_link; + + #[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); } -} -/// 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::FlowParameter) -> 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}}}") + #[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)); - move |id: &ode::FlowParameter| match id { - ode::FlowParameter::Balanced { 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::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}}}}}") - } - }, + 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); } -} -#[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::stdlib::{analyses::ode, theories}; - use catlog::zero::{LabelSegment, Namespace, QualifiedName}; - use std::rc::Rc; - use uuid::Uuid; + #[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", ""); - use super::*; - use crate::model::{DblModel, tests::backward_link}; + 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 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 equations = sys - .map_variables(latex_ob_names(&model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) - .to_latex_equations(); + 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 = LinearODEAnalysis::default() + .build_system(discrete_model) + .to_latex_equations_with_map(|param| latex_names(&model)(param)); - 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} x".to_string()), + rhs: Latex("0".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{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] - 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 equations = sys - .map_variables(latex_ob_names(&model)) - .extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model))) - .to_latex_equations(); - - let expected = vec![ - LatexEquation { - lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{xxx}".to_string(), - rhs: - "-\\kappa_{\\text{xxx} \\to \\text{yyy}} \\cdot \\text{xxx} \\cdot \\text{yyy}" + #[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: "\\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} c".to_string()), + rhs: Latex("0".to_string()), }, - ]; + ]); assert_eq!(equations, expected); } + // TODO: REMOVE THIS #[ignore] #[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(), + #[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, ), - ModalOb::App( - ModalOb::List( - List::Symmetric, - vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())], - ) - .into(), - op.clone(), + ..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, ), - 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, - elaboration_errors: Vec::new(), - 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())) - ); + ..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); } } diff --git a/packages/catlog-wasm/src/model.rs b/packages/catlog-wasm/src/model.rs index 57ddd50898..225539c5a2 100644 --- a/packages/catlog-wasm/src/model.rs +++ b/packages/catlog-wasm/src/model.rs @@ -433,46 +433,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, } @@ -881,10 +877,12 @@ pub(crate) mod tests { assert_eq!(Result::from(model.validate().0).map_err(|errs| errs.len()), Err(2)); } + /// 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 { diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index e4cb2e7479..a83b11cdb5 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -7,14 +7,16 @@ 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::ode::{ + LinearODESemantics, LotkaVolterraSemantics, ODESemanticsAnalysis, +}; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::name; -use super::latex::LatexEquations; use super::model_morphism::{MotifOccurrence, MotifsOptions, motifs}; use super::result::JsResult; -use super::theories::MassActionAnalysisLogic; use super::{analyses::*, model::DblModel, theory::DblTheory}; /// The empty or initial theory. @@ -149,17 +151,19 @@ impl ThSignedCategory { pub fn lotka_volterra( &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(), - )) + data: analyses::ode::ODESemanticsProblemData, + ) -> Result { + 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 = + analyses::ode::LotkaVolterraAnalysis::default().build_system(model.discrete()?); + ode_semantics_equations::(model, system) } /// Simulate the linear ODE system derived from a model. @@ -167,17 +171,17 @@ 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::ODESemanticsProblemData, + ) -> Result { + 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 = analyses::ode::LinearODEAnalysis::default().build_system(model.discrete()?); + ode_semantics_equations::(model, system) } } @@ -341,7 +345,12 @@ impl ThCategoryLinks { model: &DblModel, data: analyses::ode::MassActionProblemData, ) -> Result { - mass_action_simulation(model, data, MassActionAnalysisLogic::StockFlow) + 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. @@ -349,9 +358,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 = analyses::ode::StockFlowMassActionAnalysis { + mass_conservation_type: data.mass_conservation_type, + ..analyses::ode::StockFlowMassActionAnalysis::default() + } + .build_system(model.discrete_tab()?); + ode_semantics_equations::(model, system) } } @@ -370,26 +384,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. @@ -415,7 +409,12 @@ impl ThSymMonoidalCategory { model: &DblModel, data: analyses::ode::MassActionProblemData, ) -> Result { - mass_action_simulation(model, data, MassActionAnalysisLogic::PetriNet) + 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. @@ -423,9 +422,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 = 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. @@ -477,17 +481,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, - data: PolynomialODEEquationsData, - ) -> Result { - polynomial_ode_equations(model, data) + pub fn polynomial_ode_equations(&self, model: &DblModel) -> Result { + let system = + analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); + ode_semantics_equations::(model, system) } } @@ -514,17 +518,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, - data: PolynomialODEEquationsData, - ) -> Result { - polynomial_ode_equations(model, data) + pub fn polynomial_ode_equations(&self, model: &DblModel) -> Result { + let system = + analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); + ode_semantics_equations::(model, system) } } diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs new file mode 100644 index 0000000000..3e316cc3f8 --- /dev/null +++ b/packages/catlog/src/latex.rs @@ -0,0 +1,122 @@ +//! 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 duplicate::duplicate_item; +use std::fmt; + +#[cfg(feature = "serde")] +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))] +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 equation in Latex format with a left-hand side and a right-hand side. +#[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, + /// The right-hand side of the equation. + 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(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. + fn to_latex(&self) -> Latex; +} + +/// 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 { + /// 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; +} + +/// 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, +{ + fn to_latex(&self) -> Latex { + let name = |id: &QualifiedName| id.to_string(); + self.to_latex_with_map(name) + } +} + +/// 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 { + 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/lib.rs b/packages/catlog/src/lib.rs index a7fcbd387f..da82a958aa 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 5e162ffed6..281e7a2fa7 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -9,14 +9,11 @@ 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, ToLatexWithMap}; +use crate::zero::QualifiedName; use crate::zero::{alg::Polynomial, rig::DisplayCoef}; /// A system of polynomial differential equations. @@ -94,33 +91,48 @@ where PolynomialSystem { components } } - /// Converts to equations as LaTeX strings. - pub fn to_latex_equations(&self) -> Vec + /// 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 + ToLatexWithMap, + Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, + Exp: Display + ToLatex + PartialEq + One, { - self.components - .iter() - .map(|(var, poly)| LatexEquation { - lhs: format!("\\frac{{\\mathrm{{d}}}}{{\\mathrm{{d}}t}} {var}"), - rhs: poly.to_latex(), - }) - .collect() + let name = |id: &QualifiedName| id.to_string(); + self.to_latex_equations_with_map(name) } -} -#[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, + // 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 + /// 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, + Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, + Exp: Display + ToLatex + PartialEq + One, + { + LatexEquations( + self.components + .iter() + .map(|(var, poly)| LatexEquation { + 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(), + ) + } } impl PolynomialSystem diff --git a/packages/catlog/src/stdlib/analyses/mod.rs b/packages/catlog/src/stdlib/analyses/mod.rs index 861f3ef8c1..2fffc0f45e 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 83f4a6c22f..11a28f1432 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs @@ -1,117 +1,169 @@ -//! Constant-coefficient linear first-order ODE analysis of models. +//! Linear constant-coefficient 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 +//! `LinearODESemantics`. +//! +//! [`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; - -#[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 std::fmt; + +use super::Parameter; +use crate::dbl::model::{FpDblModel, MutDblModel}; +use crate::latex::{Latex, ToLatexWithMap}; +use crate::one::Path; +use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::ode_semantics::{ + ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, + ODESemanticsScalarExtension, PolynomialODESystemBuilder, }; +use crate::zero::name; +use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; -/// Data defining a linear 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 LinearODEProblemData { - /// Map from morphism IDs to interaction coefficients (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "coefficients"))] - coefficients: HashMap, +/// Implementing LinearODE as an ODE semantics for models of type `DiscreteDblModel`. +pub struct LinearODESemantics; - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] - initial_values: HashMap, +impl ODESemantics for LinearODESemantics { + type ModelType = DiscreteDblModel; + type ParameterType = LinearODEParameter; + type AnalysisType = LinearODEAnalysis; + type EquationsDataType = (); + type ParameterData = LinearODEParameterData; +} - /// Duration of simulation. - duration: f32, +/// Parameters in the linear equations correspond only to morphisms. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum LinearODEParameter { + /// The parameter associated to a morphism. + Parameter { + /// The morphism. + morphism: QualifiedName, + }, } -/// 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 fmt::Display for LinearODEParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parameter { morphism } => { + write!(f, "Parameter({})", morphism) + } + } + } } -impl SignedCoefficientBuilder { - /// Linear ODE analysis for a model of a double theory. +impl ToLatexWithMap for LinearODEParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { + match self { + Self::Parameter { morphism } => Latex(format!("\\lambda_{{{}}}", f(morphism))), + } + } +} + +impl ODEParameterType for LinearODEParameter {} + +/// Linear ODE analysis for causal loop diagrams (CLDs). +pub struct LinearODEAnalysis { + /// 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 LinearODEAnalysis { + 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 LinearODEAnalysis +{ + /// Creates a linear system with symbolic rate coefficients. /// - /// 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( + /// A system of ODEs for building arbitrary LinearODE ODEs from CLDs. + fn build_system_builder( &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) + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); + + for var in model.ob_generators_with_type(&self.var_ob_type) { + // For each object, we create a variable. + builder.add_variable(var.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; + }; + + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} += Parameter_f x + builder.add_contribution( + mor.clone(), + cod.clone(), + ContributionSign::Positive, + LinearODEParameter::Parameter { morphism: mor }, + [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; + }; + + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} -= Parameter_f x + builder.add_contribution( + mor.clone(), + cod.clone(), + ContributionSign::Negative, + LinearODEParameter::Parameter { morphism: mor }, + [dom.clone()], + ); + } + + builder } +} - /// Linear ODE system for a model of a double theory. - pub fn linear_ode_system( +/// TODO: docs +pub struct LinearODEParameterData { + /// Map from morphism IDs to interaction coefficients (nonnegative reals). + coefficients: HashMap, +} + +impl ODESemanticsScalarExtension<::ParameterType> + for LinearODEParameterData +{ + 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, i8>, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|param| match param { + LinearODEParameter::Parameter { morphism } => { + self.coefficients.get(morphism).cloned().unwrap_or_default() + } + }) + }); + + sys.normalize() } } @@ -121,43 +173,93 @@ mod test { use std::rc::Rc; use super::*; - use crate::stdlib; - use crate::{one::Path, zero::name}; + use crate::{ + dbl::model::MutDblModel, + latex::{LatexEquation, LatexEquations, wrap_with_backslash_text}, + stdlib::{analyses::ode::ODESemanticsProblemData, 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(th_signed_category()); + let model = negative_feedback(th); + let sys = LinearODEAnalysis::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 = LinearODEAnalysis::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()); } + // Numerical tests. #[test] - fn negative_feedback_numerical() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); + fn predator_prey_numerical() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); - let data = LinearODEProblemData { - coefficients: [(name("positive"), 2.0), (name("negative"), 1.0)].into_iter().collect(), + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_data: (), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, + parameter_data: LinearODEParameterData { + coefficients: [(name("positive"), 3.0), (name("negative"), 2.0)] + .into_iter() + .collect(), + }, }; - 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 = LinearODEAnalysis::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()); + } + + // LaTeX tests. + #[test] + fn to_latex() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + 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_{\\text{negative}} \\cdot y".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("\\lambda_{\\text{positive}} \\cdot x".to_string()), + }, + ]); + assert_eq!(expected, equations); } } diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs index 9a1853f16a..5387ef1079 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs @@ -1,29 +1,177 @@ //! 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 crate::{ - dbl::model::DiscreteDblModel, - one::QualifiedPath, - zero::{QualifiedName, alg::Polynomial, rig::Monomial}, +use super::Parameter; +use crate::dbl::model::{FpDblModel, MutDblModel}; +use crate::latex::{Latex, ToLatexWithMap}; +use crate::one::Path; +use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::ODESemanticsScalarExtension; +use crate::stdlib::analyses::ode::ode_semantics::{ + ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, + PolynomialODESystemBuilder, }; +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`. +pub struct LotkaVolterraSemantics; + +impl ODESemantics for LotkaVolterraSemantics { + type ModelType = DiscreteDblModel; + type ParameterType = LotkaVolterraParameter; + type AnalysisType = LotkaVolterraAnalysis; + type EquationsDataType = (); + type ParameterData = LotkaVolterraParameterData; +} + +/// 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 ToLatexWithMap for LotkaVolterraParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { + match self { + Self::Growth { variable } => Latex(format!("g_{{{}}}", f(variable))), + Self::Interaction { link } => Latex(format!("k_{{{}}}", f(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_system_builder( + &self, + model: &DiscreteDblModel, + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); + + for var in model.ob_generators_with_type(&self.var_ob_type) { + // For each object, we create a variable. + builder.add_variable(var.clone()); + + // The object + // x + // becomes the contribution + // \dot{x} += Growth_x \cdot x + builder.add_contribution( + var.clone(), + var.clone(), + ContributionSign::Positive, + LotkaVolterraParameter::Growth { variable: var.clone() }, + [var], + ); + } + + 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; + }; + + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} += Interaction_f \cdot xy + builder.add_contribution( + mor.clone(), + cod.clone(), + ContributionSign::Positive, + LotkaVolterraParameter::Interaction { link: mor }, + [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; + }; + + // The morphism + // f: x -> y + // becomes the contribution + // \dot{y} -= Interaction_f \cdot xy + builder.add_contribution( + mor.clone(), + cod.clone(), + ContributionSign::Negative, + LotkaVolterraParameter::Interaction { link: mor }, + [dom.clone(), cod.clone()], + ); + } + + builder + } +} /// Data defining a Lotka-Volterra ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -32,7 +180,7 @@ use crate::{ feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] -pub struct LotkaVolterraProblemData { +pub struct LotkaVolterraParameterData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "interactionCoefficients"))] interaction_coeffs: HashMap, @@ -40,103 +188,27 @@ pub struct LotkaVolterraProblemData { /// Map from object IDs to growth rates (arbitrary real numbers). #[cfg_attr(feature = "serde", serde(rename = "growthRates"))] growth_rates: HashMap, - - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] - initial_values: HashMap, - - /// Duration of simulation. - 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 ODESemanticsScalarExtension<::ParameterType> + for LotkaVolterraParameterData { - 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() -} - -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( + fn extend_scalars( &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() - }) + sys: PolynomialSystem, 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() + } }) - .to_numerical(); - let problem = ODEProblem::new(system, x0).end_time(data.duration); - ODEAnalysis::new(problem, ob_index) - } + }); - /// Lotka-Volterra ODE system for an model of a double theory. - pub fn lotka_volterra_system( - &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.normalize() } } @@ -146,47 +218,94 @@ mod test { use std::rc::Rc; use super::*; - use crate::stdlib; - use crate::{one::Path, zero::name}; + use crate::{ + dbl::model::MutDblModel, + latex::{LatexEquation, LatexEquations, wrap_with_backslash_text}, + stdlib::{analyses::ode::ODESemanticsProblemData, 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(th_signed_category()); + let model = negative_feedback(th); + let sys = LotkaVolterraAnalysis::default().build_system(&model); + let expected = expect!([r#" + dx = Growth(x) x - Interaction(negative) x y + dy = Interaction(positive) x y + Growth(y) y + "#]); + expected.assert_eq(&sys.to_string()); } #[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})"))); + 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#" - dx = Param(x) x - Param(negative) x y - dy = Param(positive) x y + Param(y) y + 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()); } + // Numerical tests. #[test] fn predator_prey_numerical() { - let th = Rc::new(stdlib::theories::th_signed_category()); - let neg_feedback = stdlib::models::negative_feedback(th); - - let data = LotkaVolterraProblemData { - interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] - .into_iter() - .collect(), - growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_data: (), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, + parameter_data: LotkaVolterraParameterData { + interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] + .into_iter() + .collect(), + growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), + }, }; - 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()); + } + + // LaTeX tests. + #[test] + fn to_latex() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + 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_{\\text{negative}} \\cdot x \\cdot y".to_string()), + }, + LatexEquation { + lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} y".to_string()), + rhs: Latex("k_{\\text{positive}} \\cdot x \\cdot y + g_{y} \\cdot y".to_string()), + }, + ]); + assert_eq!(expected, equations); } } diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs index 9daac606b4..b34c7dbfb2 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mass_action.rs @@ -7,24 +7,46 @@ 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 crate::dbl::{ - model::{DiscreteTabModel, FpDblModel, ModalDblModel, TabEdge}, - theory::{ModalMorType, ModalObType, TabMorType, TabObType, Unital}, -}; -use crate::one::FgCategory; -use crate::simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}; +use super::Parameter; +use crate::latex::{Latex, ToLatexWithMap}; +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::{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; +/// Mass-action semantics for stock-flow diagrams. +pub struct StockFlowMassActionSemantics; + +impl ODESemantics for PetriNetMassActionSemantics { + type ModelType = ModalDblModel; + type ParameterType = MassActionParameter; + type AnalysisType = PetriNetMassActionAnalysis; + type EquationsDataType = MassActionEquationsData; + type ParameterData = MassActionParameterData; +} + +impl ODESemantics for StockFlowMassActionSemantics { + type ModelType = DiscreteTabModel; + type ParameterType = MassActionParameter; + type AnalysisType = StockFlowMassActionAnalysis; + type EquationsDataType = MassActionEquationsData; + type ParameterData = MassActionParameterData; +} /// There are three types of mass-action semantics, each more expressive than the previous: /// - balanced @@ -49,22 +71,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. + /// Each flow (transition) gets assigned a single consumption and single production rate. PerTransition, - /// Each transition gets assigned a consumption rate for each input place and - /// a production rate for each output place. + /// Each flow (transition) gets assigned a consumption rate for each input stock (place) and + /// a production rate for each output stock (place). PerPlace, } -/// 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 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 +101,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. + /// For per flow rates, we simply need to know the associated flow. PerTransition { - /// The transition to which we associate the rate parameter. - transition: QualifiedName, + /// 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. + /// For per stock rates, we need to know both the transition and the corresponding + /// input/output stock. 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, + /// 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 +129,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::PerTransition { flow: trans }, } => { write!(f, "Incoming({})", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::IncomingFlow, - parameter: RateParameter::PerPlace { transition: trans, place: output }, + parameter: RateParameter::PerPlace { flow: trans, stock: output }, } => { write!(f, "([{}]->{})", trans, output) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { transition: trans }, + parameter: RateParameter::PerTransition { flow: trans }, } => { write!(f, "Outgoing({})", trans) } - FlowParameter::Unbalanced { + Self::Unbalanced { direction: Direction::OutgoingFlow, - parameter: RateParameter::PerPlace { transition: trans, place: input }, + parameter: RateParameter::PerPlace { flow: trans, stock: input }, } => { write!(f, "({}->[{}])", input, trans) } @@ -140,52 +163,32 @@ 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 ToLatexWithMap for MassActionParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { + match self { + MassActionParameter::Balanced { flow } => Latex(format!("r_{{{}}}", f(flow))), + MassActionParameter::Unbalanced { direction, parameter } => { + match (direction, parameter) { + (Direction::IncomingFlow, RateParameter::PerTransition { flow }) => { + Latex(format!("\\rho_{{{}}}", f(flow))) + } + (Direction::OutgoingFlow, RateParameter::PerTransition { flow }) => { + Latex(format!("\\kappa_{{{}}}", f(flow))) + } + (Direction::IncomingFlow, RateParameter::PerPlace { flow, stock }) => { + Latex(format!("\\rho_{{{}}}^{{{}}}", f(flow), f(stock))) + } + (Direction::OutgoingFlow, RateParameter::PerPlace { flow, stock }) => { + Latex(format!("\\kappa_{{{}}}^{{{}}}", f(flow), f(stock))) + } + } + } + } + } } +impl ODEParameterType for MassActionParameter {} + /// Mass-action ODE analysis for Petri nets. /// /// This struct implements the object part of the functorial semantics for reaction @@ -195,6 +198,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 +208,117 @@ 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_system_builder( &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(); + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); - match 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()); - } + for place in model.ob_generators_with_type(&self.place_ob_type) { + // For each place, we create a variable. + builder.add_variable(place.clone()); + } - for output in outputs { - sys.add_term(output.unwrap_generator(), term.clone()); + for transition in model.mor_generators_with_type(&self.transition_mor_type) { + 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()); + // 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::PerTransition => MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerTransition { flow: transition.clone() }, + }, + RateGranularity::PerPlace => MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerPlace { + flow: transition.clone(), + stock: output.clone(), + }, + }, + }, + }; + + builder.add_contribution( + id, + output, + ContributionSign::Positive, + parameter, + inputs.clone(), + ); + } - 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(), - }, - }), - term.clone(), - )], - } - .into_iter() - .collect(); - - sys.add_term(input.unwrap_generator(), -input_term.clone()); + 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] + // 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() } } - 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()); - } - } + MassConservationType::Unbalanced(granularity) => match granularity { + RateGranularity::PerTransition => MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerTransition { flow: transition.clone() }, + }, + RateGranularity::PerPlace => MassActionParameter::Unbalanced { + direction: Direction::OutgoingFlow, + parameter: RateParameter::PerPlace { + flow: transition.clone(), + stock: input.clone(), + }, + }, + }, + }; + + builder.add_contribution( + id, + input, + ContributionSign::Negative, + parameter, + inputs.clone(), + ); } } - sys.normalize() + builder } } @@ -314,156 +332,212 @@ 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_system_builder( &self, model: &DiscreteTabModel, - mass_conservation_type: MassConservationType, - ) -> PolynomialSystem, i8> { - let terms: Vec<_> = self.flow_monomials(model).into_iter().collect(); + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); - let mut sys = PolynomialSystem::new(); - for ob in model.ob_generators_with_type(&self.stock_ob_type) { - sys.add_term(ob, Polynomial::zero()); + for stock in model.ob_generators_with_type(&self.stock_ob_type) { + // For each stock, we create a variable. + builder.add_variable(stock.clone()); } - 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 { + + for flow in model.mor_generators_with_type(&self.flow_mor_type) { + let interface = flow_interface(model, &flow); + let (input, output) = (interface.input_stock, interface.output_stock); + + // 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(); + + // The flow + // F : a -> b + // with links + // l_i : x_i -> F + // becomes the contributions + // \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::PerTransition => 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 { 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); + MassActionParameter::Balanced { flow: flow.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); + MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { + direction: Direction::IncomingFlow, + parameter: RateParameter::PerTransition { 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::PerTransition { flow: flow.clone() }, + }, + }; + builder.add_contribution( + input_id, + input.clone(), + ContributionSign::Negative, + input_parameter, + monomial, + ); } - 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(); + builder + } +} - 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"); - }; - }; +/// 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))] +#[derive(Clone)] +pub struct MassActionEquationsData { + /// Whether or not mass is conserved. + #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] + pub mass_conservation_type: MassConservationType, +} - for link in model.mor_generators_with_type(&self.pos_link_mor_type) { - multiply_for_link(link, 1); - } - for link in model.mor_generators_with_type(&self.neg_link_mor_type) { - multiply_for_link(link, -1); +impl Default for MassActionEquationsData { + fn default() -> Self { + Self { + mass_conservation_type: MassConservationType::Balanced, } - - 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(), - }, - }) - }); +impl ODESemanticsEquationsData for MassActionEquationsData {} - sys.normalize() -} +/// Data input by the user to fill in the parameters numerically. +#[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 MassActionParameterData { + /// 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, -/// 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(); + /// 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>, - 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); + /// 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>, +} - let num_sys = sys.to_numerical(); - let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); +impl ODESemanticsScalarExtension for MassActionParameterData { + 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::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 }, + ) => self + .place_production_rates + .get(transition) + .and_then(|rate| rate.get(place)) + .copied() + .unwrap_or_default(), + ( + Direction::OutgoingFlow, + RateParameter::PerPlace { 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)] @@ -472,18 +546,20 @@ 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. + // Symbolic tests. + // 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, 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 +571,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( + let sys = StockFlowMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( analyses::ode::RateGranularity::PerTransition, ), - ); + ..StockFlowMassActionAnalysis::default() + } + .build_system(&model); let expected = expect!([r#" dx = -Outgoing(f) x y dy = Incoming(f) x y @@ -508,48 +585,13 @@ mod tests { 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. - - #[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()); - } - - // Tests for Petri nets. These all use the catalyzed_reaction() model, which + // 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, 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 +604,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( + let sys = PetriNetMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( analyses::ode::RateGranularity::PerTransition, ), - ); + ..PetriNetMassActionAnalysis::default() + } + .build_system(&model); let expected = expect!([r#" dx = -Outgoing(f) c x dy = Incoming(f) c x @@ -580,12 +623,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( + let sys = PetriNetMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( analyses::ode::RateGranularity::PerPlace, ), - ); + ..PetriNetMassActionAnalysis::default() + } + .build_system(&model); let expected = expect!([r#" dx = -(x->[f]) c x dy = ([f]->y) c x @@ -594,28 +638,31 @@ mod tests { expected.assert_eq(&sys.to_string()); } - // Test for LaTeX. + // Numerical tests. + // TODO: write some numerical tests. + // LaTeX tests. #[test] 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( + let sys = StockFlowMassActionAnalysis { + mass_conservation_type: analyses::ode::MassConservationType::Unbalanced( analyses::ode::RateGranularity::PerTransition, ), - ); - let expected = vec![ + ..StockFlowMassActionAnalysis::default() + } + .build_system(&model); + 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/mod.rs b/packages/catlog/src/stdlib/analyses/ode/mod.rs index 4c9b2a8622..c5ce791d20 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 0000000000..1a03c0801d --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -0,0 +1,309 @@ +//! 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 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 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; +use nalgebra::DVector; +use std::collections::HashMap; +use std::fmt; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + +use crate::{ + dbl::{ + modal::{List, ModeApp}, + model::{ + DblModel, DiscreteDblModel, DiscreteTabModel, ModalDblModel, ModalOb, MutDblModel, + }, + theory::{NonUnital, Unital}, + }, + latex::{Latex, ToLatexWithMap}, + 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. 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` for more details. + type AnalysisType: ODESemanticsAnalysis; + /// The data necessary for displaying the system of equations, to be provided at run-time by the + /// front-end. + type EquationsDataType: ODESemanticsEquationsData; + /// 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. + type ParameterData: ODESemanticsScalarExtension; +} + +/// The models for which we support ODE semantics need to be sufficiently nice, though +/// these bounds are not particularly restrictive. +pub trait DblModelForODESemantics: + FgCategory + DblModel + 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. 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 + ToLatexWithMap {} + +/// The simplest type for parameters is `QualifiedName`. +impl ToLatexWithMap for QualifiedName { + fn to_latex_with_map String>(&self, f: T) -> Latex { + Latex(f(self)) + } +} + +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. 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 { + 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() + } + + /// 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 + } + + /// 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 + } + + /// 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 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 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 { + /// 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

; + + /// 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(), + ) + } +} + +/// 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 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 a 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, +} + +/// 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 () {} + +/// How to convert the formal parameters of type `ODEParameterType` into floats using data from +/// `ODESemanticsProblemData.parameter_data`. +pub trait ODESemanticsScalarExtension { + /// TODO: documentation. + fn extend_scalars( + &self, + system: PolynomialSystem, i8>, + ) -> PolynomialSystem; +} + +/// The struct 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. 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. +#[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 ODESemanticsProblemData +where + T: ODESemantics, +{ + /// Further data needed to specify the ODE equations. + #[cfg_attr(feature = "serde", serde(rename = "equationsData"))] + pub equations_data: T::EquationsDataType, + /// 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, + /// Data needed to fill in parameters. + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: T::ParameterData, +} + +impl ODESemanticsProblemData { + /// TODO: docs + pub fn extend_scalars( + &self, + system: PolynomialSystem, i8>, + ) -> PolynomialSystem { + self.parameter_data.extend_scalars(system) + } + + /// Converting the polynomial system into a system ready for use in numerical solvers. The default + /// implementation here should essentially always be the desired one. + pub 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/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs index 0b9d49f9f7..62160ba92e 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs @@ -1,8 +1,18 @@ //! 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; use num_traits::Zero; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -15,31 +25,52 @@ use crate::{ model::{FpDblModel, ModalDblModel, ModalOb, MutDblModel}, theory::NonUnital, }, - simulate::ode::{NumericalPolynomialSystem, ODEProblem, PolynomialSystem}, + latex::{Latex, ToLatexWithMap}, + simulate::ode::PolynomialSystem, + stdlib::analyses::ode::{ + ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsScalarExtension, + Parameter, PolynomialODESystemBuilder, + }, zero::{QualifiedName, alg::Polynomial, name, rig::Monomial}, }; -use super::{ODEAnalysis, Parameter}; +/// Implementing Lotka-Volterra as an ODE semantics for models of type `DiscreteDblModel`. +pub struct PolynomialODESemantics; -/// 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, +impl ODESemantics for PolynomialODESemantics { + type ModelType = ModalDblModel; + type ParameterType = PolynomialODEParameter; + type AnalysisType = PolynomialODEAnalysis; + type EquationsDataType = (); + type ParameterData = PolynomialODEParameterData; +} + +/// 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, + }, +} - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] - pub initial_values: HashMap, +impl fmt::Display for PolynomialODEParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self::Coefficient { contribution } = &self; + write!(f, "Coefficient({})", contribution) + } +} - /// Duration of simulation. - pub duration: f32, +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 @@ -63,12 +94,67 @@ impl Default for PolynomialODEAnalysis { } } +// 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, + ::ParameterType, + > for PolynomialODEAnalysis +{ + fn build_system_builder( + &self, + model: &ModalDblModel, + ) -> PolynomialODESystemBuilder { + PolynomialODESystemBuilder::identity(model.clone()) + } +} + impl PolynomialODEAnalysis { - /// Creates a system with symbolic coefficients. + /// 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. + 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(), + PolynomialODEParameter::Coefficient { contribution: mor.clone() }, + ); + } + for mor in model.mor_generators_with_type(&self.negative_contribution_mor_type) { + associated_parameters.insert( + mor.clone(), + PolynomialODEParameter::Coefficient { contribution: 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 +162,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 +197,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) { @@ -109,36 +208,39 @@ 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()) - }); - - sys.normalize() +/// 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 PolynomialODEParameterData { + /// Map from morphism IDs to coefficients (nonnegative reals). + pub coefficients: HashMap, } -/// 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(); - - 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 num_sys = sys.to_numerical(); - let problem = ODEProblem::new(num_sys, x0).end_time(data.duration); +impl ODESemanticsScalarExtension<::ParameterType> + for PolynomialODEParameterData +{ + fn extend_scalars( + &self, + sys: PolynomialSystem< + QualifiedName, + Parameter<::ParameterType>, + i8, + >, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|mor| match mor { + PolynomialODEParameter::Coefficient { contribution } => { + self.coefficients.get(contribution).cloned().unwrap_or_default() + } + }) + }); - ODEAnalysis::new(problem, ob_index) + sys.normalize() + } } #[cfg(test)] @@ -148,50 +250,29 @@ mod tests { use super::*; use crate::{ - simulate::ode::LatexEquation, + latex::{Latex, LatexEquation, LatexEquations, wrap_with_backslash_text}, stdlib::{models::*, theories::*}, tt, }; - // (Unsigned) Lotka–Volterra dynamics on a two-level model. + // Symbolic tests. #[test] - fn lotka_volterra_equations() { + fn polynomial_ode_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 - 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()); } - // (Unsigned) 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 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(), - }, - 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(), - }, - ]; - assert_eq!(expected, sys.to_latex_equations()); - } + // Numerical tests. + // TODO: write some numerical tests. - // DoubleTT elaboration from text. + // DoubleTT elaboration test. #[test] fn ode_system_from_text() { let th = Rc::new(th_polynomial_ode_system()); @@ -209,10 +290,31 @@ 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()); } + + // LaTeX tests. + #[test] + fn polynomial_ode_lotka_volterra_equations_latex() { + let th = Rc::new(th_signed_polynomial_ode_system()); + let model = signed_lotka_volterra_dynamics(th); + 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("\\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("\\lambda_{\\text{AB_interaction}} \\cdot A \\cdot B + \\lambda_{\\text{B_growth}} \\cdot B".to_string()), + }, + ]); + assert_eq!(expected, equations); + } } 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 ce106e9092..0000000000 --- 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 ec28171cae..f00fecaa8e 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 402b5dac56..8a7c910280 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 b800f6ad08..dffdcd4a71 100644 --- a/packages/catlog/src/stdlib/analyses/stochastic/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/stochastic/mass_action.rs @@ -8,7 +8,10 @@ use std::collections::HashMap; use crate::{ dbl::{modal::*, model::FpDblModel, theory::Unital}, - stdlib::analyses::{ode::ODESolution, petri::transition_interface}, + stdlib::analyses::{ + ode::ODESolution, + petri::{TransitionInterface, transition_interface}, + }, zero::{QualifiedName, name}, }; @@ -114,20 +117,16 @@ impl PetriNetStochasticMassActionAnalysis { }; for mor in model.mor_generators_with_type(&self.transition_mor_type) { - let (inputs, outputs) = transition_interface(model, &mor); + let transition_interface: TransitionInterface = transition_interface(model, &mor); + let inputs = transition_interface.input_places.clone(); + let outputs = transition_interface.output_places.clone(); // 1. convert the inputs/outputs to sequences of counts let input_vec = ob_generators.iter().map(|id| { - inputs - .iter() - .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id)) - .count() as u32 + inputs.iter().filter(|&ob| matches!(ob, id2 if id2 == id)).count() as u32 }); let output_vec = ob_generators.iter().map(|id| { - outputs - .iter() - .filter(|&ob| matches!(ob, ModalOb::Generator(id2) if id2 == id)) - .count() as isize + outputs.iter().filter(|&ob| matches!(ob, id2 if id2 == id)).count() as isize }); // 2. output := output - input diff --git a/packages/catlog/src/stdlib/analyses/stock_flow.rs b/packages/catlog/src/stdlib/analyses/stock_flow.rs new file mode 100644 index 0000000000..2cb83ee55d --- /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 7a784733e3..3d97910b26 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -175,14 +175,17 @@ pub fn sir_petri(th: Rc>) -> ModalDblModel { model } -/// An example of Lotka–Volterra dynamics viewed as a non-unital theory for a symmetric multicategory. -pub fn lotka_volterra_dynamics(th: Rc>) -> ModalDblModel { +/// An example of (unsigned) Lotka-Volterra dynamics viewed as a non-unital theory for +/// a symmetric multicategory. +pub fn unsigned_lotka_volterra_dynamics( + th: Rc>, +) -> ModalDblModel { let ob_type = ModalObType::new(name("State")); let mor_type: ModalMorType = ModeApp::new(name("Contribution")).into(); let mut model = ModalDblModel::new(th); - // We're going to build a two-level predator-prey model, but where (in absence of signed - // arrows) all interactions have positive coefficients. + // A two-level predator-prey model, but where (in absence of signed arrows) all + // interactions have positive coefficients. let (a, b, c) = (name("A"), name("B"), name("C")); model.add_ob(a.clone(), ob_type.clone()); @@ -243,6 +246,54 @@ pub fn lotka_volterra_dynamics(th: Rc>) -> ModalDblMod model } +/// An example of Lotka-Volterra dynamics viewed as a non-unital theory for a symmetric multicategory. +pub fn signed_lotka_volterra_dynamics( + th: Rc>, +) -> ModalDblModel { + let ob_type = ModalObType::new(name("State")); + let pos_mor_type: ModalMorType = ModeApp::new(name("Contribution")).into(); + let neg_mor_type: ModalMorType = ModeApp::new(name("NegativeContribution")).into(); + + let mut model = ModalDblModel::new(th); + // We're going to build a simple predator-prey model. + let (a, b) = (name("A"), name("B")); + + model.add_ob(a.clone(), ob_type.clone()); + model.add_ob(b.clone(), ob_type.clone()); + // The growth terms, corresponding to + // dA/dt += g_A A + // dB/dt += g_B B + model.add_mor( + name("A_growth"), + ModalOb::List(List::Symmetric, vec![a.clone().into()]), + a.clone().into(), + pos_mor_type.clone(), + ); + model.add_mor( + name("B_growth"), + ModalOb::List(List::Symmetric, vec![b.clone().into()]), + b.clone().into(), + pos_mor_type.clone(), + ); + // The interaction terms, corresponding to + // dB/dt += k_AB AB + // dA/dt -= k_BA AB + model.add_mor( + name("AB_interaction"), + ModalOb::List(List::Symmetric, vec![a.clone().into(), b.clone().into()]), + b.clone().into(), + pos_mor_type.clone(), + ); + model.add_mor( + name("BA_interaction"), + ModalOb::List(List::Symmetric, vec![a.clone().into(), b.clone().into()]), + a.clone().into(), + neg_mor_type.clone(), + ); + + model +} + #[cfg(test)] mod tests { use super::super::theories::*; @@ -296,6 +347,6 @@ mod tests { #[test] fn polynomial_ode_systems() { let th = Rc::new(th_polynomial_ode_system()); - assert!(lotka_volterra_dynamics(th.clone()).validate().is_ok()); + assert!(unsigned_lotka_volterra_dynamics(th.clone()).validate().is_ok()); } } diff --git a/packages/catlog/src/stdlib/theories.rs b/packages/catlog/src/stdlib/theories.rs index a0a6e32753..0c1a13070c 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/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 6899222f95..b172576299 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -9,6 +9,9 @@ use std::ops::{Add, AddAssign, Mul, Neg}; use derivative::Derivative; +use crate::latex::{Latex, ToLatex, ToLatexWithMap}; +use crate::zero::QualifiedName; + use super::rig::*; /// A commutative algebra over a commutative ring. @@ -147,30 +150,31 @@ where } } -impl Polynomial +impl ToLatexWithMap for Polynomial where - Var: Display, - Coef: Display + DisplayCoef + Clone + PartialEq + One + Neg, - Exp: Display + PartialEq + One, + 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`]. - pub fn to_latex(&self) -> String { + /// 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 monomial = monomial.to_latex(); + 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 + monomial_latex } else if *coef == Coef::one().neg() { - format!("-{monomial}") + format!("-{monomial_latex}") } else if coef.needs_parentheses() { - format!("({coef}) \\cdot {monomial}") + format!("({coef_latex}) \\cdot {monomial_latex}") } else { - format!("{coef} \\cdot {monomial}") + format!("{coef_latex} \\cdot {monomial_latex}") } }; 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 +186,7 @@ where output.push_str(&fmt_term(coef, monomial)); } } - output + Latex(output) } } diff --git a/packages/catlog/src/zero/qualified.rs b/packages/catlog/src/zero/qualified.rs index 908f76d23a..60f13c8bf9 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(); diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index 800330b348..0a77012caa 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -22,6 +22,9 @@ use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use derivative::Derivative; use duplicate::duplicate_item; +use crate::latex::{Latex, ToLatex, ToLatexWithMap}; +use crate::zero::QualifiedName; + /// A commutative monoid, written additively. pub trait AdditiveMonoid: Add + Zero {} @@ -586,35 +589,36 @@ where } } -impl Monomial +impl ToLatexWithMap for Monomial where - Var: Display, - Exp: Display + PartialEq + One, + Var: Display + ToLatexWithMap, + Exp: Display + ToLatex + PartialEq + One, { /// Convert to a LaTeX string, separating variables with `\cdot`. - pub fn to_latex(&self) -> String { + 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(|variable| f(variable)); if exp.is_one() { - format!("{var}") + var_latex.to_string() } 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}}}") } } }; 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) } } @@ -743,7 +747,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"); @@ -752,6 +756,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())); } } diff --git a/packages/frontend/src/help/analysis/mass-action.mdx b/packages/frontend/src/help/analysis/mass-action.mdx index 68286d712f..29c2980889 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 5c652ca8f2..3ac06d8acc 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 636daee0bd..d997c0a2c8 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -4,7 +4,6 @@ import type { MassActionEquationsData, MorType, ObType, - PolynomialODEEquationsData, StochasticMassActionProblemData, } from "catlog-wasm"; import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; @@ -22,6 +21,8 @@ type AnalysisOptions = { help?: string; }; +const ODESemanticsEquationsDisplay = lazy(() => import("./analyses/ode_semantics_equations")); + export const decapodes = ( options: AnalysisOptions, ): DiagramAnalysisMeta => ({ @@ -133,6 +134,30 @@ export function linearODE( const LinearODE = lazy(() => import("./analyses/linear_ode")); +export function linearODEEquations( + options: Partial & { + getEquations: Simulators.LinearODEEquations; + }, +): 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: () => null, + }; +} + export function lotkaVolterra( options: Partial & { simulate: Simulators.LotkaVolterraSimulator; @@ -140,8 +165,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 +187,30 @@ 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: () => null, + }; +} + export function massAction( options: Partial & { ratesHaveGranularity: boolean; @@ -184,7 +233,7 @@ export function massAction( help, component: (props) => , initialContent: () => ({ - massConservationType: { type: "Balanced" }, + equationsData: { massConservationType: { type: "Balanced" } }, rates: {}, transitionProductionRates: {}, transitionConsumptionRates: {}, @@ -372,7 +421,7 @@ export function polynomialODEEquations( options: Partial & { getEquations: Simulators.PolynomialODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "polynomial-ode-equations", name = "Polynomial ODE equations", @@ -386,14 +435,11 @@ export function polynomialODEEquations( description, help, component: (props) => ( - + ), - initialContent: () => ({ - trivialData: true, - }), + initialContent: () => null, }; } -const PolynomialODEEquationsDisplay = lazy(() => import("./analyses/polynomial_ode_equations")); export function polynomialODESimulation( options: Partial & { diff --git a/packages/frontend/src/stdlib/analyses/linear_ode.tsx b/packages/frontend/src/stdlib/analyses/linear_ode.tsx index 946a156cab..94c42538bb 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode.tsx @@ -4,12 +4,14 @@ import { createNumericalColumn, FixedTableEditor, Foldable, + ExpandableTable, + KatexDisplay, } from "catcolab-ui-components"; -import type { DblModel, LinearODEProblemData, 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 { createModelODEPlot } from "./model_ode_plot"; +import { createModelODEPlotWithEquations } from "./model_ode_plot"; import type { LinearODESimulator } from "./simulator_types"; import "./simulation.css"; @@ -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/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 9d6006800b..062f281897 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/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 6cfa1fe434..107a5f4bb9 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( 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 b16365db00..ef8feb2c0a 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,11 @@ import { CheckboxField, FormGroup, SelectField } from "catcolab-ui-components"; import type { MassActionEquationsData, MassActionProblemData, RateGranularity } from "catlog-wasm"; /** Configuration of a mass-action analysis. */ -export type Config = MassActionProblemData | 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,11 +16,21 @@ export function MassActionConfigForm(props: { changeConfig: (f: (config: Config) => void) => void; enableGranularity: boolean; }) { - const massConservation = () => props.config.massConservationType; - const massConservationGranularity = () => - props.config.massConservationType.type === "Unbalanced" - ? props.config.massConservationType.granularity + function massActionEquationsData(): MassActionEquationsData { + if (isMassActionProblemData(props.config)) { + return props.config.equationsData; + } else { + return props.config; + } + } + + const massConservation = () => massActionEquationsData().massConservationType; + const massConservationGranularity = () => { + const massConversarvation = massActionEquationsData().massConservationType; + return massConversarvation.type === "Unbalanced" + ? massConversarvation.granularity : undefined; + }; return ( @@ -25,14 +39,20 @@ export function MassActionConfigForm(props: { checked={massConservation().type === "Balanced"} onChange={(evt) => { props.changeConfig((content) => { + let massActionEquationsData: MassActionEquationsData; + if (isMassActionProblemData(content)) { + massActionEquationsData = content.equationsData; + } else { + massActionEquationsData = content; + } if (evt.currentTarget.checked) { - content.massConservationType = { + massActionEquationsData.massConservationType = { type: "Balanced", }; } else { - content.massConservationType = { + massActionEquationsData.massConservationType = { type: "Unbalanced", - granularity: "PerTransition", + granularity: "PerPlace", }; } }); @@ -41,18 +61,26 @@ export function MassActionConfigForm(props: { { props.changeConfig((content) => { - if (content.massConservationType.type === "Unbalanced") { - content.massConservationType.granularity = evt.currentTarget - .value as RateGranularity; + let massActionEquationsData: MassActionEquationsData; + if (isMassActionProblemData(content)) { + massActionEquationsData = content.equationsData; + } else { + massActionEquationsData = content; + } + if ( + massActionEquationsData.massConservationType.type === "Unbalanced" + ) { + massActionEquationsData.massConservationType.granularity = evt + .currentTarget.value as RateGranularity; } }); }} > - - + + diff --git a/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx similarity index 70% rename from packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx rename to packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx index 33486ff9ef..774a37d857 100644 --- a/packages/frontend/src/stdlib/analyses/polynomial_ode_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx @@ -1,22 +1,21 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import { PolynomialODEEquationsData } from "catlog-wasm"; +import { DblModel, LatexEquations } from "catlog-wasm"; 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: PolynomialODEEquationsData; - getEquations: PolynomialODEEquations; +export default function ODESemanticsEquationsDisplay( + props: ModelAnalysisProps & { + content: null; + getEquations: (model: DblModel) => LatexEquations; 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 e5ac07d98b..cdd3c0f357 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -8,7 +8,6 @@ import type { MassActionProblemData, ODEResult, ODEResultWithEquations, - PolynomialODEEquationsData, PolynomialODEProblemData, StochasticMassActionProblemData, } from "catlog-wasm"; @@ -22,28 +21,33 @@ export type { }; 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 LinearODESimulator = ( + model: DblModel, + data: LinearODEProblemData, +) => ODEResultWithEquations; +export type LinearODEEquations = (model: DblModel) => LatexEquations; +export type LotkaVolterraSimulator = ( + model: DblModel, + data: LotkaVolterraProblemData, +) => ODEResultWithEquations; +export type LotkaVolterraEquations = (model: DblModel) => 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, ) => 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/causal-loop.ts b/packages/frontend/src/stdlib/theories/causal-loop.ts index 174d401080..8d81ad97b3 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/polynomial-ode.ts b/packages/frontend/src/stdlib/theories/polynomial-ode.ts index be3e3b03b8..30b7b09954 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/primitive-signed-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts index b99784d6fd..a9774c5cbf 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 ff6751faf7..29f627c9c4 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); }, }), ], diff --git a/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts b/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts index 63fb669885..45c02d8103 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({ diff --git a/packages/ui-components/src/inline_list_editor.module.css b/packages/ui-components/src/inline_list_editor.module.css index 0d28ad3481..fae302910d 100644 --- a/packages/ui-components/src/inline_list_editor.module.css +++ b/packages/ui-components/src/inline_list_editor.module.css @@ -19,3 +19,12 @@ .defaultDelimiter { transform: scale(1, 1.5); } + +.emptyListInput { + background: transparent; + border: none; + outline: none; + width: 0.5ex; + margin: 0; + padding: 0; +} From 4b5b46c5ac30901c589d1471ab8a7da65d17dc32 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 20 Aug 2026 14:20:13 +0100 Subject: [PATCH 02/83] WIP: v0 to v1 migration for analyses --- packages/catlog-wasm/src/analyses.rs | 68 ++-- packages/catlog-wasm/src/latex.rs | 181 +++++----- packages/catlog-wasm/src/theories.rs | 43 +-- .../catlog/src/stdlib/analyses/ode/mod.rs | 14 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 78 +++-- .../src/stdlib/analyses/ode/v0/linear_ode.rs | 30 ++ .../stdlib/analyses/ode/v0/lotka_volterra.rs | 34 ++ .../src/stdlib/analyses/ode/v0/mass_action.rs | 67 ++++ .../catlog/src/stdlib/analyses/ode/v0/mod.rs | 6 + .../stdlib/analyses/ode/v0/polynomial_ode.rs | 29 ++ .../analyses/ode/{ => v1}/linear_ode.rs | 27 +- .../analyses/ode/{ => v1}/lotka_volterra.rs | 17 +- .../analyses/ode/{ => v1}/mass_action.rs | 323 +++++++++++++----- .../src/stdlib/analyses/ode/v1/migrate.rs | 199 +++++++++++ .../catlog/src/stdlib/analyses/ode/v1/mod.rs | 7 + .../analyses/ode/{ => v1}/polynomial_ode.rs | 46 ++- packages/catlog/src/stdlib/models.rs | 4 +- .../src/stdlib/analyses/mass_action.tsx | 1 + 18 files changed, 879 insertions(+), 295 deletions(-) create mode 100644 packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs create mode 100644 packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs create mode 100644 packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs create mode 100644 packages/catlog/src/stdlib/analyses/ode/v0/mod.rs create mode 100644 packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs rename packages/catlog/src/stdlib/analyses/ode/{ => v1}/linear_ode.rs (92%) rename packages/catlog/src/stdlib/analyses/ode/{ => v1}/lotka_volterra.rs (96%) rename packages/catlog/src/stdlib/analyses/ode/{ => v1}/mass_action.rs (69%) create mode 100644 packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs create mode 100644 packages/catlog/src/stdlib/analyses/ode/v1/mod.rs rename packages/catlog/src/stdlib/analyses/ode/{ => v1}/polynomial_ode.rs (88%) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index c38fdf168e..ef2a48592f 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; -use catlog::stdlib::analyses::ode::{self, ODESemantics, Parameter}; +use catlog::stdlib::analyses::ode::{self, ODESemantics, ODESemanticsProblemData, Parameter}; use catlog::zero::QualifiedName; use super::latex::latex_names; @@ -31,7 +31,7 @@ pub struct ODEResultWithEquations { /// 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, + problem_data: ODESemanticsProblemData, system: PolynomialSystem, i8>, ) -> Result { let sys_extended_scalars = problem_data.extend_scalars(system); @@ -63,8 +63,9 @@ pub(crate) mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType, ModeApp}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; + use catlog::stdlib::analyses::ode::MassActionEquationsConfig; use catlog::stdlib::{ - analyses::ode::{self, MassConservationType, ODESemanticsAnalysis}, + analyses::ode::{self, ODESemanticsAnalysis}, theories, }; use catlog::zero::{LabelSegment, Namespace, QualifiedName}; @@ -156,11 +157,8 @@ pub(crate) mod tests { #[test] fn stock_flow_balanced_mass_action_latex_equations() { let model = backward_link("xylophone", "y", "fff"); - let system = ode::StockFlowMassActionAnalysis { - mass_conservation_type: MassConservationType::Balanced, - ..ode::StockFlowMassActionAnalysis::default() - } - .build_system(model.discrete_tab().unwrap()); + let system = + ode::StockFlowMassActionAnalysis::default().build_system(model.discrete_tab().unwrap()); let equations = ode_semantics_equations::(&model, system).unwrap(); @@ -180,13 +178,14 @@ pub(crate) mod tests { #[test] fn stock_flow_unbalanced_mass_action_latex_equations() { let model = backward_link("xylophone", "y", "fff"); - let system = ode::StockFlowMassActionAnalysis { - mass_conservation_type: MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - ..ode::StockFlowMassActionAnalysis::default() - } - .build_system(model.discrete_tab().unwrap()); + let system = ode::StockFlowMassActionAnalysis::default().build_configured_system( + model.discrete_tab().unwrap(), + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + }, + ); let equations = ode_semantics_equations::(&model, system).unwrap(); @@ -207,11 +206,8 @@ pub(crate) mod tests { 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 = ode::PetriNetMassActionAnalysis { - mass_conservation_type: MassConservationType::Balanced, - ..ode::PetriNetMassActionAnalysis::default() - } - .build_system(model.modal_unital().unwrap()); + let system = + ode::PetriNetMassActionAnalysis::default().build_system(model.modal_unital().unwrap()); let equations = ode_semantics_equations::(&model, system).unwrap(); @@ -243,13 +239,14 @@ pub(crate) mod tests { // 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", "transition"); - let system = ode::PetriNetMassActionAnalysis { - mass_conservation_type: MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - ..ode::PetriNetMassActionAnalysis::default() - } - .build_system(model.modal_unital().unwrap()); + let system = ode::PetriNetMassActionAnalysis::default().build_configured_system( + model.modal_unital().unwrap(), + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + }, + ); let equations = ode_semantics_equations::(&model, system).unwrap(); @@ -274,13 +271,14 @@ pub(crate) mod tests { 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 = ode::PetriNetMassActionAnalysis { - mass_conservation_type: MassConservationType::Unbalanced( - ode::RateGranularity::PerPlace, - ), - ..ode::PetriNetMassActionAnalysis::default() - } - .build_system(model.modal_unital().unwrap()); + let system = ode::PetriNetMassActionAnalysis::default().build_configured_system( + model.modal_unital().unwrap(), + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerPlace, + ), + }, + ); let equations = ode_semantics_equations::(&model, system).unwrap(); @@ -429,6 +427,7 @@ pub(crate) mod tests { DblModel { model: inner.into(), ty: None, + elaboration_errors: Vec::new(), ob_namespace, mor_namespace, } @@ -484,6 +483,7 @@ pub(crate) mod tests { DblModel { model: inner.into(), ty: None, + elaboration_errors: Vec::new(), ob_namespace, mor_namespace, } diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index b9c462bb85..02bb6d2382 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -44,65 +44,15 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String #[cfg(test)] mod tests { use catlog::latex::{Latex, LatexEquation, LatexEquations}; - use catlog::stdlib::analyses::ode; use catlog::stdlib::analyses::ode::{ - LinearODEAnalysis, LotkaVolterraAnalysis, PetriNetMassActionAnalysis, - StockFlowMassActionAnalysis, ode_semantics::*, + self, LinearODEAnalysis, LotkaVolterraAnalysis, MassActionEquationsConfig, + PetriNetMassActionAnalysis, StockFlowMassActionAnalysis, ode_semantics::*, }; use super::*; use crate::analyses::tests::{catalytic_petri_net, parallel_negative_cld}; use crate::model::tests::backward_link; - #[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. @@ -134,7 +84,7 @@ mod tests { } #[test] - fn cld_lcc_latex_equations() { + fn cld_linear_ode_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(); @@ -158,9 +108,58 @@ mod tests { assert_eq!(equations, expected); } - // TODO: REMOVE THIS #[ignore] #[test] - #[ignore] + 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::default() + .build_configured_system( + tab_model, + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + }, + ) + .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 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", ""); @@ -173,14 +172,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(), ), }, @@ -192,30 +191,30 @@ 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 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 equations = PetriNetMassActionAnalysis::default() + .build_configured_system( + modal_model, + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + }, + ) + .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()), + 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()), @@ -225,35 +224,35 @@ 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 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)); + let equations = PetriNetMassActionAnalysis::default() + .build_configured_system( + modal_model, + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerPlace, + ), + }, + ) + .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()), + 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("".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("".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); @@ -263,14 +262,16 @@ mod tests { 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 equations = StockFlowMassActionAnalysis::default() + .build_configured_system( + tab_model, + MassActionEquationsConfig { + mass_conservation: ode::MassConservationType::Unbalanced( + ode::RateGranularity::PerTransition, + ), + }, + ) + .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 a83b11cdb5..c46159d81e 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -10,7 +10,8 @@ use catlog::dbl::theory::{self as theory, NonUnital, Unital}; use catlog::latex::LatexEquations; use catlog::one::Path; use catlog::stdlib::analyses::ode::{ - LinearODESemantics, LotkaVolterraSemantics, ODESemanticsAnalysis, + LinearODESemantics, LotkaVolterraSemantics, ODESemanticsAnalysis, PetriNetMassActionSemantics, + PolynomialODESemantics, StockFlowMassActionSemantics, }; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::name; @@ -343,13 +344,10 @@ impl ThCategoryLinks { pub fn mass_action( &self, model: &DblModel, - data: analyses::ode::MassActionProblemData, + data: analyses::ode::ODESemanticsProblemData, ) -> Result { - let system = analyses::ode::StockFlowMassActionAnalysis { - mass_conservation_type: data.equations_data.mass_conservation_type, - ..analyses::ode::StockFlowMassActionAnalysis::default() - } - .build_system(model.discrete_tab()?); + let system = analyses::ode::StockFlowMassActionAnalysis::default() + .build_configured_system(model.discrete_tab()?, data.equations_config.clone()); ode_semantics_simulation::(model, data, system) } @@ -358,13 +356,10 @@ impl ThCategoryLinks { pub fn mass_action_equations( &self, model: &DblModel, - data: analyses::ode::MassActionEquationsData, + data: analyses::ode::MassActionEquationsConfig, ) -> Result { - let system = analyses::ode::StockFlowMassActionAnalysis { - mass_conservation_type: data.mass_conservation_type, - ..analyses::ode::StockFlowMassActionAnalysis::default() - } - .build_system(model.discrete_tab()?); + let system = analyses::ode::StockFlowMassActionAnalysis::default() + .build_configured_system(model.discrete_tab()?, data); ode_semantics_equations::(model, system) } } @@ -407,13 +402,10 @@ impl ThSymMonoidalCategory { pub fn mass_action( &self, model: &DblModel, - data: analyses::ode::MassActionProblemData, + data: analyses::ode::ODESemanticsProblemData, ) -> Result { - let system = analyses::ode::PetriNetMassActionAnalysis { - mass_conservation_type: data.equations_data.mass_conservation_type, - ..analyses::ode::PetriNetMassActionAnalysis::default() - } - .build_system(model.modal_unital()?); + let system = analyses::ode::PetriNetMassActionAnalysis::default() + .build_configured_system(model.modal_unital()?, data.equations_config.clone()); ode_semantics_simulation::(model, data, system) } @@ -422,13 +414,10 @@ impl ThSymMonoidalCategory { pub fn mass_action_equations( &self, model: &DblModel, - data: analyses::ode::MassActionEquationsData, + data: analyses::ode::MassActionEquationsConfig, ) -> Result { - let system = analyses::ode::PetriNetMassActionAnalysis { - mass_conservation_type: data.mass_conservation_type, - ..analyses::ode::PetriNetMassActionAnalysis::default() - } - .build_system(model.modal_unital()?); + let system = analyses::ode::PetriNetMassActionAnalysis::default() + .build_configured_system(model.modal_unital()?, data); ode_semantics_equations::(model, system) } @@ -479,7 +468,7 @@ impl ThPolynomialODE { pub fn polynomial_ode_simulation( &self, model: &DblModel, - data: analyses::ode::PolynomialODEProblemData, + data: analyses::ode::ODESemanticsProblemData, ) -> Result { let system = analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); @@ -516,7 +505,7 @@ impl ThSignedPolynomialODE { pub fn polynomial_ode_simulation( &self, model: &DblModel, - data: analyses::ode::PolynomialODEProblemData, + data: analyses::ode::ODESemanticsProblemData, ) -> Result { let system = analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); diff --git a/packages/catlog/src/stdlib/analyses/ode/mod.rs b/packages/catlog/src/stdlib/analyses/ode/mod.rs index c5ce791d20..bce431c28b 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mod.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mod.rs @@ -70,15 +70,13 @@ impl ODEAnalysis { } 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 v0; +pub mod v1; 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 v1::linear_ode::*; +pub use v1::lotka_volterra::*; +pub use v1::mass_action::*; +pub use v1::polynomial_ode::*; diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 1a03c0801d..6ac19d2c91 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -57,20 +57,20 @@ 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 additional configuration data (if any) necessary for generating the system of equations. + /// For an example, see `mass_action::MassActionEquationsConfig`. + type EquationsConfigType: ODESemanticsEquationsConfig; /// The data describing the things that the ODE semantics "cares about". See the documentation /// for `ODESemanticsAnalysis` for more details. - type AnalysisType: ODESemanticsAnalysis; - /// The data necessary for displaying the system of equations, to be provided at run-time by the - /// front-end. - type EquationsDataType: ODESemanticsEquationsData; + type AnalysisType: ODESemanticsAnalysis; /// 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. type ParameterData: ODESemanticsScalarExtension; } -/// The models for which we support ODE semantics need to be sufficiently nice, though -/// these bounds are not particularly restrictive. +/// The models for which we support ODE semantics need to be sufficiently nice, though these bounds +/// should not prove particularly restrictive in practice. pub trait DblModelForODESemantics: FgCategory + DblModel + MutDblModel + Clone { @@ -177,26 +177,57 @@ impl PolynomialODESystemBuilder

{ } } +/// 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 widget. For an example, see `mass_action::MassActionEquationsConfig`. +/// +/// Note this this is slightly annoying, and will lead to most ODE semantics requiring us to define +/// `EquationsConfigType = ()` and passing `_equations_config: ()` into `build_system_builder`. +/// It seems likely that we should eventually either (a) break up mass-action semantics into three +/// separate semantics, or (b) have `AdmitsEquationsConfig` as a further separate trait. +pub trait ODESemanticsEquationsConfig: Default {} +impl ODESemanticsEquationsConfig for () {} + /// 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 +/// `build_system_builder()`, whereas `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 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 { +/// `ObType` and `MorType` that you want to distinguish between and iterate over. However, +/// this is left to the user: the type checker will *not* enforce anything helpful here. We +/// recommend looking at any existing implementations to get a better understanding. +pub trait ODESemanticsAnalysis< + T: DblModelForODESemantics, + P: ODEParameterType, + E: ODESemanticsEquationsConfig, +>: Default +{ /// 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

; + fn build_system_builder(&self, model: &T, equations_config: E) + -> PolynomialODESystemBuilder

; /// We simply feed the `PolynomialODESystemBuilder` constructed by the above function into - /// `PolynomialODEAnalysis::build_system_custom_parameters`. + /// `PolynomialODEAnalysis::build_system_custom_parameters` with the *default* values of the + /// `ODESemanticsEquationsConfig` type. fn build_system(&self, model: &T) -> PolynomialSystem, i8> { - let builder = self.build_system_builder(model); + self.build_configured_system(model, E::default()) + } + + /// We simply feed the `PolynomialODESystemBuilder` constructed by the above function into + /// `PolynomialODEAnalysis::build_system_custom_parameters` with *custom* values of the + /// `ODESemanticsEquationsConfig` type. + /// + /// Note that, if the `ODESemanticsEquationsConfig` in question is trivial, then this function + /// should essentially never be used, since `build_system` then always does the same. + fn build_configured_system( + &self, + model: &T, + equations_config: E, + ) -> PolynomialSystem, i8> { + let builder = self.build_system_builder(model, equations_config); PolynomialODEAnalysis::default().build_system_custom_parameters( &builder.clone().model(), builder.associated_parameters(), @@ -231,13 +262,6 @@ pub enum ContributionSign { Negative, } -/// 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 () {} - /// How to convert the formal parameters of type `ODEParameterType` into floats using data from /// `ODESemanticsProblemData.parameter_data`. pub trait ODESemanticsScalarExtension { @@ -252,7 +276,7 @@ pub trait ODESemanticsScalarExtension { /// 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. Note that it must -/// also contain `ODESemanticsEquationsData`, since we need to know how to build the equations +/// also contain `ODESemanticsEquationsConfig`, since we need to know how to build the equations /// before we are able to solve them numerically. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -265,8 +289,8 @@ where T: ODESemantics, { /// Further data needed to specify the ODE equations. - #[cfg_attr(feature = "serde", serde(rename = "equationsData"))] - pub equations_data: T::EquationsDataType, + #[cfg_attr(feature = "serde", serde(rename = "equationsConfig"))] + pub equations_config: T::EquationsConfigType, /// Map from object IDs to initial values (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] pub initial_values: HashMap, @@ -278,7 +302,7 @@ where } impl ODESemanticsProblemData { - /// TODO: docs + /// TODO: docs. pub fn extend_scalars( &self, system: PolynomialSystem, i8>, diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs new file mode 100644 index 0000000000..62739e89c1 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs @@ -0,0 +1,30 @@ +//! Version 0 of `linear_ode`, before the addition of `ode_semantics`. + +use std::collections::HashMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + +use crate::zero::QualifiedName; + +/// Data defining a linear 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 LinearODEProblemData { + /// Map from morphism IDs to interaction coefficients (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "coefficients"))] + pub(crate) coefficients: HashMap, + + /// Map from object IDs to initial values (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] + pub(crate) initial_values: HashMap, + + /// Duration of simulation. + pub(crate) duration: f32, +} diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs new file mode 100644 index 0000000000..7da71daf78 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs @@ -0,0 +1,34 @@ +//! Version 0 of `lotka_volterra`, before the addition of `ode_semantics`. + +use std::collections::HashMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + +use crate::zero::QualifiedName; + +/// Data defining a Lotka-Volterra 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 LotkaVolterraProblemData { + /// Map from morphism IDs to interaction coefficients (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "interactionCoefficients"))] + pub(crate) interaction_coeffs: HashMap, + + /// Map from object IDs to growth rates (arbitrary real numbers). + #[cfg_attr(feature = "serde", serde(rename = "growthRates"))] + pub(crate) growth_rates: HashMap, + + /// Map from object IDs to initial values (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] + pub(crate) initial_values: HashMap, + + /// Duration of simulation. + pub(crate) duration: f32, +} diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs new file mode 100644 index 0000000000..724f2b4006 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs @@ -0,0 +1,67 @@ +//! Version 0 of `mass_action`, before the addition of `ode_semantics`. + +use std::collections::HashMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + +use crate::{stdlib::analyses::ode::MassConservationType, zero::QualifiedName}; + +/// 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))] +#[derive(Clone)] +pub struct MassActionEquationsData { + /// Whether or not mass is conserved. + #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] + pub mass_conservation_type: MassConservationType, +} + +/// Data defining a mass-action 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). + #[cfg_attr(feature = "serde", serde(rename = "equationsData"))] + pub(crate) equations_data: MassActionEquationsData, + + /// Map from morphism IDs to consumption rate coefficients (non-negative 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"))] + pub(crate) transition_rates: HashMap, + + /// Map from morphism IDs to consumption rate coefficients (non-negative reals), + /// for the unbalanced per transition case. + #[cfg_attr(feature = "serde", serde(rename = "transitionConsumptionRates"))] + pub(crate) transition_consumption_rates: HashMap, + + /// Map from morphism IDs to production rate coefficients (non-negative reals), + /// for the unbalanced per transition case. + #[cfg_attr(feature = "serde", serde(rename = "transitionProductionRates"))] + pub(crate) transition_production_rates: HashMap, + + /// Map from morphism IDs to (map from input objects to consumption rate coefficients), + /// for the unbalanced per place case (non-negative reals). + #[cfg_attr(feature = "serde", serde(rename = "placeConsumptionRates"))] + pub(crate) place_consumption_rates: HashMap>, + + /// Map from morphism IDs to (map from output objects to production rate coefficients), + /// for the unbalanced per place case (non-negative reals). + #[cfg_attr(feature = "serde", serde(rename = "placeProductionRates"))] + pub(crate) place_production_rates: HashMap>, + + /// Map from object IDs to initial values (non-negative reals). + #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] + pub(crate) initial_values: HashMap, + + /// Duration of simulation. + pub(crate) duration: f32, +} diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/mod.rs b/packages/catlog/src/stdlib/analyses/ode/v0/mod.rs new file mode 100644 index 0000000000..7e229172a7 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v0/mod.rs @@ -0,0 +1,6 @@ +//! Version 0 analyses, i.e. those that have been since modified (to v1) with breaking changes. + +pub mod linear_ode; +pub mod lotka_volterra; +pub mod mass_action; +pub mod polynomial_ode; diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs new file mode 100644 index 0000000000..1e1b17a614 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs @@ -0,0 +1,29 @@ +//! Version 0 of `polynomial_ode`, before the addition of `ode_semantics`. + +use std::collections::HashMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + +use crate::zero::QualifiedName; + +/// Data defining a polynomial 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 interaction coefficients (nonnegative reals). + pub(crate) coefficients: HashMap, + + /// Map from object IDs to initial values (nonnegative reals). + #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] + pub(crate) initial_values: HashMap, + + /// Duration of simulation. + pub(crate) duration: f32, +} diff --git a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs similarity index 92% rename from packages/catlog/src/stdlib/analyses/ode/linear_ode.rs rename to packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs index 11a28f1432..90cb688328 100644 --- a/packages/catlog/src/stdlib/analyses/ode/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs @@ -8,11 +8,16 @@ use std::collections::HashMap; use std::fmt; -use super::Parameter; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + use crate::dbl::model::{FpDblModel, MutDblModel}; use crate::latex::{Latex, ToLatexWithMap}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::Parameter; use crate::stdlib::analyses::ode::ode_semantics::{ ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsScalarExtension, PolynomialODESystemBuilder, @@ -27,7 +32,7 @@ impl ODESemantics for LinearODESemantics { type ModelType = DiscreteDblModel; type ParameterType = LinearODEParameter; type AnalysisType = LinearODEAnalysis; - type EquationsDataType = (); + type EquationsConfigType = (); type ParameterData = LinearODEParameterData; } @@ -86,6 +91,7 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, + ::EquationsConfigType, > for LinearODEAnalysis { /// Creates a linear system with symbolic rate coefficients. @@ -94,6 +100,7 @@ impl fn build_system_builder( &self, model: &DiscreteDblModel, + _equations_config: (), ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); @@ -142,10 +149,16 @@ impl } } -/// TODO: docs +/// Data input by the user to fill in the parameters numerically. +#[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 LinearODEParameterData { - /// Map from morphism IDs to interaction coefficients (nonnegative reals). - coefficients: HashMap, + /// Map from morphism IDs to interaction coefficients (non-negative reals). + pub(crate) coefficients: HashMap, } impl ODESemanticsScalarExtension<::ParameterType> @@ -221,9 +234,8 @@ mod test { fn predator_prey_numerical() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_data: (), + equations_config: (), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, parameter_data: LinearODEParameterData { @@ -232,7 +244,6 @@ mod test { .collect(), }, }; - let sys = LinearODEAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" diff --git a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs similarity index 96% rename from packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs rename to packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs index 5387ef1079..863844175b 100644 --- a/packages/catlog/src/stdlib/analyses/ode/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs @@ -13,15 +13,14 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::Parameter; use crate::dbl::model::{FpDblModel, MutDblModel}; use crate::latex::{Latex, ToLatexWithMap}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::ODESemanticsScalarExtension; +use crate::stdlib::analyses::ode::Parameter; use crate::stdlib::analyses::ode::ode_semantics::{ ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, - PolynomialODESystemBuilder, + ODESemanticsScalarExtension, PolynomialODESystemBuilder, }; use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; @@ -33,7 +32,7 @@ impl ODESemantics for LotkaVolterraSemantics { type ModelType = DiscreteDblModel; type ParameterType = LotkaVolterraParameter; type AnalysisType = LotkaVolterraAnalysis; - type EquationsDataType = (); + type EquationsConfigType = (); type ParameterData = LotkaVolterraParameterData; } @@ -102,6 +101,7 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, + ::EquationsConfigType, > for LotkaVolterraAnalysis { /// Creates a Lotka-Volterra system with symbolic rate coefficients. @@ -113,6 +113,7 @@ impl fn build_system_builder( &self, model: &DiscreteDblModel, + _equations_config: (), ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); @@ -183,11 +184,11 @@ impl pub struct LotkaVolterraParameterData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "interactionCoefficients"))] - interaction_coeffs: HashMap, + pub(crate) interaction_coeffs: HashMap, /// Map from object IDs to growth rates (arbitrary real numbers). #[cfg_attr(feature = "serde", serde(rename = "growthRates"))] - growth_rates: HashMap, + pub(crate) growth_rates: HashMap, } impl ODESemanticsScalarExtension<::ParameterType> @@ -266,9 +267,8 @@ mod test { fn predator_prey_numerical() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_data: (), + equations_config: (), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, parameter_data: LotkaVolterraParameterData { @@ -278,7 +278,6 @@ mod test { growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), }, }; - let sys = LotkaVolterraAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" diff --git a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs similarity index 69% rename from packages/catlog/src/stdlib/analyses/ode/mass_action.rs rename to packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs index b34c7dbfb2..72c4f1d1a3 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs @@ -12,9 +12,9 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use super::Parameter; use crate::latex::{Latex, ToLatexWithMap}; use crate::simulate::ode::PolynomialSystem; +use crate::stdlib::analyses::ode::Parameter; use crate::stdlib::analyses::ode::ode_semantics::*; use crate::stdlib::analyses::petri::transition_interface; use crate::stdlib::analyses::stock_flow::flow_interface; @@ -27,6 +27,11 @@ use crate::{ zero::name_seg, }; +// Because Petri nets and stock-flow diagrams are different types of models (unital modal and +// discrete tabulator, respectively), we need a different structs for each one, since to implement +// `ODESemantics` we need to specify a `ModelType`. In particular, they will need different +// implementations of `ODESemanticsAnalysis::build_system_builder`. + /// Mass-action semantics for Petri nets. pub struct PetriNetMassActionSemantics; /// Mass-action semantics for stock-flow diagrams. @@ -36,7 +41,7 @@ impl ODESemantics for PetriNetMassActionSemantics { type ModelType = ModalDblModel; type ParameterType = MassActionParameter; type AnalysisType = PetriNetMassActionAnalysis; - type EquationsDataType = MassActionEquationsData; + type EquationsConfigType = MassActionEquationsConfig; type ParameterData = MassActionParameterData; } @@ -44,7 +49,7 @@ impl ODESemantics for StockFlowMassActionSemantics { type ModelType = DiscreteTabModel; type ParameterType = MassActionParameter; type AnalysisType = StockFlowMassActionAnalysis; - type EquationsDataType = MassActionEquationsData; + type EquationsConfigType = MassActionEquationsConfig; type ParameterData = MassActionParameterData; } @@ -189,6 +194,27 @@ impl ToLatexWithMap for MassActionParameter { impl ODEParameterType for MassActionParameter {} +/// 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))] +#[derive(Clone)] +pub struct MassActionEquationsConfig { + /// Whether or not mass is conserved. + #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] + pub mass_conservation: MassConservationType, +} + +impl Default for MassActionEquationsConfig { + fn default() -> Self { + Self { + mass_conservation: MassConservationType::Balanced, + } + } +} + +impl ODESemanticsEquationsConfig for MassActionEquationsConfig {} + /// Mass-action ODE analysis for Petri nets. /// /// This struct implements the object part of the functorial semantics for reaction @@ -198,8 +224,6 @@ 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 { @@ -208,7 +232,6 @@ impl Default for PetriNetMassActionAnalysis { Self { place_ob_type: ob_type.clone(), transition_mor_type: ModalMorType::Zero(ob_type), - mass_conservation_type: MassConservationType::Balanced, } } } @@ -217,11 +240,13 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, + ::EquationsConfigType, > for PetriNetMassActionAnalysis { fn build_system_builder( &self, model: &ModalDblModel, + equations_config: MassActionEquationsConfig, ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); @@ -247,11 +272,11 @@ impl // 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`: + // where Parameter_! depends on `mass_conservation`: // Balanced => Parameter_T // Unbalanced::PerTransition => Parameter_T^inflow // Unbalanced::PerPlace => Parameter_{T,y_i}^inflow - let parameter = match self.mass_conservation_type { + let parameter = match equations_config.mass_conservation { MassConservationType::Balanced => { MassActionParameter::Balanced { flow: transition.clone() } } @@ -285,11 +310,11 @@ impl // 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 + // where Parameter_! depends on `mass_conservation`: + // Balanced => Parameter_T // Unbalanced::PerTransition => Parameter_T^outflow - // Unbalanced::PerPlace => Parameter_{T,x_i}^outflow - let parameter = match self.mass_conservation_type { + // Unbalanced::PerPlace => Parameter_{T,x_i}^outflow + let parameter = match equations_config.mass_conservation { MassConservationType::Balanced => { MassActionParameter::Balanced { flow: transition.clone() } } @@ -332,8 +357,6 @@ 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 { @@ -344,7 +367,6 @@ impl Default for StockFlowMassActionAnalysis { 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, } } } @@ -353,11 +375,13 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, + ::EquationsConfigType, > for StockFlowMassActionAnalysis { fn build_system_builder( &self, model: &DiscreteTabModel, + equations_config: MassActionEquationsConfig, ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); @@ -382,14 +406,14 @@ impl // becomes the contributions // \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 + // where Parameter_! and Parameter_? depend on `mass_conservation`: + // Balanced => Parameter_! = Parameter_F + // Parameter_? = Parameter_F // Unbalanced::PerTransition => Parameter_! = Parameter_F^inflow - // Parameter_? = Parameter_F^outflow + // Parameter_? = Parameter_F^outflow let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); - let output_parameter = match self.mass_conservation_type { + let output_parameter = match equations_config.mass_conservation { MassConservationType::Balanced => { MassActionParameter::Balanced { flow: flow.clone() } } @@ -407,7 +431,7 @@ impl ); let input_id = input.cons(name_seg("ToInput")).cons(flow.only().unwrap()); - let input_parameter = match self.mass_conservation_type { + let input_parameter = match equations_config.mass_conservation { MassConservationType::Balanced => { MassActionParameter::Balanced { flow: flow.clone() } } @@ -429,27 +453,6 @@ 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))] -#[derive(Clone)] -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 input by the user to fill in the parameters numerically. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -458,31 +461,30 @@ impl ODESemanticsEquationsData for MassActionEquationsData {} tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] pub struct MassActionParameterData { - /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), + /// Map from morphism IDs to consumption rate coefficients (non-negative 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, + #[cfg_attr(feature = "serde", serde(rename = "transitionRates"))] + pub(crate) transition_rates: HashMap, - /// Map from morphism IDs to consumption rate coefficients (nonnegative reals), + /// Map from morphism IDs to consumption rate coefficients (non-negative reals), /// for the unbalanced per transition case. #[cfg_attr(feature = "serde", serde(rename = "transitionConsumptionRates"))] - transition_consumption_rates: HashMap, + pub(crate) transition_consumption_rates: HashMap, - /// Map from morphism IDs to production rate coefficients (nonnegative reals), + /// Map from morphism IDs to production rate coefficients (non-negative reals), /// for the unbalanced per transition case. #[cfg_attr(feature = "serde", serde(rename = "transitionProductionRates"))] - transition_production_rates: HashMap, + pub(crate) 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). + /// for the unbalanced per place case (non-negative reals). #[cfg_attr(feature = "serde", serde(rename = "placeConsumptionRates"))] - place_consumption_rates: HashMap>, + pub(crate) 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). + /// for the unbalanced per place case (non-negative reals). #[cfg_attr(feature = "serde", serde(rename = "placeProductionRates"))] - place_production_rates: HashMap>, + pub(crate) place_production_rates: HashMap>, } impl ODESemanticsScalarExtension for MassActionParameterData { @@ -546,9 +548,10 @@ mod tests { use std::rc::Rc; use super::*; + use crate::stdlib::analyses::ode::{MassConservationType, RateGranularity}; use crate::{ latex::{LatexEquation, LatexEquations}, - stdlib::{analyses, models::*, theories::*}, + stdlib::{models::*, theories::*}, }; // Symbolic tests. @@ -571,13 +574,12 @@ mod tests { 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::PerTransition, - ), - ..StockFlowMassActionAnalysis::default() - } - .build_system(&model); + let sys = StockFlowMassActionAnalysis::default().build_configured_system( + &model, + MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), + }, + ); let expected = expect!([r#" dx = -Outgoing(f) x y dy = Incoming(f) x y @@ -604,13 +606,12 @@ mod tests { 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::PerTransition, - ), - ..PetriNetMassActionAnalysis::default() - } - .build_system(&model); + let sys = PetriNetMassActionAnalysis::default().build_configured_system( + &model, + MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), + }, + ); let expected = expect!([r#" dx = -Outgoing(f) c x dy = Incoming(f) c x @@ -623,13 +624,12 @@ mod tests { 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::PerPlace, - ), - ..PetriNetMassActionAnalysis::default() - } - .build_system(&model); + let sys = PetriNetMassActionAnalysis::default().build_configured_system( + &model, + MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerPlace), + }, + ); let expected = expect!([r#" dx = -(x->[f]) c x dy = ([f]->y) c x @@ -639,20 +639,175 @@ mod tests { } // Numerical tests. - // TODO: write some numerical tests. + + // 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_numerical() { + let th = Rc::new(th_category_links()); + let model = backward_link(th); + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig::default(), + initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), + duration: 10.0, + parameter_data: MassActionParameterData { + transition_rates: [(name("f"), 2.0)].into_iter().collect(), + transition_consumption_rates: HashMap::new(), + transition_production_rates: HashMap::new(), + place_consumption_rates: HashMap::new(), + place_production_rates: HashMap::new(), + }, + }; + let sys = StockFlowMassActionAnalysis::default().build_system(&model); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dx = -2 x y + dy = 2 x y + "#]); + expected.assert_eq(&analysis.to_string()); + } + + #[test] + fn unbalanced_stock_flow_numerical() { + let th = Rc::new(th_category_links()); + let model = backward_link(th); + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), + }, + initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), + duration: 10.0, + parameter_data: MassActionParameterData { + transition_rates: HashMap::new(), + transition_consumption_rates: [(name("f"), 1.5)].into_iter().collect(), + transition_production_rates: [(name("f"), 2.0)].into_iter().collect(), + place_consumption_rates: HashMap::new(), + place_production_rates: HashMap::new(), + }, + }; + let sys = StockFlowMassActionAnalysis::default() + .build_configured_system(&model, data.equations_config.clone()); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dx = -1.5 x y + dy = 2 x y + "#]); + expected.assert_eq(&analysis.to_string()); + } + + #[test] + fn balanced_petri_numerical() { + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig::default(), + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + parameter_data: MassActionParameterData { + transition_rates: [(name("f"), 1.5)].into_iter().collect(), + transition_consumption_rates: HashMap::new(), + transition_production_rates: HashMap::new(), + place_consumption_rates: HashMap::new(), + place_production_rates: HashMap::new(), + }, + }; + let sys = PetriNetMassActionAnalysis::default() + .build_configured_system(&model, data.equations_config.clone()); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dx = -1.5 c x + dy = 1.5 c x + dc = 0 + "#]); + expected.assert_eq(&analysis.to_string()); + } + + #[test] + fn unbalanced_petri_per_transition_numerical() { + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), + }, + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + parameter_data: MassActionParameterData { + transition_rates: HashMap::new(), + transition_consumption_rates: [(name("f"), 3.5)].into_iter().collect(), + transition_production_rates: [(name("f"), 4.0)].into_iter().collect(), + place_consumption_rates: HashMap::new(), + place_production_rates: HashMap::new(), + }, + }; + let sys = PetriNetMassActionAnalysis::default() + .build_configured_system(&model, data.equations_config.clone()); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dx = -3.5 c x + dy = 4 c x + dc = 0.5 c x + "#]); + expected.assert_eq(&analysis.to_string()); + } + + #[test] + fn unbalanced_petri_per_place_numerical() { + // TODO: finish + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerPlace), + }, + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + parameter_data: MassActionParameterData { + transition_rates: HashMap::new(), + transition_consumption_rates: HashMap::new(), + transition_production_rates: HashMap::new(), + place_consumption_rates: [( + name("f"), + [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), + )] + .into_iter() + .collect(), + place_production_rates: [( + name("f"), + [(name("y"), 1.5), (name("c"), 2.5)].into_iter().collect(), + )] + .into_iter() + .collect(), + }, + }; + let sys = PetriNetMassActionAnalysis::default() + .build_configured_system(&model, data.equations_config.clone()); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dx = -2 c x + dy = 1.5 c x + dc = -0.5 c x + "#]); + expected.assert_eq(&analysis.to_string()); + } // LaTeX tests. #[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::PerTransition, - ), - ..StockFlowMassActionAnalysis::default() - } - .build_system(&model); + let sys = StockFlowMassActionAnalysis::default().build_configured_system( + &model, + MassActionEquationsConfig { + mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), + }, + ); let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs new file mode 100644 index 0000000000..702b2d688c --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs @@ -0,0 +1,199 @@ +//! Migrations from v0 to v1 for analyses. + +use crate::stdlib::analyses::ode::v0::linear_ode::LinearODEProblemData; +use crate::stdlib::analyses::ode::v0::lotka_volterra::LotkaVolterraProblemData; +use crate::stdlib::analyses::ode::v0::mass_action::MassActionProblemData; +use crate::stdlib::analyses::ode::v0::polynomial_ode::PolynomialODEProblemData; +use crate::stdlib::analyses::ode::{ + LinearODEParameterData, LinearODESemantics, LotkaVolterraParameterData, LotkaVolterraSemantics, + MassActionEquationsConfig, MassActionParameterData, ODESemanticsProblemData, + PetriNetMassActionSemantics, PolynomialODEParameterData, PolynomialODESemantics, + StockFlowMassActionSemantics, +}; + +/// Migration for problem data for linear ODE. +pub fn migrate_linear_ode_v0_to_v1( + v0: LinearODEProblemData, +) -> ODESemanticsProblemData { + let v1: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: (), + initial_values: v0.initial_values, + duration: v0.duration, + parameter_data: LinearODEParameterData { coefficients: v0.coefficients }, + }; + v1 +} + +/// Migration for problem data for Lotka-Volterra. +pub fn migrate_lotka_volterra_v0_to_v1( + v0: LotkaVolterraProblemData, +) -> ODESemanticsProblemData { + let v1: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: (), + initial_values: v0.initial_values, + duration: v0.duration, + parameter_data: LotkaVolterraParameterData { + interaction_coeffs: v0.interaction_coeffs, + growth_rates: v0.growth_rates, + }, + }; + v1 +} + +/// Migration for problem data for polynomial ODE. +pub fn migrate_polynomial_ode_v0_to_v1( + v0: PolynomialODEProblemData, +) -> ODESemanticsProblemData { + let v1: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: (), + initial_values: v0.initial_values, + duration: v0.duration, + parameter_data: PolynomialODEParameterData { coefficients: v0.coefficients }, + }; + v1 +} + +/// Migration for problem data for mass-action. +pub fn migrate_petri_net_mass_action_v0_to_v1( + v0: MassActionProblemData, +) -> ODESemanticsProblemData { + let v1: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig { + mass_conservation: v0.equations_data.mass_conservation_type, + }, + initial_values: v0.initial_values, + duration: v0.duration, + parameter_data: MassActionParameterData { + transition_rates: v0.transition_rates, + transition_consumption_rates: v0.transition_consumption_rates, + transition_production_rates: v0.transition_production_rates, + place_consumption_rates: v0.place_consumption_rates, + place_production_rates: v0.place_production_rates, + }, + }; + v1 +} + +/// Migration for problem data for mass-action. +pub fn migrate_stock_flow_mass_action_v0_to_v1( + v0: MassActionProblemData, +) -> ODESemanticsProblemData { + let v1: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: MassActionEquationsConfig { + mass_conservation: v0.equations_data.mass_conservation_type, + }, + initial_values: v0.initial_values, + duration: v0.duration, + parameter_data: MassActionParameterData { + transition_rates: v0.transition_rates, + transition_consumption_rates: v0.transition_consumption_rates, + transition_production_rates: v0.transition_production_rates, + place_consumption_rates: v0.place_consumption_rates, + place_production_rates: v0.place_production_rates, + }, + }; + v1 +} + +#[cfg(test)] +mod test { + use std::rc::Rc; + + use super::*; + use crate::{ + stdlib::{ + analyses::ode::{ + LinearODEAnalysis, LotkaVolterraAnalysis, ODESemanticsAnalysis, + PolynomialODEAnalysis, + }, + negative_feedback, th_polynomial_ode_system, th_signed_category, + unsigned_lotka_volterra_dynamics, + }, + zero::name, + }; + use expect_test::expect; + + #[test] + fn linear_ode_v0_to_v1_migration() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + + let v0_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 v1_data = migrate_linear_ode_v0_to_v1(v0_data); + + let sys = LinearODEAnalysis::default().build_system(&model); + let analysis = v1_data.extend_scalars(sys); + let expected = expect!([r#" + dx = -2 y + dy = 3 x + "#]); + expected.assert_eq(&analysis.to_string()); + } + + #[test] + fn lotka_volterra_v0_to_v1_migration() { + let th = Rc::new(th_signed_category()); + let model = negative_feedback(th); + + let v0_data = LotkaVolterraProblemData { + interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] + .into_iter() + .collect(), + growth_rates: [(name("x"), 2.0), (name("y"), -1.0)].into_iter().collect(), + initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), + duration: 10.0, + }; + + let v1_data = migrate_lotka_volterra_v0_to_v1(v0_data); + + let sys = LotkaVolterraAnalysis::default().build_system(&model); + let analysis = v1_data.extend_scalars(sys); + let expected = expect!([r#" + dx = 2 x - x y + dy = x y - y + "#]); + expected.assert_eq(&analysis.to_string()); + } + + #[test] + fn polynomial_ode_v0_to_v1_migration() { + let th = Rc::new(th_polynomial_ode_system()); + let model = unsigned_lotka_volterra_dynamics(th); + + let v0_data = PolynomialODEProblemData { + coefficients: [ + (name("A_growth"), 1.0), + (name("B_growth"), 2.0), + (name("C_growth"), -2.0), + (name("AB_interaction"), 1.5), + (name("BA_interaction"), -2.0), + (name("BC_interaction"), 3.0), + (name("CB_interaction"), -3.0), + ] + .into_iter() + .collect(), + initial_values: [(name("a"), 1.0), (name("b"), 1.0), (name("c"), 1.0)] + .into_iter() + .collect(), + duration: 10.0, + }; + + let v1_data = migrate_polynomial_ode_v0_to_v1(v0_data); + + let sys = PolynomialODEAnalysis::default().build_system(&model); + let analysis = v1_data.extend_scalars(sys); + let expected = expect!([r#" + dA = A - 2 A B + dB = 1.5 A B + 2 B - 3 B C + dC = 3 B C - 2 C + "#]); + expected.assert_eq(&analysis.to_string()); + } + + // TODO: mass-action migration tests. +} diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/mod.rs b/packages/catlog/src/stdlib/analyses/ode/v1/mod.rs new file mode 100644 index 0000000000..1e17cb360a --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/v1/mod.rs @@ -0,0 +1,7 @@ +//! Version 1 analyses, i.e. those that have received breaking changes since v0. + +pub mod linear_ode; +pub mod lotka_volterra; +pub mod mass_action; +pub mod migrate; +pub mod polynomial_ode; diff --git a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs similarity index 88% rename from packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs rename to packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs index 62160ba92e..d594d2ff71 100644 --- a/packages/catlog/src/stdlib/analyses/ode/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs @@ -41,7 +41,7 @@ impl ODESemantics for PolynomialODESemantics { type ModelType = ModalDblModel; type ParameterType = PolynomialODEParameter; type AnalysisType = PolynomialODEAnalysis; - type EquationsDataType = (); + type EquationsConfigType = (); type ParameterData = PolynomialODEParameterData; } @@ -103,11 +103,13 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, + ::EquationsConfigType, > for PolynomialODEAnalysis { fn build_system_builder( &self, model: &ModalDblModel, + _equations_config: (), ) -> PolynomialODESystemBuilder { PolynomialODESystemBuilder::identity(model.clone()) } @@ -217,7 +219,7 @@ impl PolynomialODEAnalysis { )] pub struct PolynomialODEParameterData { /// Map from morphism IDs to coefficients (nonnegative reals). - pub coefficients: HashMap, + pub(crate) coefficients: HashMap, } impl ODESemanticsScalarExtension<::ParameterType> @@ -251,13 +253,13 @@ mod tests { use super::*; use crate::{ latex::{Latex, LatexEquation, LatexEquations, wrap_with_backslash_text}, - stdlib::{models::*, theories::*}, + stdlib::{analyses::ode::ODESemanticsProblemData, models::*, theories::*}, tt, }; // Symbolic tests. #[test] - fn polynomial_ode_unsigned_lotka_volterra_equations() { + fn 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); @@ -270,7 +272,39 @@ mod tests { } // Numerical tests. - // TODO: write some numerical tests. + #[test] + fn numerical() { + let th = Rc::new(th_polynomial_ode_system()); + let model = unsigned_lotka_volterra_dynamics(th); + let data: ODESemanticsProblemData = ODESemanticsProblemData { + equations_config: (), + initial_values: [(name("a"), 1.0), (name("b"), 1.0), (name("c"), 1.0)] + .into_iter() + .collect(), + duration: 10.0, + parameter_data: PolynomialODEParameterData { + coefficients: [ + (name("A_growth"), 1.0), + (name("B_growth"), 2.0), + (name("C_growth"), -2.0), + (name("AB_interaction"), 1.5), + (name("BA_interaction"), -2.0), + (name("BC_interaction"), 3.0), + (name("CB_interaction"), -3.0), + ] + .into_iter() + .collect(), + }, + }; + let sys = PolynomialODEAnalysis::default().build_system(&model); + let analysis = data.extend_scalars(sys); + let expected = expect!([r#" + dA = A - 2 A B + dB = 1.5 A B + 2 B - 3 B C + dC = 3 B C - 2 C + "#]); + expected.assert_eq(&analysis.to_string()); + } // DoubleTT elaboration test. #[test] @@ -299,7 +333,7 @@ mod tests { // LaTeX tests. #[test] - fn polynomial_ode_lotka_volterra_equations_latex() { + fn lotka_volterra_equations_latex() { let th = Rc::new(th_signed_polynomial_ode_system()); let model = signed_lotka_volterra_dynamics(th); let system = PolynomialODEAnalysis::default().build_system(&model); diff --git a/packages/catlog/src/stdlib/models.rs b/packages/catlog/src/stdlib/models.rs index 3d97910b26..d9f34e369f 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -132,7 +132,7 @@ fn backward_link_of_type(th: Rc, link_type: TabMorType) -> Di model } -/// A reaction involving three species, one playing the role of a catalyst. +/// A reaction involving three species, one playing the role of a catalyst: [x,c] -> [y,c]. /// /// A free symmetric monoidal category, viewed as a reaction network. pub fn catalyzed_reaction(th: Rc>) -> ModalDblModel { @@ -151,7 +151,7 @@ pub fn catalyzed_reaction(th: Rc>) -> ModalDblModel [I, I]. pub fn sir_petri(th: Rc>) -> ModalDblModel { let (ob_type, op) = (ModalObType::new(name("Object")), name("tensor")); let mut model = ModalDblModel::new(th); diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 107a5f4bb9..5c73fcbbb5 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -28,6 +28,7 @@ import "./simulation.css"; /** Analyze a model using mass-action dynamics. */ export default function MassAction( + // TODO: switch to GeneralMassActionProblemData = MassActionProblemData | PPMassActionProblemData | PTMassActionProblemData props: ModelAnalysisProps & { ratesHaveGranularity: boolean; simulate: MassActionSimulator; From 75de963aaa56b8f09fa974999ed19bacb11d4c09 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Thu, 20 Aug 2026 21:00:46 +0100 Subject: [PATCH 03/83] WIP: Getting rid of EquationConfig; all migrations in catlog --- packages/catlog-wasm/src/analyses.rs | 47 +- packages/catlog-wasm/src/latex.rs | 53 +- packages/catlog-wasm/src/theories.rs | 21 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 127 +- .../src/stdlib/analyses/ode/v0/mass_action.rs | 81 +- .../src/stdlib/analyses/ode/v1/linear_ode.rs | 20 +- .../stdlib/analyses/ode/v1/lotka_volterra.rs | 20 +- .../src/stdlib/analyses/ode/v1/mass_action.rs | 1043 ++++++++++------- .../src/stdlib/analyses/ode/v1/migrate.rs | 242 +++- .../stdlib/analyses/ode/v1/polynomial_ode.rs | 20 +- 10 files changed, 1035 insertions(+), 639 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index ef2a48592f..f341b182d0 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -63,7 +63,6 @@ pub(crate) mod tests { use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType, ModeApp}; use catlog::dbl::model::{ModalDblModel, MutDblModel}; use catlog::latex::{Latex, LatexEquation, LatexEquations}; - use catlog::stdlib::analyses::ode::MassActionEquationsConfig; use catlog::stdlib::{ analyses::ode::{self, ODESemanticsAnalysis}, theories, @@ -178,16 +177,11 @@ pub(crate) mod tests { #[test] fn stock_flow_unbalanced_mass_action_latex_equations() { let model = backward_link("xylophone", "y", "fff"); - let system = ode::StockFlowMassActionAnalysis::default().build_configured_system( - model.discrete_tab().unwrap(), - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - }, - ); + let system = ode::StockFlowUnbalancedMassActionAnalysis::default() + .build_system(model.discrete_tab().unwrap()); let equations = - ode_semantics_equations::(&model, system).unwrap(); + ode_semantics_equations::(&model, system) + .unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -239,16 +233,11 @@ pub(crate) mod tests { // 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", "transition"); - let system = ode::PetriNetMassActionAnalysis::default().build_configured_system( - model.modal_unital().unwrap(), - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - }, - ); + let system = ode::PetriNetUnbalancedMassActionAnalysis::default() + .build_system(model.modal_unital().unwrap()); let equations = - ode_semantics_equations::(&model, system).unwrap(); + ode_semantics_equations::(&model, system) + .unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -261,7 +250,7 @@ pub(crate) mod tests { }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} c".to_string()), - rhs: Latex("(\\rho_{\\text{transition}} - \\kappa_{\\text{transition}}) \\cdot \\text{liquid} \\cdot c".to_string()), + rhs: Latex("(-\\kappa_{\\text{transition}} + \\rho_{\\text{transition}}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); @@ -271,16 +260,12 @@ pub(crate) mod tests { 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 = ode::PetriNetMassActionAnalysis::default().build_configured_system( - model.modal_unital().unwrap(), - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerPlace, - ), - }, - ); - let equations = - ode_semantics_equations::(&model, system).unwrap(); + let system = ode::PetriNetVeryUnbalancedMassActionAnalysis::default() + .build_system(model.modal_unital().unwrap()); + let equations = ode_semantics_equations::( + &model, system, + ) + .unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -293,7 +278,7 @@ pub(crate) mod tests { }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} 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()), + rhs: Latex("(-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{c} + \\rho_{[\\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 02bb6d2382..cb09eb735f 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -45,8 +45,9 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String mod tests { use catlog::latex::{Latex, LatexEquation, LatexEquations}; use catlog::stdlib::analyses::ode::{ - self, LinearODEAnalysis, LotkaVolterraAnalysis, MassActionEquationsConfig, - PetriNetMassActionAnalysis, StockFlowMassActionAnalysis, ode_semantics::*, + LinearODEAnalysis, LotkaVolterraAnalysis, PetriNetMassActionAnalysis, + PetriNetUnbalancedMassActionAnalysis, PetriNetVeryUnbalancedMassActionAnalysis, + StockFlowMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, ode_semantics::*, }; use super::*; @@ -133,15 +134,8 @@ 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 equations = StockFlowMassActionAnalysis::default() - .build_configured_system( - tab_model, - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - }, - ) + let equations = StockFlowUnbalancedMassActionAnalysis::default() + .build_system(tab_model) .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ @@ -196,15 +190,8 @@ mod tests { // 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_configured_system( - modal_model, - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - }, - ) + let equations = PetriNetUnbalancedMassActionAnalysis::default() + .build_system(modal_model) .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ @@ -218,7 +205,7 @@ mod tests { }, 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("(-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]} + \\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); @@ -229,15 +216,8 @@ mod tests { // 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_configured_system( - modal_model, - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerPlace, - ), - }, - ) + let equations = PetriNetVeryUnbalancedMassActionAnalysis::default() + .build_system(modal_model) .to_latex_equations_with_map(|param| latex_names(&model)(param)); // TODO: write down the expected equations @@ -252,7 +232,7 @@ mod tests { }, LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} 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()), + rhs: Latex("(-\\kappa_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{c} + \\rho_{[\\text{liquid}, c] \\to [\\text{solid}, c]}^{c}) \\cdot \\text{liquid} \\cdot c".to_string()), }, ]); assert_eq!(equations, expected); @@ -262,15 +242,8 @@ mod tests { fn unnamed_mor_uses_dom_cod_in_equations() { let model = backward_link("xxx", "yyy", ""); let tab_model = model.discrete_tab().unwrap(); - let equations = StockFlowMassActionAnalysis::default() - .build_configured_system( - tab_model, - MassActionEquationsConfig { - mass_conservation: ode::MassConservationType::Unbalanced( - ode::RateGranularity::PerTransition, - ), - }, - ) + let equations = StockFlowUnbalancedMassActionAnalysis::default() + .build_system(tab_model) .to_latex_equations_with_map(|param| latex_names(&model)(param)); let expected = LatexEquations(vec![ diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index c46159d81e..5bd815617e 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -345,9 +345,13 @@ impl ThCategoryLinks { &self, model: &DblModel, data: analyses::ode::ODESemanticsProblemData, + // TODO: need to have something here (in wasm) that replaces "MassConservationType" + // also something like + // GeneralMassActionProblemData = + // MassActionProblemData | PPMassActionProblemData | PTMassActionProblemData ) -> Result { let system = analyses::ode::StockFlowMassActionAnalysis::default() - .build_configured_system(model.discrete_tab()?, data.equations_config.clone()); + .build_system(model.discrete_tab()?); ode_semantics_simulation::(model, data, system) } @@ -356,10 +360,11 @@ impl ThCategoryLinks { pub fn mass_action_equations( &self, model: &DblModel, - data: analyses::ode::MassActionEquationsConfig, + // TODO: need to have something here (in wasm) that replaces "MassConservationType" + // data: analyses::ode::MassActionEquationsConfig, ) -> Result { let system = analyses::ode::StockFlowMassActionAnalysis::default() - .build_configured_system(model.discrete_tab()?, data); + .build_system(model.discrete_tab()?); ode_semantics_equations::(model, system) } } @@ -405,19 +410,15 @@ impl ThSymMonoidalCategory { data: analyses::ode::ODESemanticsProblemData, ) -> Result { let system = analyses::ode::PetriNetMassActionAnalysis::default() - .build_configured_system(model.modal_unital()?, data.equations_config.clone()); + .build_system(model.modal_unital()?); ode_semantics_simulation::(model, data, system) } /// Returns the symbolic mass-action equations in LaTeX format. #[wasm_bindgen(js_name = "massActionEquations")] - pub fn mass_action_equations( - &self, - model: &DblModel, - data: analyses::ode::MassActionEquationsConfig, - ) -> Result { + pub fn mass_action_equations(&self, model: &DblModel) -> Result { let system = analyses::ode::PetriNetMassActionAnalysis::default() - .build_configured_system(model.modal_unital()?, data); + .build_system(model.modal_unital()?); ode_semantics_equations::(model, system) } diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 6ac19d2c91..6fc5a430b7 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -57,18 +57,19 @@ 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 additional configuration data (if any) necessary for generating the system of equations. - /// For an example, see `mass_action::MassActionEquationsConfig`. - type EquationsConfigType: ODESemanticsEquationsConfig; /// The data describing the things that the ODE semantics "cares about". See the documentation /// for `ODESemanticsAnalysis` for more details. - type AnalysisType: ODESemanticsAnalysis; + type AnalysisType: ODESemanticsAnalysis; /// 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. type ParameterData: ODESemanticsScalarExtension; } +// ┌--------------┐ +// | 0. ModelType | +// └--------------┘ + /// The models for which we support ODE semantics need to be sufficiently nice, though these bounds /// should not prove particularly restrictive in practice. pub trait DblModelForODESemantics: @@ -81,6 +82,10 @@ impl DblModelForODESemantics for DiscreteTabModel {} impl DblModelForODESemantics for ModalDblModel {} impl DblModelForODESemantics for ModalDblModel {} +// ┌------------------┐ +// | 1. ParameterType | +// └------------------┘ + /// The type of the parameters in the ODE system need to be sufficiently nice, though /// (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 @@ -97,6 +102,10 @@ impl ToLatexWithMap for QualifiedName { impl ODEParameterType for QualifiedName {} +// ┌---------------------------------------┐ +// | INTERLUDE. PolynomialODESystemBuilder | +// └---------------------------------------┘ + /// Builder for polynomial ODE systems. /// /// This struct is just a convenient interface to construct a model of the theory of polynomial ODE @@ -120,6 +129,33 @@ impl Default for PolynomialODESystemBuilder

{ } } +/// 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 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 a 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, +} + impl PolynomialODESystemBuilder

{ /// Constructs an empty ODE system. pub fn new() -> Self { @@ -177,17 +213,9 @@ impl PolynomialODESystemBuilder

{ } } -/// 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 widget. For an example, see `mass_action::MassActionEquationsConfig`. -/// -/// Note this this is slightly annoying, and will lead to most ODE semantics requiring us to define -/// `EquationsConfigType = ()` and passing `_equations_config: ()` into `build_system_builder`. -/// It seems likely that we should eventually either (a) break up mass-action semantics into three -/// separate semantics, or (b) have `AdmitsEquationsConfig` as a further separate trait. -pub trait ODESemanticsEquationsConfig: Default {} -impl ODESemanticsEquationsConfig for () {} +// ┌-----------------┐ +// | 2. AnalysisType | +// └-----------------┘ /// This trait is where we define the actual ODE semantics, in the implementation of /// `build_system_builder()`, whereas `build_system()` will almost certainly always use the default @@ -198,36 +226,15 @@ impl ODESemanticsEquationsConfig for () {} /// `ObType` and `MorType` that you want to distinguish between and iterate over. However, /// this is left to the user: the type checker will *not* enforce anything helpful here. We /// recommend looking at any existing implementations to get a better understanding. -pub trait ODESemanticsAnalysis< - T: DblModelForODESemantics, - P: ODEParameterType, - E: ODESemanticsEquationsConfig, ->: Default -{ +pub trait ODESemanticsAnalysis: Default { /// 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, equations_config: E) - -> PolynomialODESystemBuilder

; + fn build_system_builder(&self, model: &T) -> PolynomialODESystemBuilder

; /// We simply feed the `PolynomialODESystemBuilder` constructed by the above function into - /// `PolynomialODEAnalysis::build_system_custom_parameters` with the *default* values of the - /// `ODESemanticsEquationsConfig` type. + /// `PolynomialODEAnalysis::build_system_custom_parameters`. fn build_system(&self, model: &T) -> PolynomialSystem, i8> { - self.build_configured_system(model, E::default()) - } - - /// We simply feed the `PolynomialODESystemBuilder` constructed by the above function into - /// `PolynomialODEAnalysis::build_system_custom_parameters` with *custom* values of the - /// `ODESemanticsEquationsConfig` type. - /// - /// Note that, if the `ODESemanticsEquationsConfig` in question is trivial, then this function - /// should essentially never be used, since `build_system` then always does the same. - fn build_configured_system( - &self, - model: &T, - equations_config: E, - ) -> PolynomialSystem, i8> { - let builder = self.build_system_builder(model, equations_config); + let builder = self.build_system_builder(model); PolynomialODEAnalysis::default().build_system_custom_parameters( &builder.clone().model(), builder.associated_parameters(), @@ -235,37 +242,14 @@ pub trait ODESemanticsAnalysis< } } -/// 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 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 a 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, -} +// ┌------------------┐ +// | 3. ParameterData | +// └------------------┘ -/// How to convert the formal parameters of type `ODEParameterType` into floats using data from -/// `ODESemanticsProblemData.parameter_data`. +/// This trait is required to be implemented for the `ParameterData` type, and is used to convert +/// the formal parameters of type `ODEParameterType` to floats. pub trait ODESemanticsScalarExtension { - /// TODO: documentation. + /// Take formal parameters and convert them into floats using problem data. fn extend_scalars( &self, system: PolynomialSystem, i8>, @@ -288,9 +272,6 @@ pub struct ODESemanticsProblemData where T: ODESemantics, { - /// Further data needed to specify the ODE equations. - #[cfg_attr(feature = "serde", serde(rename = "equationsConfig"))] - pub equations_config: T::EquationsConfigType, /// Map from object IDs to initial values (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] pub initial_values: HashMap, @@ -302,7 +283,7 @@ where } impl ODESemanticsProblemData { - /// TODO: docs. + /// Take formal parameters and convert them into floats using `self.parameter_data`. pub fn extend_scalars( &self, system: PolynomialSystem, i8>, diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs index 724f2b4006..1305989c04 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs @@ -7,7 +7,86 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "serde-wasm")] use tsify::Tsify; -use crate::{stdlib::analyses::ode::MassConservationType, zero::QualifiedName}; +use crate::zero::QualifiedName; + +/// There are three types of mass-action semantics, each more expressive than the previous: +/// - balanced +/// - unbalanced (rates per transition) +/// - unbalanced (rates per place) +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", content = "granularity"))] +#[cfg_attr(feature = "serde-wasm", derive(Tsify))] +#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +pub enum MassConservationType { + /// Mass is conserved. + Balanced, + /// Mass is not conserved. + Unbalanced(RateGranularity), +} + +/// When mass is not necessarily conserved, consumption/production rate parameters +/// can be set either *per transition* or *per place*. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +#[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 enum RateGranularity { + /// Each flow (transition) gets assigned a single consumption and single production rate. + PerTransition, + /// 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 +/// terms for `MassActionParameter`. Parameters in the generated polynomial equations +/// are *undirected* in the balanced case and *directed* in the unbalanced case. +/// Parameters for the usual ("balanced") mass-action semantics, where each flow simply has a rate. +#[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. + Balanced { + /// Since there is no direction, the rate parameter corresponds to a single transition. + flow: QualifiedName, + }, + /// If mass is not conserved, then we need to know whether a flow is incoming or outgoing. + Unbalanced { + /// The direction of the flow. + direction: Direction, + /// The structure of the rate parameter can be either per transition or per place. + parameter: RateParameter, + }, +} + +/// Depending on the rate granularity, the parameters are specified by different structures. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum RateParameter { + /// For per flow rates, we simply need to know the associated flow. + 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. + 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. + stock: QualifiedName, + }, +} + +/// The associated direction of a "flow" term. Note that this is *opposite* from +/// the terminology of "input" and "output", i.e. a flow A=>B gives rise to an +/// *incoming flow to B* and an *outgoing flow from A*. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum Direction { + /// The parameter corresponds to an incoming flow to a specific output. + IncomingFlow, + /// The parameter corresponds to an outgoing flow to a specific input. + OutgoingFlow, +} /// Data defining mass-action ODE equations for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs index 90cb688328..b5c2d3c91d 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs @@ -32,10 +32,13 @@ impl ODESemantics for LinearODESemantics { type ModelType = DiscreteDblModel; type ParameterType = LinearODEParameter; type AnalysisType = LinearODEAnalysis; - type EquationsConfigType = (); type ParameterData = LinearODEParameterData; } +// ┌------------------┐ +// | 1. ParameterType | +// └------------------┘ + /// Parameters in the linear equations correspond only to morphisms. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum LinearODEParameter { @@ -66,6 +69,10 @@ impl ToLatexWithMap for LinearODEParameter { impl ODEParameterType for LinearODEParameter {} +// ┌-----------------┐ +// | 2. AnalysisType | +// └-----------------┘ + /// Linear ODE analysis for causal loop diagrams (CLDs). pub struct LinearODEAnalysis { /// Object type for variables. @@ -91,7 +98,6 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, - ::EquationsConfigType, > for LinearODEAnalysis { /// Creates a linear system with symbolic rate coefficients. @@ -100,7 +106,6 @@ impl fn build_system_builder( &self, model: &DiscreteDblModel, - _equations_config: (), ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); @@ -149,6 +154,10 @@ impl } } +// ┌------------------┐ +// | 3. ParameterData | +// └------------------┘ + /// Data input by the user to fill in the parameters numerically. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -180,6 +189,10 @@ impl ODESemanticsScalarExtension<::Parameter } } +// ┌-------┐ +// | TESTS | +// └-------┘ + #[cfg(test)] mod test { use expect_test::expect; @@ -235,7 +248,6 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: (), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, parameter_data: LinearODEParameterData { diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs index 863844175b..366d284b90 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs @@ -32,10 +32,13 @@ impl ODESemantics for LotkaVolterraSemantics { type ModelType = DiscreteDblModel; type ParameterType = LotkaVolterraParameter; type AnalysisType = LotkaVolterraAnalysis; - type EquationsConfigType = (); type ParameterData = LotkaVolterraParameterData; } +// ┌------------------┐ +// | 1. ParameterType | +// └------------------┘ + /// Parameters in the Lotka-Volterra equations come in two flavours, corresponding to /// either variables or links. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] @@ -76,6 +79,10 @@ impl ToLatexWithMap for LotkaVolterraParameter { impl ODEParameterType for LotkaVolterraParameter {} +// ┌-----------------┐ +// | 2. AnalysisType | +// └-----------------┘ + /// This Lotka-Volterra ODE analysis is intended for application to CLDs. pub struct LotkaVolterraAnalysis { /// Object type for variables. @@ -101,7 +108,6 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, - ::EquationsConfigType, > for LotkaVolterraAnalysis { /// Creates a Lotka-Volterra system with symbolic rate coefficients. @@ -113,7 +119,6 @@ impl fn build_system_builder( &self, model: &DiscreteDblModel, - _equations_config: (), ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); @@ -174,6 +179,10 @@ impl } } +// ┌------------------┐ +// | 3. ParameterData | +// └------------------┘ + /// Data defining a Lotka-Volterra ODE problem for a model. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -213,6 +222,10 @@ impl ODESemanticsScalarExtension<::Param } } +// ┌-------┐ +// | TESTS | +// └-------┘ + #[cfg(test)] mod test { use expect_test::expect; @@ -268,7 +281,6 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: (), initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, parameter_data: LotkaVolterraParameterData { diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs index 72c4f1d1a3..286b08080c 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs @@ -30,140 +30,79 @@ use crate::{ // Because Petri nets and stock-flow diagrams are different types of models (unital modal and // discrete tabulator, respectively), we need a different structs for each one, since to implement // `ODESemantics` we need to specify a `ModelType`. In particular, they will need different -// implementations of `ODESemanticsAnalysis::build_system_builder`. +// implementations of `ODESemanticsAnalysis::build_system_builder`. Furthermore, we implement the +// corresponding "unbalanced" semantics for each. For "ease", we do all of these in this same file. +// For convenience, we briefly summarise here how these variations of mass-action are constructed. +// +// For Petri nets, 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) +// +// - two negative contributions: +// (ab -> a , ab -> b). +// +// The variations of mass-action determine the coefficients of these contributions: +// +// - In the balanced (i.e. classical) case, all four contributions will have the same coefficient. +// +// - In the unbalanced (per-transition) case, the two positive contributions will have the same +// coefficient (the "production rate" of the transition) and the two negative contributions will +// have the same coefficient (the "consumption rate" of the transition). +// +// - In the very unbalanced (per-place) case, the production (resp. consumption) rates from the +// unbalanced case are now potentially distinct, i.e. each coefficient depends on the specific +// (transition, place) pair. +// +// For stock-flow diagrams, 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. The balanced and unbalanced +// cases are analogous to those for Petri nets (by thinking of a flow as a single-input and +// single-output transition). + +// ┌-----------------------┐ +// | A. BALANCED SEMANTICS | +// └-----------------------┘ +// We start with the usual mass-action semantics, for both Petri nets and stock-flow diagrams. /// 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 EquationsConfigType = MassActionEquationsConfig; type ParameterData = MassActionParameterData; } +/// Mass-action semantics for stock-flow diagrams. +pub struct StockFlowMassActionSemantics; impl ODESemantics for StockFlowMassActionSemantics { type ModelType = DiscreteTabModel; type ParameterType = MassActionParameter; type AnalysisType = StockFlowMassActionAnalysis; - type EquationsConfigType = MassActionEquationsConfig; type ParameterData = MassActionParameterData; } -/// There are three types of mass-action semantics, each more expressive than the previous: -/// - balanced -/// - unbalanced (rates per transition) -/// - unbalanced (rates per place) -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde", serde(tag = "type", content = "granularity"))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub enum MassConservationType { - /// Mass is conserved. - Balanced, - /// Mass is not conserved. - Unbalanced(RateGranularity), -} - -/// When mass is not necessarily conserved, consumption/production rate parameters -/// can be set either *per transition* or *per place*. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -#[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 enum RateGranularity { - /// Each flow (transition) gets assigned a single consumption and single production rate. - PerTransition, - - /// Each flow (transition) gets assigned a consumption rate for each input stock (place) and - /// a production rate for each output stock (place). - PerPlace, -} +// ┌--------------------┐ +// | A.1. ParameterType | +// └--------------------┘ -/// 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. +/// Parameters for the usual ("balanced") mass-action semantics, where each flow simply has a rate. #[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. - Balanced { - /// Since there is no direction, the rate parameter corresponds to a single transition. - flow: QualifiedName, - }, - /// If mass is not conserved, then we need to know whether a flow is incoming or outgoing. - Unbalanced { - /// The direction of the flow. - direction: Direction, - /// The structure of the rate parameter can be either per transition or per place. - parameter: RateParameter, - }, -} - -/// Depending on the rate granularity, the parameters are specified by different structures. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum RateParameter { - /// For per flow rates, we simply need to know the associated flow. - 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. - PerPlace { - /// The flow whose input/output objects we wish to associate rate parameters. + /// The rate of a flow. + Rate { + /// The flow in question. flow: QualifiedName, - /// The input/output stock to which we associate the rate parameter. - stock: QualifiedName, }, } -/// The associated direction of a "flow" term. Note that this is *opposite* from -/// the terminology of "input" and "output", i.e. a flow A=>B gives rise to an -/// *incoming flow to B* and an *outgoing flow from A*. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum Direction { - /// The parameter corresponds to an incoming flow to a specific output. - IncomingFlow, - - /// The parameter corresponds to an outgoing flow to a specific input. - OutgoingFlow, -} - impl fmt::Display for MassActionParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self { - Self::Balanced { flow: trans } => { - write!(f, "{}", trans) - } - Self::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerTransition { flow: trans }, - } => { - write!(f, "Incoming({})", trans) - } - Self::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerPlace { flow: trans, stock: output }, - } => { - write!(f, "([{}]->{})", trans, output) - } - Self::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { flow: trans }, - } => { - write!(f, "Outgoing({})", trans) - } - Self::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerPlace { flow: trans, stock: input }, - } => { - write!(f, "({}->[{}])", input, trans) - } + match self { + MassActionParameter::Rate { flow } => write!(f, "Rate({})", flow), } } } @@ -171,49 +110,16 @@ impl fmt::Display for MassActionParameter { impl ToLatexWithMap for MassActionParameter { fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - MassActionParameter::Balanced { flow } => Latex(format!("r_{{{}}}", f(flow))), - MassActionParameter::Unbalanced { direction, parameter } => { - match (direction, parameter) { - (Direction::IncomingFlow, RateParameter::PerTransition { flow }) => { - Latex(format!("\\rho_{{{}}}", f(flow))) - } - (Direction::OutgoingFlow, RateParameter::PerTransition { flow }) => { - Latex(format!("\\kappa_{{{}}}", f(flow))) - } - (Direction::IncomingFlow, RateParameter::PerPlace { flow, stock }) => { - Latex(format!("\\rho_{{{}}}^{{{}}}", f(flow), f(stock))) - } - (Direction::OutgoingFlow, RateParameter::PerPlace { flow, stock }) => { - Latex(format!("\\kappa_{{{}}}^{{{}}}", f(flow), f(stock))) - } - } - } + MassActionParameter::Rate { flow } => Latex(format!("r_{{{}}}", f(flow))), } } } impl ODEParameterType for MassActionParameter {} -/// 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))] -#[derive(Clone)] -pub struct MassActionEquationsConfig { - /// Whether or not mass is conserved. - #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] - pub mass_conservation: MassConservationType, -} - -impl Default for MassActionEquationsConfig { - fn default() -> Self { - Self { - mass_conservation: MassConservationType::Balanced, - } - } -} - -impl ODESemanticsEquationsConfig for MassActionEquationsConfig {} +// ┌-------------------┐ +// | A.2. AnalysisType | +// └-------------------┘ /// Mass-action ODE analysis for Petri nets. /// @@ -240,104 +146,49 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, - ::EquationsConfigType, > for PetriNetMassActionAnalysis { fn build_system_builder( &self, model: &ModalDblModel, - equations_config: MassActionEquationsConfig, ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); + // For each place, we create a variable. for place in model.ob_generators_with_type(&self.place_ob_type) { - // For each place, we create a variable. builder.add_variable(place.clone()); } + // Each transition + // T : [x_1, ..., x_n] -> [y_1, ..., y_n] + // gives rise to the contributions + // d/dt(x_i) -= Rate(T) x_1 ... x_n + // d/dt(y_i) += Rate(T) x_1 ... x_n. for transition in model.mor_generators_with_type(&self.transition_mor_type) { 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). + let parameter = MassActionParameter::Rate { flow: transition.clone() }; 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] - // becomes the contributions - // \dot{y_i} += Parameter_! \cdot x_1...x_n - // where Parameter_! depends on `mass_conservation`: - // Balanced => Parameter_T - // Unbalanced::PerTransition => Parameter_T^inflow - // Unbalanced::PerPlace => Parameter_{T,y_i}^inflow - let parameter = match equations_config.mass_conservation { - MassConservationType::Balanced => { - MassActionParameter::Balanced { flow: transition.clone() } - } - MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerTransition => MassActionParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerTransition { flow: transition.clone() }, - }, - RateGranularity::PerPlace => MassActionParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerPlace { - flow: transition.clone(), - stock: output.clone(), - }, - }, - }, - }; - builder.add_contribution( id, output, ContributionSign::Positive, - parameter, + parameter.clone(), inputs.clone(), ); } 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] - // becomes the contributions - // \dot{x_i} -= Parameter_! \cdot x_1...x_n - // where Parameter_! depends on `mass_conservation`: - // Balanced => Parameter_T - // Unbalanced::PerTransition => Parameter_T^outflow - // Unbalanced::PerPlace => Parameter_{T,x_i}^outflow - let parameter = match equations_config.mass_conservation { - MassConservationType::Balanced => { - MassActionParameter::Balanced { flow: transition.clone() } - } - MassConservationType::Unbalanced(granularity) => match granularity { - RateGranularity::PerTransition => MassActionParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { flow: transition.clone() }, - }, - RateGranularity::PerPlace => MassActionParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerPlace { - flow: transition.clone(), - stock: input.clone(), - }, - }, - }, - }; - + let id = input.cons(name_seg("FromInput")).cons(transition.only().unwrap()); builder.add_contribution( id, input, ContributionSign::Negative, - parameter, + parameter.clone(), inputs.clone(), ); } @@ -375,76 +226,48 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, - ::EquationsConfigType, > for StockFlowMassActionAnalysis { fn build_system_builder( &self, model: &DiscreteTabModel, - equations_config: MassActionEquationsConfig, ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); + // For each stock, we create a variable. for stock in model.ob_generators_with_type(&self.stock_ob_type) { - // For each stock, we create a variable. builder.add_variable(stock.clone()); } + // The flow + // F : x -> y + // with links + // l_i : a_i -> F + // gives rise to the contributions + // d/dt(x) -= Rate(F) x a_1 ... a_n + // d/dt(y) += Rate(F) x a_1 ... a_n for flow in model.mor_generators_with_type(&self.flow_mor_type) { let interface = flow_interface(model, &flow); let (input, output) = (interface.input_stock, interface.output_stock); - // 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 parameter = MassActionParameter::Rate { flow: flow.clone() }; let monomial = [interface.input_pos_link_doms, vec![input.clone()]].concat(); - // The flow - // F : a -> b - // with links - // l_i : x_i -> F - // becomes the contributions - // \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`: - // Balanced => Parameter_! = Parameter_F - // Parameter_? = Parameter_F - // Unbalanced::PerTransition => Parameter_! = Parameter_F^inflow - // Parameter_? = Parameter_F^outflow - let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); - let output_parameter = match equations_config.mass_conservation { - MassConservationType::Balanced => { - MassActionParameter::Balanced { flow: flow.clone() } - } - MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { - direction: Direction::IncomingFlow, - parameter: RateParameter::PerTransition { flow: flow.clone() }, - }, - }; builder.add_contribution( output_id, - output.clone(), + output, ContributionSign::Positive, - output_parameter, + parameter.clone(), monomial.clone(), ); let input_id = input.cons(name_seg("ToInput")).cons(flow.only().unwrap()); - let input_parameter = match equations_config.mass_conservation { - MassConservationType::Balanced => { - MassActionParameter::Balanced { flow: flow.clone() } - } - MassConservationType::Unbalanced(_) => MassActionParameter::Unbalanced { - direction: Direction::OutgoingFlow, - parameter: RateParameter::PerTransition { flow: flow.clone() }, - }, - }; builder.add_contribution( input_id, - input.clone(), + input, ContributionSign::Negative, - input_parameter, + parameter, monomial, ); } @@ -453,6 +276,10 @@ impl } } +// ┌--------------------┐ +// | A.3. ParameterData | +// └--------------------┘ + /// Data input by the user to fill in the parameters numerically. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] @@ -461,79 +288,291 @@ impl tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] pub struct MassActionParameterData { - /// Map from morphism IDs to consumption rate coefficients (non-negative reals), - /// for the balanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionRates"))] - pub(crate) transition_rates: HashMap, + /// Map from morphism IDs to consumption rate coefficients (non-negative reals). + pub(crate) rates: HashMap, +} + +impl ODESemanticsScalarExtension for MassActionParameterData { + fn extend_scalars( + &self, + sys: PolynomialSystem, i8>, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|flow| match flow { + MassActionParameter::Rate { flow } => { + self.rates.get(flow).cloned().unwrap_or_default() + } + }) + }); + + sys.normalize() + } +} - /// Map from morphism IDs to consumption rate coefficients (non-negative reals), - /// for the unbalanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionConsumptionRates"))] - pub(crate) transition_consumption_rates: HashMap, +// ┌-------------------------┐ +// | B. UNBALANCED SEMANTICS | +// └-------------------------┘ - /// Map from morphism IDs to production rate coefficients (non-negative reals), - /// for the unbalanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionProductionRates"))] - pub(crate) transition_production_rates: HashMap, +// ┌--------------------┐ +// | B.1. ParameterType | +// └--------------------┘ - /// Map from morphism IDs to (map from input objects to consumption rate coefficients), - /// for the unbalanced per place case (non-negative reals). - #[cfg_attr(feature = "serde", serde(rename = "placeConsumptionRates"))] - pub(crate) place_consumption_rates: HashMap>, +/// Unbalanced ("per-transition") mass-action semantics for Petri nets. +pub struct PetriNetUnbalancedMassActionSemantics; +impl ODESemantics for PetriNetUnbalancedMassActionSemantics { + type ModelType = ModalDblModel; + type ParameterType = UnbalancedMassActionParameter; + type AnalysisType = PetriNetUnbalancedMassActionAnalysis; + type ParameterData = UnbalancedMassActionParameterData; +} - /// Map from morphism IDs to (map from output objects to production rate coefficients), - /// for the unbalanced per place case (non-negative reals). - #[cfg_attr(feature = "serde", serde(rename = "placeProductionRates"))] - pub(crate) place_production_rates: HashMap>, +/// Unbalanced ("per-flow") mass-action semantics for stock-flow diagrams. +pub struct StockFlowUnbalancedMassActionSemantics; +impl ODESemantics for StockFlowUnbalancedMassActionSemantics { + type ModelType = DiscreteTabModel; + type ParameterType = UnbalancedMassActionParameter; + type AnalysisType = StockFlowUnbalancedMassActionAnalysis; + type ParameterData = UnbalancedMassActionParameterData; } -impl ODESemanticsScalarExtension for MassActionParameterData { +/// Parameters for unbalanced ("per-flow") mass-action semantics, where each flow has two associated +/// rates: its *consumption* (affecting its inputs) and its *production* (affecting its outputs). +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum UnbalancedMassActionParameter { + /// The consumption parameter associated to all inputs to a flow. + Consumption { + /// The flow in question. + flow: QualifiedName, + }, + /// The production parameter associated to all inputs to a flow. + Production { + /// The flow in question. + flow: QualifiedName, + }, +} + +impl fmt::Display for UnbalancedMassActionParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UnbalancedMassActionParameter::Consumption { flow } => { + write!(f, "Consumption({})", flow) + } + UnbalancedMassActionParameter::Production { flow } => { + write!(f, "Production({})", flow) + } + } + } +} + +impl ToLatexWithMap for UnbalancedMassActionParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { + match self { + UnbalancedMassActionParameter::Consumption { flow } => { + Latex(format!("\\kappa_{{{}}}", f(flow))) + } + UnbalancedMassActionParameter::Production { flow } => { + Latex(format!("\\rho_{{{}}}", f(flow))) + } + } + } +} + +impl ODEParameterType for UnbalancedMassActionParameter {} + +// ┌-------------------┐ +// | B.2. AnalysisType | +// └-------------------┘ + +/// Unbalanced ("per-transition") mass-action ODE analysis for Petri nets. +pub struct PetriNetUnbalancedMassActionAnalysis { + /// Object type for places. + pub place_ob_type: ModalObType, + /// Morphism type for transitions. + pub transition_mor_type: ModalMorType, +} + +impl Default for PetriNetUnbalancedMassActionAnalysis { + fn default() -> Self { + let ob_type = ModalObType::new(name("Object")); + Self { + place_ob_type: ob_type.clone(), + transition_mor_type: ModalMorType::Zero(ob_type), + } + } +} + +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for PetriNetUnbalancedMassActionAnalysis +{ + fn build_system_builder( + &self, + model: &ModalDblModel, + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); + + // For each place, we create a variable. + for place in model.ob_generators_with_type(&self.place_ob_type) { + builder.add_variable(place.clone()); + } + + // Each transition + // T : [x_1, ..., x_n] -> [y_1, ..., y_n] + // gives rise to the contributions + // d/dt(x_i) -= Consumption(T) x_1 ... x_n + // d/dt(y_i) += Production(T) x_1 ... x_n. + for transition in model.mor_generators_with_type(&self.transition_mor_type) { + 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()); + let output_parameter = + UnbalancedMassActionParameter::Production { flow: transition.clone() }; + builder.add_contribution( + id, + output, + ContributionSign::Positive, + output_parameter, + inputs.clone(), + ); + } + + for input in inputs.clone() { + let id = input.cons(name_seg("FromInput")).cons(transition.only().unwrap()); + let input_parameter = + UnbalancedMassActionParameter::Consumption { flow: transition.clone() }; + builder.add_contribution( + id, + input, + ContributionSign::Negative, + input_parameter.clone(), + inputs.clone(), + ); + } + } + + builder + } +} + +/// Unbalanced ("per-flow") mass-action ODE analysis for stock-flow models. +pub struct StockFlowUnbalancedMassActionAnalysis { + /// Object type for stocks. + pub stock_ob_type: TabObType, + /// Morphism type for flows between stocks. + pub flow_mor_type: TabMorType, + /// Morphism type for positive links from stocks to flows. + pub pos_link_mor_type: TabMorType, + /// Morphism type for negative links from stocks to flows. + pub neg_link_mor_type: TabMorType, +} + +impl Default for StockFlowUnbalancedMassActionAnalysis { + fn default() -> Self { + let ob_type = TabObType::Basic(name("Object")); + Self { + 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")), + } + } +} + +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for StockFlowUnbalancedMassActionAnalysis +{ + fn build_system_builder( + &self, + model: &DiscreteTabModel, + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); + + // For each stock, we create a variable. + for stock in model.ob_generators_with_type(&self.stock_ob_type) { + builder.add_variable(stock.clone()); + } + + // The flow + // F : x -> y + // with links + // l_i : a_i -> F + // gives rise to the contributions + // d/dt(x) -= Consumption(F) x a_1 ... a_n + // d/dt(y) += Production(F) x a_1 ... a_n + for flow in model.mor_generators_with_type(&self.flow_mor_type) { + let interface = flow_interface(model, &flow); + let (input, output) = (interface.input_stock, interface.output_stock); + + let monomial = [interface.input_pos_link_doms, vec![input.clone()]].concat(); + + let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); + let output_parameter = UnbalancedMassActionParameter::Production { flow: flow.clone() }; + builder.add_contribution( + output_id, + output, + ContributionSign::Positive, + output_parameter, + monomial.clone(), + ); + + let input_id = input.cons(name_seg("ToInput")).cons(flow.only().unwrap()); + let input_parameter = UnbalancedMassActionParameter::Consumption { flow: flow.clone() }; + builder.add_contribution( + input_id, + input, + ContributionSign::Negative, + input_parameter, + monomial, + ); + } + + builder + } +} + +// ┌--------------------┐ +// | B.3. ParameterData | +// └--------------------┘ + +/// Data input by the user to fill in the parameters numerically. +#[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 UnbalancedMassActionParameterData { + /// Map from morphism IDs to consumption rate coefficients (non-negative reals). + #[cfg_attr(feature = "serde", serde(rename = "consumptionRates"))] + pub(crate) consumption_rates: HashMap, + + /// Map from morphism IDs to production rate coefficients (non-negative reals). + #[cfg_attr(feature = "serde", serde(rename = "productionRates"))] + pub(crate) production_rates: HashMap, +} + +impl ODESemanticsScalarExtension + for UnbalancedMassActionParameterData +{ fn extend_scalars( &self, - sys: PolynomialSystem, i8>, + 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() + UnbalancedMassActionParameter::Consumption { flow } => { + self.consumption_rates.get(flow).cloned().unwrap_or_default() } - 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::PerPlace { flow: transition, stock: place }, - ) => self - .place_production_rates - .get(transition) - .and_then(|rate| rate.get(place)) - .copied() - .unwrap_or_default(), - ( - Direction::OutgoingFlow, - RateParameter::PerPlace { flow: transition, stock: place }, - ) => self - .place_consumption_rates - .get(transition) - .and_then(|rate| rate.get(place)) - .copied() - .unwrap_or_default(), - } + UnbalancedMassActionParameter::Production { flow } => { + self.production_rates.get(flow).cloned().unwrap_or_default() } }) }); @@ -542,13 +581,215 @@ impl ODESemanticsScalarExtension for MassActionParameterDat } } +// ┌------------------------------┐ +// | C. VERY UNBALANCED SEMANTICS | +// └------------------------------┘ + +/// Unbalanced ("per-place") mass-action semantics for Petri nets. +pub struct PetriNetVeryUnbalancedMassActionSemantics; +impl ODESemantics for PetriNetVeryUnbalancedMassActionSemantics { + type ModelType = ModalDblModel; + type ParameterType = VeryUnbalancedMassActionParameter; + type AnalysisType = PetriNetVeryUnbalancedMassActionAnalysis; + type ParameterData = VeryUnbalancedMassActionParameterData; +} + +// ┌--------------------┐ +// | C.1. ParameterType | +// └--------------------┘ + +/// Parameters for very unbalanced ("per-place") mass-action semantics, where each transition has +/// individual consumption (resp. production) rates for each of its input (resp. output) stock. Note +/// that this only makes sense for Petri nets, not stock-flow diagrams. +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum VeryUnbalancedMassActionParameter { + /// The consumption parameter associated to a specific input stock to a transition. + Consumption { + /// The transition in question. + transition: QualifiedName, + /// The input stock in question. + input_place: QualifiedName, + }, + /// The production parameter associated to a specific output stock to a transition. + Production { + /// The transition in question. + transition: QualifiedName, + /// The output stock. + output_place: QualifiedName, + }, +} + +impl fmt::Display for VeryUnbalancedMassActionParameter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => { + write!(f, "Consumption([{}] <- {})", transition, input_place) + } + VeryUnbalancedMassActionParameter::Production { transition, output_place } => { + write!(f, "Production([{}] -> {})", transition, output_place) + } + } + } +} + +impl ToLatexWithMap for VeryUnbalancedMassActionParameter { + fn to_latex_with_map String>(&self, f: T) -> Latex { + match self { + VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => { + Latex(format!("\\kappa_{{{}}}^{{{}}}", f(transition), f(input_place))) + } + VeryUnbalancedMassActionParameter::Production { transition, output_place } => { + Latex(format!("\\rho_{{{}}}^{{{}}}", f(transition), f(output_place))) + } + } + } +} + +impl ODEParameterType for VeryUnbalancedMassActionParameter {} + +// ┌-------------------┐ +// | C.2. AnalysisType | +// └-------------------┘ + +/// Unbalanced ("per-transition") mass-action ODE analysis for Petri nets. +pub struct PetriNetVeryUnbalancedMassActionAnalysis { + /// Object type for places. + pub place_ob_type: ModalObType, + /// Morphism type for transitions. + pub transition_mor_type: ModalMorType, +} + +impl Default for PetriNetVeryUnbalancedMassActionAnalysis { + fn default() -> Self { + let ob_type = ModalObType::new(name("Object")); + Self { + place_ob_type: ob_type.clone(), + transition_mor_type: ModalMorType::Zero(ob_type), + } + } +} + +impl + ODESemanticsAnalysis< + ::ModelType, + ::ParameterType, + > for PetriNetVeryUnbalancedMassActionAnalysis +{ + fn build_system_builder( + &self, + model: &ModalDblModel, + ) -> PolynomialODESystemBuilder { + let mut builder = PolynomialODESystemBuilder::new(); + + // For each place, we create a variable. + for place in model.ob_generators_with_type(&self.place_ob_type) { + builder.add_variable(place.clone()); + } + + // Each transition + // T : [x_1, ..., x_n] -> [y_1, ..., y_n] + // gives rise to the contributions + // d/dt(x_i) -= Consumption(T <- x_i) x_1 ... x_n + // d/dt(y_i) += Production(T -> y_i) x_1 ... x_n. + for transition in model.mor_generators_with_type(&self.transition_mor_type) { + 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()); + let output_parameter = VeryUnbalancedMassActionParameter::Production { + transition: transition.clone(), + output_place: output.clone(), + }; + builder.add_contribution( + id, + output, + ContributionSign::Positive, + output_parameter, + inputs.clone(), + ); + } + + for input in inputs.clone() { + let id = input.cons(name_seg("FromInput")).cons(transition.only().unwrap()); + let input_parameter = VeryUnbalancedMassActionParameter::Consumption { + transition: transition.clone(), + input_place: input.clone(), + }; + builder.add_contribution( + id, + input, + ContributionSign::Negative, + input_parameter, + inputs.clone(), + ); + } + } + + builder + } +} + +// ┌--------------------┐ +// | C.3. ParameterData | +// └--------------------┘ + +/// Data input by the user to fill in the parameters numerically. +#[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 VeryUnbalancedMassActionParameterData { + /// Map from morphism IDs to (map from input objects to consumption rate coefficients). + #[cfg_attr(feature = "serde", serde(rename = "consumptionRates"))] + pub(crate) consumption_rates: HashMap>, + + /// Map from morphism IDs to (map from input objects to consumption rate coefficients). + #[cfg_attr(feature = "serde", serde(rename = "productionRates"))] + pub(crate) production_rates: HashMap>, +} + +impl ODESemanticsScalarExtension + for VeryUnbalancedMassActionParameterData +{ + fn extend_scalars( + &self, + sys: PolynomialSystem, i8>, + ) -> PolynomialSystem { + let sys = sys.extend_scalars(|poly| { + poly.eval(|flow| match flow { + VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => self + .consumption_rates + .get(transition) + .and_then(|rate| rate.get(input_place)) + .copied() + .unwrap_or_default(), + VeryUnbalancedMassActionParameter::Production { transition, output_place } => self + .production_rates + .get(transition) + .and_then(|rate| rate.get(output_place)) + .copied() + .unwrap_or_default(), + }) + }); + + sys.normalize() + } +} + +// ┌-------┐ +// | TESTS | +// └-------┘ + #[cfg(test)] mod tests { use expect_test::expect; use std::rc::Rc; use super::*; - use crate::stdlib::analyses::ode::{MassConservationType, RateGranularity}; use crate::{ latex::{LatexEquation, LatexEquations}, stdlib::{models::*, theories::*}, @@ -564,8 +805,8 @@ mod tests { let model = backward_link(th); let sys = StockFlowMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" - dx = -f x y - dy = f x y + dx = -Rate(f) x y + dy = Rate(f) x y "#]); expected.assert_eq(&sys.to_string()); } @@ -574,15 +815,10 @@ mod tests { fn unbalanced_stock_flow() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let sys = StockFlowMassActionAnalysis::default().build_configured_system( - &model, - MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), - }, - ); + let sys = StockFlowUnbalancedMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" - dx = -Outgoing(f) x y - dy = Incoming(f) x y + dx = -Consumption(f) x y + dy = Production(f) x y "#]); expected.assert_eq(&sys.to_string()); } @@ -595,8 +831,8 @@ mod tests { let model = catalyzed_reaction(th); let sys = PetriNetMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" - dx = -f c x - dy = f c x + dx = -Rate(f) c x + dy = Rate(f) c x dc = 0 "#]); expected.assert_eq(&sys.to_string()); @@ -606,16 +842,11 @@ 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_configured_system( - &model, - MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), - }, - ); + let sys = PetriNetUnbalancedMassActionAnalysis::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 + dx = -Consumption(f) c x + dy = Production(f) c x + dc = (-Consumption(f) + Production(f)) c x "#]); expected.assert_eq(&sys.to_string()); } @@ -624,16 +855,11 @@ 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_configured_system( - &model, - MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerPlace), - }, - ); + let sys = PetriNetVeryUnbalancedMassActionAnalysis::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 + dx = -Consumption([f] <- x) c x + dy = Production([f] -> y) c x + dc = (-Consumption([f] <- c) + Production([f] -> c)) c x "#]); expected.assert_eq(&sys.to_string()); } @@ -647,15 +873,10 @@ mod tests { let th = Rc::new(th_category_links()); let model = backward_link(th); let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig::default(), initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), duration: 10.0, parameter_data: MassActionParameterData { - transition_rates: [(name("f"), 2.0)].into_iter().collect(), - transition_consumption_rates: HashMap::new(), - transition_production_rates: HashMap::new(), - place_consumption_rates: HashMap::new(), - place_production_rates: HashMap::new(), + rates: [(name("f"), 2.0)].into_iter().collect(), }, }; let sys = StockFlowMassActionAnalysis::default().build_system(&model); @@ -671,22 +892,16 @@ mod tests { fn unbalanced_stock_flow_numerical() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), - }, - initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), - duration: 10.0, - parameter_data: MassActionParameterData { - transition_rates: HashMap::new(), - transition_consumption_rates: [(name("f"), 1.5)].into_iter().collect(), - transition_production_rates: [(name("f"), 2.0)].into_iter().collect(), - place_consumption_rates: HashMap::new(), - place_production_rates: HashMap::new(), - }, - }; - let sys = StockFlowMassActionAnalysis::default() - .build_configured_system(&model, data.equations_config.clone()); + let data: ODESemanticsProblemData = + ODESemanticsProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), + duration: 10.0, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: [(name("f"), 1.5)].into_iter().collect(), + production_rates: [(name("f"), 2.0)].into_iter().collect(), + }, + }; + let sys = StockFlowUnbalancedMassActionAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" dx = -1.5 x y @@ -700,21 +915,15 @@ mod tests { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig::default(), initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] .into_iter() .collect(), duration: 10.0, parameter_data: MassActionParameterData { - transition_rates: [(name("f"), 1.5)].into_iter().collect(), - transition_consumption_rates: HashMap::new(), - transition_production_rates: HashMap::new(), - place_consumption_rates: HashMap::new(), - place_production_rates: HashMap::new(), + rates: [(name("f"), 1.5)].into_iter().collect(), }, }; - let sys = PetriNetMassActionAnalysis::default() - .build_configured_system(&model, data.equations_config.clone()); + let sys = PetriNetMassActionAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" dx = -1.5 c x @@ -728,24 +937,18 @@ mod tests { fn unbalanced_petri_per_transition_numerical() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), - }, - initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] - .into_iter() - .collect(), - duration: 10.0, - parameter_data: MassActionParameterData { - transition_rates: HashMap::new(), - transition_consumption_rates: [(name("f"), 3.5)].into_iter().collect(), - transition_production_rates: [(name("f"), 4.0)].into_iter().collect(), - place_consumption_rates: HashMap::new(), - place_production_rates: HashMap::new(), - }, - }; - let sys = PetriNetMassActionAnalysis::default() - .build_configured_system(&model, data.equations_config.clone()); + let data: ODESemanticsProblemData = + ODESemanticsProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: [(name("f"), 3.5)].into_iter().collect(), + production_rates: [(name("f"), 4.0)].into_iter().collect(), + }, + }; + let sys = PetriNetUnbalancedMassActionAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" dx = -3.5 c x @@ -757,37 +960,30 @@ mod tests { #[test] fn unbalanced_petri_per_place_numerical() { - // TODO: finish let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerPlace), - }, - initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] - .into_iter() - .collect(), - duration: 10.0, - parameter_data: MassActionParameterData { - transition_rates: HashMap::new(), - transition_consumption_rates: HashMap::new(), - transition_production_rates: HashMap::new(), - place_consumption_rates: [( - name("f"), - [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), - )] - .into_iter() - .collect(), - place_production_rates: [( - name("f"), - [(name("y"), 1.5), (name("c"), 2.5)].into_iter().collect(), - )] - .into_iter() - .collect(), - }, - }; - let sys = PetriNetMassActionAnalysis::default() - .build_configured_system(&model, data.equations_config.clone()); + let data: ODESemanticsProblemData = + ODESemanticsProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + parameter_data: VeryUnbalancedMassActionParameterData { + consumption_rates: [( + name("f"), + [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), + )] + .into_iter() + .collect(), + production_rates: [( + name("f"), + [(name("y"), 1.5), (name("c"), 2.5)].into_iter().collect(), + )] + .into_iter() + .collect(), + }, + }; + let sys = PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); let analysis = data.extend_scalars(sys); let expected = expect!([r#" dx = -2 c x @@ -802,12 +998,7 @@ mod tests { fn to_latex() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let sys = StockFlowMassActionAnalysis::default().build_configured_system( - &model, - MassActionEquationsConfig { - mass_conservation: MassConservationType::Unbalanced(RateGranularity::PerTransition), - }, - ); + let sys = StockFlowUnbalancedMassActionAnalysis::default().build_system(&model); let expected = LatexEquations(vec![ LatexEquation { lhs: Latex("\\frac{\\mathrm{d}}{\\mathrm{d}t} x".to_string()), diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs index 702b2d688c..e437a00675 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs @@ -1,14 +1,21 @@ //! Migrations from v0 to v1 for analyses. +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +#[cfg(feature = "serde-wasm")] +use tsify::Tsify; + use crate::stdlib::analyses::ode::v0::linear_ode::LinearODEProblemData; use crate::stdlib::analyses::ode::v0::lotka_volterra::LotkaVolterraProblemData; use crate::stdlib::analyses::ode::v0::mass_action::MassActionProblemData; use crate::stdlib::analyses::ode::v0::polynomial_ode::PolynomialODEProblemData; use crate::stdlib::analyses::ode::{ LinearODEParameterData, LinearODESemantics, LotkaVolterraParameterData, LotkaVolterraSemantics, - MassActionEquationsConfig, MassActionParameterData, ODESemanticsProblemData, - PetriNetMassActionSemantics, PolynomialODEParameterData, PolynomialODESemantics, - StockFlowMassActionSemantics, + MassActionParameterData, ODESemanticsProblemData, PetriNetMassActionSemantics, + PetriNetUnbalancedMassActionSemantics, PetriNetVeryUnbalancedMassActionSemantics, + PolynomialODEParameterData, PolynomialODESemantics, StockFlowMassActionSemantics, + StockFlowUnbalancedMassActionSemantics, UnbalancedMassActionParameterData, + VeryUnbalancedMassActionParameterData, }; /// Migration for problem data for linear ODE. @@ -16,7 +23,6 @@ pub fn migrate_linear_ode_v0_to_v1( v0: LinearODEProblemData, ) -> ODESemanticsProblemData { let v1: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: (), initial_values: v0.initial_values, duration: v0.duration, parameter_data: LinearODEParameterData { coefficients: v0.coefficients }, @@ -29,7 +35,6 @@ pub fn migrate_lotka_volterra_v0_to_v1( v0: LotkaVolterraProblemData, ) -> ODESemanticsProblemData { let v1: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: (), initial_values: v0.initial_values, duration: v0.duration, parameter_data: LotkaVolterraParameterData { @@ -45,7 +50,6 @@ pub fn migrate_polynomial_ode_v0_to_v1( v0: PolynomialODEProblemData, ) -> ODESemanticsProblemData { let v1: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: (), initial_values: v0.initial_values, duration: v0.duration, parameter_data: PolynomialODEParameterData { coefficients: v0.coefficients }, @@ -53,60 +57,101 @@ pub fn migrate_polynomial_ode_v0_to_v1( v1 } -/// Migration for problem data for mass-action. +/// In order for `migrate_petri_net_mass_action_v0_to_v1` to have a well-defined return type, we +/// unify the three mass-action semantics for Petri nets into a single struct. +#[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 PetriNetMassActionProblemData { + balanced: ODESemanticsProblemData, + unbalanced: ODESemanticsProblemData, + very_unbalanced: ODESemanticsProblemData, +} + +/// Migration for problem data for mass-action on a Petri net. pub fn migrate_petri_net_mass_action_v0_to_v1( v0: MassActionProblemData, -) -> ODESemanticsProblemData { - let v1: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig { - mass_conservation: v0.equations_data.mass_conservation_type, - }, - initial_values: v0.initial_values, +) -> PetriNetMassActionProblemData { + let balanced: ODESemanticsProblemData = ODESemanticsProblemData { + initial_values: v0.initial_values.clone(), duration: v0.duration, - parameter_data: MassActionParameterData { - transition_rates: v0.transition_rates, - transition_consumption_rates: v0.transition_consumption_rates, - transition_production_rates: v0.transition_production_rates, - place_consumption_rates: v0.place_consumption_rates, - place_production_rates: v0.place_production_rates, - }, + parameter_data: MassActionParameterData { rates: v0.transition_rates }, }; - v1 + let unbalanced: ODESemanticsProblemData = + ODESemanticsProblemData { + initial_values: v0.initial_values.clone(), + duration: v0.duration, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: v0.transition_consumption_rates, + production_rates: v0.transition_production_rates, + }, + }; + let very_unbalanced: ODESemanticsProblemData = + ODESemanticsProblemData { + initial_values: v0.initial_values, + duration: v0.duration, + parameter_data: VeryUnbalancedMassActionParameterData { + consumption_rates: v0.place_consumption_rates, + production_rates: v0.place_production_rates, + }, + }; + + PetriNetMassActionProblemData { balanced, unbalanced, very_unbalanced } +} + +/// In order for `migrate_stock_flow_mass_action_v0_to_v1` to have a well-defined return type, we +/// unify the three mass-action semantics for stock-flow diagrams into a single struct. +#[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 StockFlowMassActionProblemData { + balanced: ODESemanticsProblemData, + unbalanced: ODESemanticsProblemData, } -/// Migration for problem data for mass-action. +/// Migration for problem data for mass-action on a stock-flow diagram. pub fn migrate_stock_flow_mass_action_v0_to_v1( v0: MassActionProblemData, -) -> ODESemanticsProblemData { - let v1: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: MassActionEquationsConfig { - mass_conservation: v0.equations_data.mass_conservation_type, - }, - initial_values: v0.initial_values, +) -> StockFlowMassActionProblemData { + let balanced: ODESemanticsProblemData = ODESemanticsProblemData { + initial_values: v0.initial_values.clone(), duration: v0.duration, - parameter_data: MassActionParameterData { - transition_rates: v0.transition_rates, - transition_consumption_rates: v0.transition_consumption_rates, - transition_production_rates: v0.transition_production_rates, - place_consumption_rates: v0.place_consumption_rates, - place_production_rates: v0.place_production_rates, - }, + parameter_data: MassActionParameterData { rates: v0.transition_rates }, }; - v1 + let unbalanced: ODESemanticsProblemData = + ODESemanticsProblemData { + initial_values: v0.initial_values.clone(), + duration: v0.duration, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: v0.transition_consumption_rates, + production_rates: v0.transition_production_rates, + }, + }; + StockFlowMassActionProblemData { balanced, unbalanced } } #[cfg(test)] mod test { - use std::rc::Rc; + use std::{collections::HashMap, rc::Rc}; use super::*; use crate::{ stdlib::{ analyses::ode::{ LinearODEAnalysis, LotkaVolterraAnalysis, ODESemanticsAnalysis, - PolynomialODEAnalysis, + PetriNetMassActionAnalysis, PetriNetUnbalancedMassActionAnalysis, + PetriNetVeryUnbalancedMassActionAnalysis, PolynomialODEAnalysis, + StockFlowMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, + v0::mass_action::MassActionEquationsData, }, - negative_feedback, th_polynomial_ode_system, th_signed_category, + backward_link, catalyzed_reaction, negative_feedback, th_category_links, + th_polynomial_ode_system, th_signed_category, th_sym_monoidal_category, unsigned_lotka_volterra_dynamics, }, zero::name, @@ -126,8 +171,8 @@ mod test { let v1_data = migrate_linear_ode_v0_to_v1(v0_data); - let sys = LinearODEAnalysis::default().build_system(&model); - let analysis = v1_data.extend_scalars(sys); + let system = LinearODEAnalysis::default().build_system(&model); + let analysis = v1_data.extend_scalars(system); let expected = expect!([r#" dx = -2 y dy = 3 x @@ -151,8 +196,8 @@ mod test { let v1_data = migrate_lotka_volterra_v0_to_v1(v0_data); - let sys = LotkaVolterraAnalysis::default().build_system(&model); - let analysis = v1_data.extend_scalars(sys); + let system = LotkaVolterraAnalysis::default().build_system(&model); + let analysis = v1_data.extend_scalars(system); let expected = expect!([r#" dx = 2 x - x y dy = x y - y @@ -185,8 +230,8 @@ mod test { let v1_data = migrate_polynomial_ode_v0_to_v1(v0_data); - let sys = PolynomialODEAnalysis::default().build_system(&model); - let analysis = v1_data.extend_scalars(sys); + let system = PolynomialODEAnalysis::default().build_system(&model); + let analysis = v1_data.extend_scalars(system); let expected = expect!([r#" dA = A - 2 A B dB = 1.5 A B + 2 B - 3 B C @@ -195,5 +240,110 @@ mod test { expected.assert_eq(&analysis.to_string()); } - // TODO: mass-action migration tests. + // TODO: Petri net mass-action migration tests. + + #[test] + fn petri_net_mass_action_v0_to_v1_migration() { + let th = Rc::new(th_sym_monoidal_category()); + let model = catalyzed_reaction(th); + + let v0_data = MassActionProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + equations_data: MassActionEquationsData { + mass_conservation_type: + crate::stdlib::analyses::ode::v0::mass_action::MassConservationType::Balanced, + }, + transition_rates: [(name("f"), 1.5)].into_iter().collect(), + transition_consumption_rates: [(name("f"), 3.5)].into_iter().collect(), + transition_production_rates: [(name("f"), 4.0)].into_iter().collect(), + place_consumption_rates: [( + name("f"), + [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), + )] + .into_iter() + .collect(), + place_production_rates: [( + name("f"), + [(name("y"), 1.5), (name("c"), 2.5)].into_iter().collect(), + )] + .into_iter() + .collect(), + }; + + let v1_data = migrate_petri_net_mass_action_v0_to_v1(v0_data); + + let balanced_system = PetriNetMassActionAnalysis::default().build_system(&model); + let balanced_analysis = v1_data.balanced.extend_scalars(balanced_system); + let balanced_expected = expect!([r#" + dx = -1.5 c x + dy = 1.5 c x + dc = 0 + "#]); + balanced_expected.assert_eq(&balanced_analysis.to_string()); + + let unbalanced_system = + PetriNetUnbalancedMassActionAnalysis::default().build_system(&model); + let unbalanced_analysis = v1_data.unbalanced.extend_scalars(unbalanced_system); + let unbalanced_expected = expect!([r#" + dx = -3.5 c x + dy = 4 c x + dc = 0.5 c x + "#]); + unbalanced_expected.assert_eq(&unbalanced_analysis.to_string()); + + let very_unbalanced_system = + PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); + let very_unbalanced_analysis = + v1_data.very_unbalanced.extend_scalars(very_unbalanced_system); + let very_unbalanced_expected = expect!([r#" + dx = -2 c x + dy = 1.5 c x + dc = -0.5 c x + "#]); + very_unbalanced_expected.assert_eq(&very_unbalanced_analysis.to_string()); + } + + #[test] + fn stock_flow_mass_action_v0_to_v1_migration() { + let th = Rc::new(th_category_links()); + let model = backward_link(th); + + let v0_data = MassActionProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), + duration: 10.0, + equations_data: MassActionEquationsData { + mass_conservation_type: + crate::stdlib::analyses::ode::v0::mass_action::MassConservationType::Balanced, + }, + transition_rates: [(name("f"), 3.0)].into_iter().collect(), + transition_consumption_rates: [(name("f"), 1.5)].into_iter().collect(), + transition_production_rates: [(name("f"), 2.0)].into_iter().collect(), + place_consumption_rates: HashMap::new(), + place_production_rates: HashMap::new(), + }; + + let v1_data = migrate_stock_flow_mass_action_v0_to_v1(v0_data); + + let balanced_system = StockFlowMassActionAnalysis::default().build_system(&model); + let balanced_analysis = v1_data.balanced.extend_scalars(balanced_system); + + let unbalanced_system = + StockFlowUnbalancedMassActionAnalysis::default().build_system(&model); + let unbalanced_analysis = v1_data.unbalanced.extend_scalars(unbalanced_system); + + let expected_balanced = expect!([r#" + dx = -3 x y + dy = 3 x y + "#]); + expected_balanced.assert_eq(&balanced_analysis.to_string()); + + let expected_unbalanced = expect!([r#" + dx = -1.5 x y + dy = 2 x y + "#]); + expected_unbalanced.assert_eq(&unbalanced_analysis.to_string()); + } } diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs index d594d2ff71..4fe62d3d6a 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs @@ -41,10 +41,13 @@ impl ODESemantics for PolynomialODESemantics { type ModelType = ModalDblModel; type ParameterType = PolynomialODEParameter; type AnalysisType = PolynomialODEAnalysis; - type EquationsConfigType = (); type ParameterData = PolynomialODEParameterData; } +// ┌------------------┐ +// | 1. ParameterType | +// └------------------┘ + /// Parameters come precisely from contributions. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum PolynomialODEParameter { @@ -71,6 +74,10 @@ impl ToLatexWithMap for PolynomialODEParameter { impl ODEParameterType for PolynomialODEParameter {} +// ┌-----------------┐ +// | 2. AnalysisType | +// └-----------------┘ + /// Polynomial ODE analysis. /// /// The "canonical" analysis for system of polynomial ODEs, namely interpreting @@ -103,13 +110,11 @@ impl ODESemanticsAnalysis< ::ModelType, ::ParameterType, - ::EquationsConfigType, > for PolynomialODEAnalysis { fn build_system_builder( &self, model: &ModalDblModel, - _equations_config: (), ) -> PolynomialODESystemBuilder { PolynomialODESystemBuilder::identity(model.clone()) } @@ -210,6 +215,10 @@ impl PolynomialODEAnalysis { } } +// ┌------------------┐ +// | 3. ParameterData | +// └------------------┘ + /// 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))] @@ -245,6 +254,10 @@ impl ODESemanticsScalarExtension<::Param } } +// ┌-------┐ +// | TESTS | +// └-------┘ + #[cfg(test)] mod tests { use expect_test::expect; @@ -277,7 +290,6 @@ mod tests { let th = Rc::new(th_polynomial_ode_system()); let model = unsigned_lotka_volterra_dynamics(th); let data: ODESemanticsProblemData = ODESemanticsProblemData { - equations_config: (), initial_values: [(name("a"), 1.0), (name("b"), 1.0), (name("c"), 1.0)] .into_iter() .collect(), From 94cebe53602dd4acb195e58bab0e6bcfd950039e Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 21 Aug 2026 15:32:57 +0100 Subject: [PATCH 04/83] WIP: Remove generics --- packages/catlog-wasm/src/analyses.rs | 26 +- packages/catlog-wasm/src/latex.rs | 9 +- packages/catlog-wasm/src/theories.rs | 103 ++--- .../src/stdlib/analyses/ode/ode_semantics.rs | 29 +- .../src/stdlib/analyses/ode/v0/linear_ode.rs | 15 - .../stdlib/analyses/ode/v0/lotka_volterra.rs | 17 - .../src/stdlib/analyses/ode/v0/mass_action.rs | 33 +- .../stdlib/analyses/ode/v0/polynomial_ode.rs | 14 - .../src/stdlib/analyses/ode/v1/linear_ode.rs | 41 +- .../stdlib/analyses/ode/v1/lotka_volterra.rs | 41 +- .../src/stdlib/analyses/ode/v1/mass_action.rs | 377 ++++++++++++------ .../src/stdlib/analyses/ode/v1/migrate.rs | 207 +++++----- .../stdlib/analyses/ode/v1/polynomial_ode.rs | 46 ++- packages/frontend/src/analysis/document.ts | 1 + packages/frontend/src/stdlib/analyses.tsx | 86 ++-- .../src/stdlib/analyses/mass_action.tsx | 127 ++++-- .../analyses/mass_action_config_form.tsx | 91 +++-- .../stdlib/analyses/mass_action_equations.tsx | 8 +- .../src/stdlib/analyses/simulator_types.ts | 17 +- .../frontend/src/stdlib/theories/petri-net.ts | 4 +- .../stdlib/theories/primitive-stock-flow.ts | 33 +- 21 files changed, 772 insertions(+), 553 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index f341b182d0..406799dcd9 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, ODESemantics, ODESemanticsProblemData, Parameter}; +use catlog::stdlib::analyses::ode::{ + self, ODESemantics, ODESemanticsProblemData, ODESemanticsScalarExtension, Parameter, +}; use catlog::zero::QualifiedName; use super::latex::latex_names; @@ -29,15 +31,15 @@ pub struct ODEResultWithEquations { } /// Simulate specific ODE semantics on a model, for use in a simulation analysis. -pub(crate) fn ode_semantics_simulation( +pub(crate) fn ode_semantics_simulation>( model: &DblModel, - problem_data: ODESemanticsProblemData, + problem_data: P, system: PolynomialSystem, i8>, ) -> Result { - let sys_extended_scalars = problem_data.extend_scalars(system); + let sys_extended_scalars = problem_data.clone().parameter_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 analysis = problem_data.general_data().build_analysis(sys_extended_scalars); let solution = analysis.solve_with_defaults().map_err(|err| format!("{err:?}")); Ok(ODEResultWithEquations { solution: ODEResult(solution.into()), @@ -156,10 +158,11 @@ pub(crate) mod tests { #[test] fn stock_flow_balanced_mass_action_latex_equations() { let model = backward_link("xylophone", "y", "fff"); - let system = - ode::StockFlowMassActionAnalysis::default().build_system(model.discrete_tab().unwrap()); + let system = ode::StockFlowBalancedMassActionAnalysis::default() + .build_system(model.discrete_tab().unwrap()); let equations = - ode_semantics_equations::(&model, system).unwrap(); + ode_semantics_equations::(&model, system) + .unwrap(); let expected = LatexEquations(vec![ LatexEquation { @@ -200,10 +203,11 @@ pub(crate) mod tests { 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 = - ode::PetriNetMassActionAnalysis::default().build_system(model.modal_unital().unwrap()); + let system = ode::PetriNetBalancedMassActionAnalysis::default() + .build_system(model.modal_unital().unwrap()); let equations = - ode_semantics_equations::(&model, system).unwrap(); + ode_semantics_equations::(&model, system) + .unwrap(); let expected = LatexEquations(vec![ LatexEquation { diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index cb09eb735f..d080ebf81e 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -45,9 +45,10 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String mod tests { use catlog::latex::{Latex, LatexEquation, LatexEquations}; use catlog::stdlib::analyses::ode::{ - LinearODEAnalysis, LotkaVolterraAnalysis, PetriNetMassActionAnalysis, + LinearODEAnalysis, LotkaVolterraAnalysis, PetriNetBalancedMassActionAnalysis, PetriNetUnbalancedMassActionAnalysis, PetriNetVeryUnbalancedMassActionAnalysis, - StockFlowMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, ode_semantics::*, + StockFlowBalancedMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, + ode_semantics::*, }; use super::*; @@ -113,7 +114,7 @@ mod tests { 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 analysis = StockFlowBalancedMassActionAnalysis::default(); let sys = analysis.build_system(tab_model); let equations = sys.to_latex_equations_with_map(|param| latex_names(&model)(param)); @@ -158,7 +159,7 @@ mod tests { // 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() + let equations = PetriNetBalancedMassActionAnalysis::default() .build_system(modal_model) .to_latex_equations_with_map(|param| latex_names(&model)(param)); diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 5bd815617e..19632ce059 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -9,10 +9,7 @@ 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::{ - LinearODESemantics, LotkaVolterraSemantics, ODESemanticsAnalysis, PetriNetMassActionSemantics, - PolynomialODESemantics, StockFlowMassActionSemantics, -}; +use catlog::stdlib::analyses::ode::ODESemanticsAnalysis; use catlog::stdlib::{analyses, models, theories, theory_morphisms}; use catlog::zero::name; @@ -152,11 +149,14 @@ impl ThSignedCategory { pub fn lotka_volterra( &self, model: &DblModel, - data: analyses::ode::ODESemanticsProblemData, + data: analyses::ode::LotkaVolterraProblemData, ) -> Result { let system = analyses::ode::LotkaVolterraAnalysis::default().build_system(model.discrete()?); - ode_semantics_simulation::(model, data, system) + ode_semantics_simulation::< + analyses::ode::LotkaVolterraSemantics, + analyses::ode::LotkaVolterraProblemData, + >(model, data, system) } /// Show the equations of the Lotka-Volterra system derived from a model. @@ -172,10 +172,13 @@ impl ThSignedCategory { pub fn linear_ode( &self, model: &DblModel, - data: analyses::ode::ODESemanticsProblemData, + data: analyses::ode::LinearODEProblemData, ) -> Result { let system = analyses::ode::LinearODEAnalysis::default().build_system(model.discrete()?); - ode_semantics_simulation::(model, data, system) + ode_semantics_simulation::< + analyses::ode::LinearODESemantics, + analyses::ode::LinearODEProblemData, + >(model, data, system) } /// Show the equations of the linear ODE system derived from a model. @@ -339,34 +342,34 @@ impl ThCategoryLinks { 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::ODESemanticsProblemData, - // TODO: need to have something here (in wasm) that replaces "MassConservationType" - // also something like - // GeneralMassActionProblemData = - // MassActionProblemData | PPMassActionProblemData | PTMassActionProblemData - ) -> Result { - let system = analyses::ode::StockFlowMassActionAnalysis::default() - .build_system(model.discrete_tab()?); - ode_semantics_simulation::(model, data, system) - } - - /// Returns the symbolic mass-action equations in LaTeX format. - #[wasm_bindgen(js_name = "massActionEquations")] - pub fn mass_action_equations( - &self, - model: &DblModel, - // TODO: need to have something here (in wasm) that replaces "MassConservationType" - // data: analyses::ode::MassActionEquationsConfig, - ) -> Result { - let system = analyses::ode::StockFlowMassActionAnalysis::default() - .build_system(model.discrete_tab()?); - ode_semantics_equations::(model, system) - } + // TODO: fix for stock-flow + // /// Simulates the mass-action ODE system derived from a model. + // #[wasm_bindgen(js_name = "massAction")] + // pub fn mass_action( + // &self, + // model: &DblModel, + // data: StockFlowMassActionProblemData, + // ) -> Result { + // let system = analyses::ode::StockFlowMassActionAnalysis::default() + // .build_system(model.discrete_tab()?); + // ode_semantics_simulation::( + // model, + // data.balanced, + // system, + // ) + // } + + // /// Returns the symbolic mass-action equations in LaTeX format. + // #[wasm_bindgen(js_name = "massActionEquations")] + // pub fn mass_action_equations( + // &self, + // model: &DblModel, + // data: MassActionVariant, + // ) -> Result { + // let system = analyses::ode::StockFlowMassActionAnalysis::default() + // .build_system(model.discrete_tab()?); + // ode_semantics_equations::(model, system) + // } } /// The theory of categories with signed links. @@ -407,19 +410,23 @@ impl ThSymMonoidalCategory { pub fn mass_action( &self, model: &DblModel, - data: analyses::ode::ODESemanticsProblemData, + data: analyses::ode::PetriNetMassActionProblemData, ) -> Result { - let system = analyses::ode::PetriNetMassActionAnalysis::default() + let system = analyses::ode::PetriNetBalancedMassActionAnalysis::default() .build_system(model.modal_unital()?); - ode_semantics_simulation::(model, data, system) + ode_semantics_simulation::< + analyses::ode::PetriNetBalancedMassActionSemantics, + // TODO: fix this: allow PetriNetMassActionProblemData + analyses::ode::BalancedMassActionProblemData, + >(model, data.balanced, system) } /// Returns the symbolic mass-action equations in LaTeX format. #[wasm_bindgen(js_name = "massActionEquations")] pub fn mass_action_equations(&self, model: &DblModel) -> Result { - let system = analyses::ode::PetriNetMassActionAnalysis::default() + let system = analyses::ode::PetriNetBalancedMassActionAnalysis::default() .build_system(model.modal_unital()?); - ode_semantics_equations::(model, system) + ode_semantics_equations::(model, system) } /// Simulates the stochastic mass-action system derived from a model. @@ -469,11 +476,14 @@ impl ThPolynomialODE { pub fn polynomial_ode_simulation( &self, model: &DblModel, - data: analyses::ode::ODESemanticsProblemData, + data: analyses::ode::PolynomialODEProblemData, ) -> Result { let system = analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); - ode_semantics_simulation::(model, data, system) + ode_semantics_simulation::< + analyses::ode::PolynomialODESemantics, + analyses::ode::PolynomialODEProblemData, + >(model, data, system) } /// Returns the symbolic equations in LaTeX format. @@ -506,11 +516,14 @@ impl ThSignedPolynomialODE { pub fn polynomial_ode_simulation( &self, model: &DblModel, - data: analyses::ode::ODESemanticsProblemData, + data: analyses::ode::PolynomialODEProblemData, ) -> Result { let system = analyses::ode::PolynomialODEAnalysis::default().build_system(model.modal_nonunital()?); - ode_semantics_simulation::(model, data, system) + ode_semantics_simulation::< + analyses::ode::PolynomialODESemantics, + analyses::ode::PolynomialODEProblemData, + >(model, data, system) } /// Returns the symbolic equations in LaTeX format. diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 6fc5a430b7..35aa41d605 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -259,38 +259,23 @@ pub trait ODESemanticsScalarExtension { /// The struct 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. Note that it must -/// also contain `ODESemanticsEquationsConfig`, since we need to know how to build the equations -/// before we are able to solve them numerically. +/// converting the parameters (which are of type `ODEParameterType`) into floats. #[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 ODESemanticsProblemData -where - T: ODESemantics, -{ +#[derive(Clone)] +pub struct ODESemanticsGeneralProblemData { /// 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, - /// Data needed to fill in parameters. - #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] - pub parameter_data: T::ParameterData, } -impl ODESemanticsProblemData { - /// Take formal parameters and convert them into floats using `self.parameter_data`. - pub fn extend_scalars( - &self, - system: PolynomialSystem, i8>, - ) -> PolynomialSystem { - self.parameter_data.extend_scalars(system) - } - +impl ODESemanticsGeneralProblemData { /// Converting the polynomial system into a system ready for use in numerical solvers. The default /// implementation here should essentially always be the desired one. pub fn build_analysis( @@ -312,3 +297,9 @@ impl ODESemanticsProblemData { ODEAnalysis::new(problem, ob_index) } } + +pub trait ODESemanticsProblemData: Clone { + type ParameterData: ODESemanticsScalarExtension; + fn general_data(self) -> ODESemanticsGeneralProblemData; + fn parameter_data(self) -> Self::ParameterData; +} diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs index 62739e89c1..98de326a24 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v0/linear_ode.rs @@ -2,29 +2,14 @@ use std::collections::HashMap; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde-wasm")] -use tsify::Tsify; - use crate::zero::QualifiedName; /// Data defining a linear 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 LinearODEProblemData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "coefficients"))] pub(crate) coefficients: HashMap, - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] pub(crate) initial_values: HashMap, - /// Duration of simulation. pub(crate) duration: f32, } diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs index 7da71daf78..a1a40037d1 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v0/lotka_volterra.rs @@ -2,33 +2,16 @@ use std::collections::HashMap; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde-wasm")] -use tsify::Tsify; - use crate::zero::QualifiedName; /// Data defining a Lotka-Volterra 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 LotkaVolterraProblemData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "interactionCoefficients"))] pub(crate) interaction_coeffs: HashMap, - /// Map from object IDs to growth rates (arbitrary real numbers). - #[cfg_attr(feature = "serde", serde(rename = "growthRates"))] pub(crate) growth_rates: HashMap, - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] pub(crate) initial_values: HashMap, - /// Duration of simulation. pub(crate) duration: f32, } diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs index 1305989c04..446df1ee23 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v0/mass_action.rs @@ -2,22 +2,13 @@ use std::collections::HashMap; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde-wasm")] -use tsify::Tsify; - use crate::zero::QualifiedName; /// There are three types of mass-action semantics, each more expressive than the previous: /// - balanced /// - unbalanced (rates per transition) /// - unbalanced (rates per place) -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[cfg_attr(feature = "serde", serde(tag = "type", content = "granularity"))] -#[cfg_attr(feature = "serde-wasm", derive(Tsify))] -#[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] +#[derive(Clone)] pub enum MassConservationType { /// Mass is conserved. Balanced, @@ -27,10 +18,7 @@ pub enum MassConservationType { /// When mass is not necessarily conserved, consumption/production rate parameters /// can be set either *per transition* or *per place*. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -#[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 enum RateGranularity { /// Each flow (transition) gets assigned a single consumption and single production rate. PerTransition, @@ -89,56 +77,39 @@ pub enum Direction { } /// 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))] #[derive(Clone)] pub struct MassActionEquationsData { /// Whether or not mass is conserved. - #[cfg_attr(feature = "serde", serde(rename = "massConservationType"))] pub mass_conservation_type: MassConservationType, } /// Data defining a mass-action 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). - #[cfg_attr(feature = "serde", serde(rename = "equationsData"))] pub(crate) equations_data: MassActionEquationsData, /// Map from morphism IDs to consumption rate coefficients (non-negative 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"))] pub(crate) transition_rates: HashMap, /// Map from morphism IDs to consumption rate coefficients (non-negative reals), /// for the unbalanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionConsumptionRates"))] pub(crate) transition_consumption_rates: HashMap, /// Map from morphism IDs to production rate coefficients (non-negative reals), /// for the unbalanced per transition case. - #[cfg_attr(feature = "serde", serde(rename = "transitionProductionRates"))] pub(crate) transition_production_rates: HashMap, /// Map from morphism IDs to (map from input objects to consumption rate coefficients), /// for the unbalanced per place case (non-negative reals). - #[cfg_attr(feature = "serde", serde(rename = "placeConsumptionRates"))] pub(crate) place_consumption_rates: HashMap>, /// Map from morphism IDs to (map from output objects to production rate coefficients), /// for the unbalanced per place case (non-negative reals). - #[cfg_attr(feature = "serde", serde(rename = "placeProductionRates"))] pub(crate) place_production_rates: HashMap>, /// Map from object IDs to initial values (non-negative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] pub(crate) initial_values: HashMap, /// Duration of simulation. diff --git a/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs index 1e1b17a614..8ce711f626 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v0/polynomial_ode.rs @@ -2,28 +2,14 @@ use std::collections::HashMap; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde-wasm")] -use tsify::Tsify; - use crate::zero::QualifiedName; /// Data defining a polynomial 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 interaction coefficients (nonnegative reals). pub(crate) coefficients: HashMap, - /// Map from object IDs to initial values (nonnegative reals). - #[cfg_attr(feature = "serde", serde(rename = "initialValues"))] pub(crate) initial_values: HashMap, - /// Duration of simulation. pub(crate) duration: f32, } diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs index b5c2d3c91d..3801d01e40 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs @@ -17,11 +17,13 @@ use crate::dbl::model::{FpDblModel, MutDblModel}; use crate::latex::{Latex, ToLatexWithMap}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::Parameter; use crate::stdlib::analyses::ode::ode_semantics::{ ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsScalarExtension, PolynomialODESystemBuilder, }; +use crate::stdlib::analyses::ode::{ + ODESemanticsGeneralProblemData, ODESemanticsProblemData, Parameter, +}; use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; @@ -165,6 +167,7 @@ impl feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] +#[derive(Clone)] pub struct LinearODEParameterData { /// Map from morphism IDs to interaction coefficients (non-negative reals). pub(crate) coefficients: HashMap, @@ -189,6 +192,30 @@ impl ODESemanticsScalarExtension<::Parameter } } +#[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) +)] +#[derive(Clone)] +pub struct LinearODEProblemData { + #[cfg_attr(feature = "serde", serde(rename = "generalData"))] + pub general_data: ODESemanticsGeneralProblemData, + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: LinearODEParameterData, +} + +impl ODESemanticsProblemData for LinearODEProblemData { + type ParameterData = LinearODEParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + // ┌-------┐ // | TESTS | // └-------┘ @@ -202,7 +229,7 @@ mod test { use crate::{ dbl::model::MutDblModel, latex::{LatexEquation, LatexEquations, wrap_with_backslash_text}, - stdlib::{analyses::ode::ODESemanticsProblemData, models::*, theories::*}, + stdlib::{models::*, theories::*}, }; // Symbolic tests. @@ -247,9 +274,11 @@ mod test { fn predator_prey_numerical() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), - duration: 10.0, + let data = LinearODEProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), + duration: 10.0, + }, parameter_data: LinearODEParameterData { coefficients: [(name("positive"), 3.0), (name("negative"), 2.0)] .into_iter() @@ -257,7 +286,7 @@ mod test { }, }; let sys = LinearODEAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -2 y dy = 3 x diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs index 366d284b90..d4b5becc7e 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs @@ -17,11 +17,13 @@ use crate::dbl::model::{FpDblModel, MutDblModel}; use crate::latex::{Latex, ToLatexWithMap}; use crate::one::Path; use crate::simulate::ode::PolynomialSystem; -use crate::stdlib::analyses::ode::Parameter; use crate::stdlib::analyses::ode::ode_semantics::{ ContributionSign, ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsScalarExtension, PolynomialODESystemBuilder, }; +use crate::stdlib::analyses::ode::{ + ODESemanticsGeneralProblemData, ODESemanticsProblemData, Parameter, +}; use crate::zero::name; use crate::{dbl::model::DiscreteDblModel, one::QualifiedPath, zero::QualifiedName}; @@ -190,6 +192,7 @@ impl feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] +#[derive(Clone)] pub struct LotkaVolterraParameterData { /// Map from morphism IDs to interaction coefficients (nonnegative reals). #[cfg_attr(feature = "serde", serde(rename = "interactionCoefficients"))] @@ -222,6 +225,30 @@ impl ODESemanticsScalarExtension<::Param } } +#[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) +)] +#[derive(Clone)] +pub struct LotkaVolterraProblemData { + #[cfg_attr(feature = "serde", serde(rename = "generalData"))] + pub general_data: ODESemanticsGeneralProblemData, + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: LotkaVolterraParameterData, +} + +impl ODESemanticsProblemData for LotkaVolterraProblemData { + type ParameterData = LotkaVolterraParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + // ┌-------┐ // | TESTS | // └-------┘ @@ -235,7 +262,7 @@ mod test { use crate::{ dbl::model::MutDblModel, latex::{LatexEquation, LatexEquations, wrap_with_backslash_text}, - stdlib::{analyses::ode::ODESemanticsProblemData, models::*, theories::*}, + stdlib::{models::*, theories::*}, }; // Symbolic tests. @@ -280,9 +307,11 @@ mod test { fn predator_prey_numerical() { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), - duration: 10.0, + let data = LotkaVolterraProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), + duration: 10.0, + }, parameter_data: LotkaVolterraParameterData { interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] .into_iter() @@ -291,7 +320,7 @@ mod test { }, }; let sys = LotkaVolterraAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = 2 x - x y dy = x y - y diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs index 286b08080c..4c87fe11e9 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs @@ -68,54 +68,56 @@ use crate::{ // We start with the usual mass-action semantics, for both Petri nets and stock-flow diagrams. /// Mass-action semantics for Petri nets. -pub struct PetriNetMassActionSemantics; -impl ODESemantics for PetriNetMassActionSemantics { +pub struct PetriNetBalancedMassActionSemantics; +impl ODESemantics for PetriNetBalancedMassActionSemantics { type ModelType = ModalDblModel; - type ParameterType = MassActionParameter; - type AnalysisType = PetriNetMassActionAnalysis; - type ParameterData = MassActionParameterData; + type ParameterType = BalancedMassActionParameter; + type AnalysisType = PetriNetBalancedMassActionAnalysis; + type ParameterData = BalancedMassActionParameterData; } /// Mass-action semantics for stock-flow diagrams. -pub struct StockFlowMassActionSemantics; -impl ODESemantics for StockFlowMassActionSemantics { +pub struct StockFlowBalancedMassActionSemantics; +impl ODESemantics for StockFlowBalancedMassActionSemantics { type ModelType = DiscreteTabModel; - type ParameterType = MassActionParameter; - type AnalysisType = StockFlowMassActionAnalysis; - type ParameterData = MassActionParameterData; + type ParameterType = BalancedMassActionParameter; + type AnalysisType = StockFlowBalancedMassActionAnalysis; + type ParameterData = BalancedMassActionParameterData; } // ┌--------------------┐ // | A.1. ParameterType | // └--------------------┘ -/// Parameters for the usual ("balanced") mass-action semantics, where each flow simply has a rate. +/// Parameters for the usual ("balanced") mass-action semantics, where each transition has a rate. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum MassActionParameter { - /// The rate of a flow. +pub enum BalancedMassActionParameter { + /// The rate of a transition. Rate { - /// The flow in question. - flow: QualifiedName, + /// The transition in question. + transition: QualifiedName, }, } -impl fmt::Display for MassActionParameter { +impl fmt::Display for BalancedMassActionParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - MassActionParameter::Rate { flow } => write!(f, "Rate({})", flow), + BalancedMassActionParameter::Rate { transition } => write!(f, "Rate({})", transition), } } } -impl ToLatexWithMap for MassActionParameter { +impl ToLatexWithMap for BalancedMassActionParameter { fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - MassActionParameter::Rate { flow } => Latex(format!("r_{{{}}}", f(flow))), + BalancedMassActionParameter::Rate { transition } => { + Latex(format!("r_{{{}}}", f(transition))) + } } } } -impl ODEParameterType for MassActionParameter {} +impl ODEParameterType for BalancedMassActionParameter {} // ┌-------------------┐ // | A.2. AnalysisType | @@ -125,14 +127,14 @@ impl ODEParameterType for MassActionParameter {} /// /// This struct implements the object part of the functorial semantics for reaction /// networks (aka, Petri nets) due to [Baez & Pollard](crate::refs::ReactionNets). -pub struct PetriNetMassActionAnalysis { +pub struct PetriNetBalancedMassActionAnalysis { /// Object type for places. pub place_ob_type: ModalObType, /// Morphism type for transitions. pub transition_mor_type: ModalMorType, } -impl Default for PetriNetMassActionAnalysis { +impl Default for PetriNetBalancedMassActionAnalysis { fn default() -> Self { let ob_type = ModalObType::new(name("Object")); Self { @@ -144,14 +146,14 @@ impl Default for PetriNetMassActionAnalysis { impl ODESemanticsAnalysis< - ::ModelType, - ::ParameterType, - > for PetriNetMassActionAnalysis + ::ModelType, + ::ParameterType, + > for PetriNetBalancedMassActionAnalysis { fn build_system_builder( &self, model: &ModalDblModel, - ) -> PolynomialODESystemBuilder { + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); // For each place, we create a variable. @@ -169,7 +171,7 @@ impl let (inputs, outputs) = (interface.input_places.clone(), interface.output_places.clone()); - let parameter = MassActionParameter::Rate { flow: transition.clone() }; + let parameter = BalancedMassActionParameter::Rate { transition: transition.clone() }; for output in outputs.clone() { let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap()); @@ -199,7 +201,7 @@ impl } /// Mass-action ODE analysis for stock-flow models. -pub struct StockFlowMassActionAnalysis { +pub struct StockFlowBalancedMassActionAnalysis { /// Object type for stocks. pub stock_ob_type: TabObType, /// Morphism type for flows between stocks. @@ -210,7 +212,7 @@ pub struct StockFlowMassActionAnalysis { pub neg_link_mor_type: TabMorType, } -impl Default for StockFlowMassActionAnalysis { +impl Default for StockFlowBalancedMassActionAnalysis { fn default() -> Self { let ob_type = TabObType::Basic(name("Object")); Self { @@ -224,14 +226,14 @@ impl Default for StockFlowMassActionAnalysis { impl ODESemanticsAnalysis< - ::ModelType, - ::ParameterType, - > for StockFlowMassActionAnalysis + ::ModelType, + ::ParameterType, + > for StockFlowBalancedMassActionAnalysis { fn build_system_builder( &self, model: &DiscreteTabModel, - ) -> PolynomialODESystemBuilder { + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); // For each stock, we create a variable. @@ -250,7 +252,7 @@ impl let interface = flow_interface(model, &flow); let (input, output) = (interface.input_stock, interface.output_stock); - let parameter = MassActionParameter::Rate { flow: flow.clone() }; + let parameter = BalancedMassActionParameter::Rate { transition: flow.clone() }; let monomial = [interface.input_pos_link_doms, vec![input.clone()]].concat(); let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); @@ -287,20 +289,21 @@ impl feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] -pub struct MassActionParameterData { +#[derive(Clone)] +pub struct BalancedMassActionParameterData { /// Map from morphism IDs to consumption rate coefficients (non-negative reals). pub(crate) rates: HashMap, } -impl ODESemanticsScalarExtension for MassActionParameterData { +impl ODESemanticsScalarExtension for BalancedMassActionParameterData { fn extend_scalars( &self, - sys: PolynomialSystem, i8>, + sys: PolynomialSystem, i8>, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { - poly.eval(|flow| match flow { - MassActionParameter::Rate { flow } => { - self.rates.get(flow).cloned().unwrap_or_default() + poly.eval(|parameter| match parameter { + BalancedMassActionParameter::Rate { transition } => { + self.rates.get(transition).cloned().unwrap_or_default() } }) }); @@ -309,6 +312,32 @@ impl ODESemanticsScalarExtension for MassActionParameterDat } } +#[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) +)] +#[derive(Clone)] +pub struct BalancedMassActionProblemData { + #[cfg_attr(feature = "serde", serde(rename = "generalData"))] + pub general_data: ODESemanticsGeneralProblemData, + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: BalancedMassActionParameterData, +} + +impl ODESemanticsProblemData + for BalancedMassActionProblemData +{ + type ParameterData = BalancedMassActionParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + // ┌-------------------------┐ // | B. UNBALANCED SEMANTICS | // └-------------------------┘ @@ -335,30 +364,31 @@ impl ODESemantics for StockFlowUnbalancedMassActionSemantics { type ParameterData = UnbalancedMassActionParameterData; } -/// Parameters for unbalanced ("per-flow") mass-action semantics, where each flow has two associated -/// rates: its *consumption* (affecting its inputs) and its *production* (affecting its outputs). +/// Parameters for unbalanced ("per-transition") mass-action semantics, where each transition has +/// two associated rates: its *consumption* (affecting its inputs) and its *production* (affecting +/// its outputs). #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] pub enum UnbalancedMassActionParameter { - /// The consumption parameter associated to all inputs to a flow. + /// The consumption parameter associated to all inputs to a transition. Consumption { - /// The flow in question. - flow: QualifiedName, + /// The transition in question. + transition: QualifiedName, }, - /// The production parameter associated to all inputs to a flow. + /// The production parameter associated to all inputs to a transition. Production { - /// The flow in question. - flow: QualifiedName, + /// The transition in question. + transition: QualifiedName, }, } impl fmt::Display for UnbalancedMassActionParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - UnbalancedMassActionParameter::Consumption { flow } => { - write!(f, "Consumption({})", flow) + UnbalancedMassActionParameter::Consumption { transition } => { + write!(f, "Consumption({})", transition) } - UnbalancedMassActionParameter::Production { flow } => { - write!(f, "Production({})", flow) + UnbalancedMassActionParameter::Production { transition } => { + write!(f, "Production({})", transition) } } } @@ -367,11 +397,11 @@ impl fmt::Display for UnbalancedMassActionParameter { impl ToLatexWithMap for UnbalancedMassActionParameter { fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - UnbalancedMassActionParameter::Consumption { flow } => { - Latex(format!("\\kappa_{{{}}}", f(flow))) + UnbalancedMassActionParameter::Consumption { transition } => { + Latex(format!("\\kappa_{{{}}}", f(transition))) } - UnbalancedMassActionParameter::Production { flow } => { - Latex(format!("\\rho_{{{}}}", f(flow))) + UnbalancedMassActionParameter::Production { transition } => { + Latex(format!("\\rho_{{{}}}", f(transition))) } } } @@ -431,7 +461,7 @@ impl for output in outputs.clone() { let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap()); let output_parameter = - UnbalancedMassActionParameter::Production { flow: transition.clone() }; + UnbalancedMassActionParameter::Production { transition: transition.clone() }; builder.add_contribution( id, output, @@ -444,7 +474,7 @@ impl for input in inputs.clone() { let id = input.cons(name_seg("FromInput")).cons(transition.only().unwrap()); let input_parameter = - UnbalancedMassActionParameter::Consumption { flow: transition.clone() }; + UnbalancedMassActionParameter::Consumption { transition: transition.clone() }; builder.add_contribution( id, input, @@ -466,9 +496,7 @@ pub struct StockFlowUnbalancedMassActionAnalysis { /// Morphism type for flows between stocks. pub flow_mor_type: TabMorType, /// Morphism type for positive links from stocks to flows. - pub pos_link_mor_type: TabMorType, - /// Morphism type for negative links from stocks to flows. - pub neg_link_mor_type: TabMorType, + pub link_mor_type: TabMorType, } impl Default for StockFlowUnbalancedMassActionAnalysis { @@ -477,8 +505,7 @@ impl Default for StockFlowUnbalancedMassActionAnalysis { Self { 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")), + link_mor_type: TabMorType::Basic(name("Link")), } } } @@ -514,7 +541,8 @@ impl let monomial = [interface.input_pos_link_doms, vec![input.clone()]].concat(); let output_id = output.cons(name_seg("ToOutput")).cons(flow.only().unwrap()); - let output_parameter = UnbalancedMassActionParameter::Production { flow: flow.clone() }; + let output_parameter = + UnbalancedMassActionParameter::Production { transition: flow.clone() }; builder.add_contribution( output_id, output, @@ -524,7 +552,8 @@ impl ); let input_id = input.cons(name_seg("ToInput")).cons(flow.only().unwrap()); - let input_parameter = UnbalancedMassActionParameter::Consumption { flow: flow.clone() }; + let input_parameter = + UnbalancedMassActionParameter::Consumption { transition: flow.clone() }; builder.add_contribution( input_id, input, @@ -549,6 +578,7 @@ impl feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] +#[derive(Clone)] pub struct UnbalancedMassActionParameterData { /// Map from morphism IDs to consumption rate coefficients (non-negative reals). #[cfg_attr(feature = "serde", serde(rename = "consumptionRates"))] @@ -567,12 +597,12 @@ impl ODESemanticsScalarExtension sys: PolynomialSystem, i8>, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { - poly.eval(|flow| match flow { - UnbalancedMassActionParameter::Consumption { flow } => { - self.consumption_rates.get(flow).cloned().unwrap_or_default() + poly.eval(|parameter| match parameter { + UnbalancedMassActionParameter::Consumption { transition } => { + self.consumption_rates.get(transition).cloned().unwrap_or_default() } - UnbalancedMassActionParameter::Production { flow } => { - self.production_rates.get(flow).cloned().unwrap_or_default() + UnbalancedMassActionParameter::Production { transition } => { + self.production_rates.get(transition).cloned().unwrap_or_default() } }) }); @@ -581,6 +611,32 @@ impl ODESemanticsScalarExtension } } +#[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) +)] +#[derive(Clone)] +pub struct UnbalancedMassActionProblemData { + #[cfg_attr(feature = "serde", serde(rename = "generalData"))] + pub general_data: ODESemanticsGeneralProblemData, + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: UnbalancedMassActionParameterData, +} + +impl ODESemanticsProblemData + for UnbalancedMassActionProblemData +{ + type ParameterData = UnbalancedMassActionParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + // ┌------------------------------┐ // | C. VERY UNBALANCED SEMANTICS | // └------------------------------┘ @@ -742,6 +798,7 @@ impl feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] +#[derive(Clone)] pub struct VeryUnbalancedMassActionParameterData { /// Map from morphism IDs to (map from input objects to consumption rate coefficients). #[cfg_attr(feature = "serde", serde(rename = "consumptionRates"))] @@ -760,7 +817,7 @@ impl ODESemanticsScalarExtension sys: PolynomialSystem, i8>, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { - poly.eval(|flow| match flow { + poly.eval(|parameter| match parameter { VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => self .consumption_rates .get(transition) @@ -780,6 +837,77 @@ impl ODESemanticsScalarExtension } } +#[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) +)] +#[derive(Clone)] +pub struct VeryUnbalancedMassActionProblemData { + #[cfg_attr(feature = "serde", serde(rename = "generalData"))] + pub general_data: ODESemanticsGeneralProblemData, + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: VeryUnbalancedMassActionParameterData, +} + +impl ODESemanticsProblemData + for VeryUnbalancedMassActionProblemData +{ + type ParameterData = VeryUnbalancedMassActionParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + +// ┌------------┐ +// | MIGRATIONS | +// └------------┘ + +// For backwards compatibility to when there was a *single* mass-action semantics with three +// internal variants, we give here some wrappers that will be useful in `analyses::ode::v1::migrate` +// and also in `catlog-wasm` and `frontend`. + +/// The variants of mass-action. +#[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(PartialEq, Eq, Hash, Clone)] +pub enum MassActionVariant { + Balanced, + Unbalanced, + VeryUnbalanced, +} + +/// In order for `migrate_petri_net_mass_action_v0_to_v1` to have a well-defined return type, we +/// unify the three mass-action semantics for Petri nets into a single struct. +#[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 PetriNetMassActionProblemData { + pub variant: MassActionVariant, + pub balanced: BalancedMassActionProblemData, + pub unbalanced: UnbalancedMassActionProblemData, + #[cfg_attr(feature = "serde", serde(rename = "veryUnbalanced"))] + pub very_unbalanced: VeryUnbalancedMassActionProblemData, +} + +/// In order for `migrate_stock_flow_mass_action_v0_to_v1` to have a well-defined return type, we +/// unify the two mass-action semantics for stock-flow diagrams into a single struct. +#[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 StockFlowMassActionProblemData { + pub variant: MassActionVariant, + pub balanced: BalancedMassActionProblemData, + pub unbalanced: UnbalancedMassActionProblemData, +} + // ┌-------┐ // | TESTS | // └-------┘ @@ -803,7 +931,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); + let sys = StockFlowBalancedMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" dx = -Rate(f) x y dy = Rate(f) x y @@ -829,7 +957,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); + let sys = PetriNetBalancedMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" dx = -Rate(f) c x dy = Rate(f) c x @@ -872,15 +1000,17 @@ mod tests { fn balanced_stock_flow_numerical() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), - duration: 10.0, - parameter_data: MassActionParameterData { + let data = BalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), + duration: 10.0, + }, + parameter_data: BalancedMassActionParameterData { rates: [(name("f"), 2.0)].into_iter().collect(), }, }; - let sys = StockFlowMassActionAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let sys = StockFlowBalancedMassActionAnalysis::default().build_system(&model); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -2 x y dy = 2 x y @@ -892,17 +1022,18 @@ mod tests { fn unbalanced_stock_flow_numerical() { let th = Rc::new(th_category_links()); let model = backward_link(th); - let data: ODESemanticsProblemData = - ODESemanticsProblemData { + let data = UnbalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { initial_values: [(name("x"), 1.0), (name("y"), 1.5)].into_iter().collect(), duration: 10.0, - parameter_data: UnbalancedMassActionParameterData { - consumption_rates: [(name("f"), 1.5)].into_iter().collect(), - production_rates: [(name("f"), 2.0)].into_iter().collect(), - }, - }; + }, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: [(name("f"), 1.5)].into_iter().collect(), + production_rates: [(name("f"), 2.0)].into_iter().collect(), + }, + }; let sys = StockFlowUnbalancedMassActionAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -1.5 x y dy = 2 x y @@ -914,17 +1045,19 @@ mod tests { fn balanced_petri_numerical() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] - .into_iter() - .collect(), - duration: 10.0, - parameter_data: MassActionParameterData { + let data = BalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] + .into_iter() + .collect(), + duration: 10.0, + }, + parameter_data: BalancedMassActionParameterData { rates: [(name("f"), 1.5)].into_iter().collect(), }, }; - let sys = PetriNetMassActionAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let sys = PetriNetBalancedMassActionAnalysis::default().build_system(&model); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -1.5 c x dy = 1.5 c x @@ -937,19 +1070,20 @@ mod tests { fn unbalanced_petri_per_transition_numerical() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let data: ODESemanticsProblemData = - ODESemanticsProblemData { + let data = UnbalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] .into_iter() .collect(), duration: 10.0, - parameter_data: UnbalancedMassActionParameterData { - consumption_rates: [(name("f"), 3.5)].into_iter().collect(), - production_rates: [(name("f"), 4.0)].into_iter().collect(), - }, - }; + }, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: [(name("f"), 3.5)].into_iter().collect(), + production_rates: [(name("f"), 4.0)].into_iter().collect(), + }, + }; let sys = PetriNetUnbalancedMassActionAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -3.5 c x dy = 4 c x @@ -962,29 +1096,30 @@ mod tests { fn unbalanced_petri_per_place_numerical() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let data: ODESemanticsProblemData = - ODESemanticsProblemData { + let data = VeryUnbalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] .into_iter() .collect(), duration: 10.0, - parameter_data: VeryUnbalancedMassActionParameterData { - consumption_rates: [( - name("f"), - [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), - )] - .into_iter() - .collect(), - production_rates: [( - name("f"), - [(name("y"), 1.5), (name("c"), 2.5)].into_iter().collect(), - )] - .into_iter() - .collect(), - }, - }; + }, + parameter_data: VeryUnbalancedMassActionParameterData { + consumption_rates: [( + name("f"), + [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), + )] + .into_iter() + .collect(), + production_rates: [( + name("f"), + [(name("y"), 1.5), (name("c"), 2.5)].into_iter().collect(), + )] + .into_iter() + .collect(), + }, + }; let sys = PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -2 c x dy = 1.5 c x diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs index e437a00675..9556b65d1f 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs @@ -1,30 +1,16 @@ //! Migrations from v0 to v1 for analyses. -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde-wasm")] -use tsify::Tsify; - -use crate::stdlib::analyses::ode::v0::linear_ode::LinearODEProblemData; -use crate::stdlib::analyses::ode::v0::lotka_volterra::LotkaVolterraProblemData; -use crate::stdlib::analyses::ode::v0::mass_action::MassActionProblemData; -use crate::stdlib::analyses::ode::v0::polynomial_ode::PolynomialODEProblemData; -use crate::stdlib::analyses::ode::{ - LinearODEParameterData, LinearODESemantics, LotkaVolterraParameterData, LotkaVolterraSemantics, - MassActionParameterData, ODESemanticsProblemData, PetriNetMassActionSemantics, - PetriNetUnbalancedMassActionSemantics, PetriNetVeryUnbalancedMassActionSemantics, - PolynomialODEParameterData, PolynomialODESemantics, StockFlowMassActionSemantics, - StockFlowUnbalancedMassActionSemantics, UnbalancedMassActionParameterData, - VeryUnbalancedMassActionParameterData, -}; +use crate::stdlib::analyses::ode::*; /// Migration for problem data for linear ODE. pub fn migrate_linear_ode_v0_to_v1( - v0: LinearODEProblemData, -) -> ODESemanticsProblemData { - let v1: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: v0.initial_values, - duration: v0.duration, + v0: v0::linear_ode::LinearODEProblemData, +) -> LinearODEProblemData { + let v1 = LinearODEProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: v0.initial_values, + duration: v0.duration, + }, parameter_data: LinearODEParameterData { coefficients: v0.coefficients }, }; v1 @@ -32,11 +18,13 @@ pub fn migrate_linear_ode_v0_to_v1( /// Migration for problem data for Lotka-Volterra. pub fn migrate_lotka_volterra_v0_to_v1( - v0: LotkaVolterraProblemData, -) -> ODESemanticsProblemData { - let v1: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: v0.initial_values, - duration: v0.duration, + v0: v0::lotka_volterra::LotkaVolterraProblemData, +) -> LotkaVolterraProblemData { + let v1 = LotkaVolterraProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: v0.initial_values, + duration: v0.duration, + }, parameter_data: LotkaVolterraParameterData { interaction_coeffs: v0.interaction_coeffs, growth_rates: v0.growth_rates, @@ -47,93 +35,84 @@ pub fn migrate_lotka_volterra_v0_to_v1( /// Migration for problem data for polynomial ODE. pub fn migrate_polynomial_ode_v0_to_v1( - v0: PolynomialODEProblemData, -) -> ODESemanticsProblemData { - let v1: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: v0.initial_values, - duration: v0.duration, + v0: v0::polynomial_ode::PolynomialODEProblemData, +) -> PolynomialODEProblemData { + let v1 = PolynomialODEProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: v0.initial_values, + duration: v0.duration, + }, parameter_data: PolynomialODEParameterData { coefficients: v0.coefficients }, }; v1 } -/// In order for `migrate_petri_net_mass_action_v0_to_v1` to have a well-defined return type, we -/// unify the three mass-action semantics for Petri nets into a single struct. -#[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 PetriNetMassActionProblemData { - balanced: ODESemanticsProblemData, - unbalanced: ODESemanticsProblemData, - very_unbalanced: ODESemanticsProblemData, -} - /// Migration for problem data for mass-action on a Petri net. pub fn migrate_petri_net_mass_action_v0_to_v1( - v0: MassActionProblemData, + v0: v0::mass_action::MassActionProblemData, ) -> PetriNetMassActionProblemData { - let balanced: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: v0.initial_values.clone(), - duration: v0.duration, - parameter_data: MassActionParameterData { rates: v0.transition_rates }, + let balanced = BalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: v0.initial_values.clone(), + duration: v0.duration, + }, + parameter_data: BalancedMassActionParameterData { rates: v0.transition_rates }, }; - let unbalanced: ODESemanticsProblemData = - ODESemanticsProblemData { + let unbalanced = UnbalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values.clone(), duration: v0.duration, - parameter_data: UnbalancedMassActionParameterData { - consumption_rates: v0.transition_consumption_rates, - production_rates: v0.transition_production_rates, - }, - }; - let very_unbalanced: ODESemanticsProblemData = - ODESemanticsProblemData { + }, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: v0.transition_consumption_rates, + production_rates: v0.transition_production_rates, + }, + }; + let very_unbalanced = VeryUnbalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values, duration: v0.duration, - parameter_data: VeryUnbalancedMassActionParameterData { - consumption_rates: v0.place_consumption_rates, - production_rates: v0.place_production_rates, - }, - }; - - PetriNetMassActionProblemData { balanced, unbalanced, very_unbalanced } -} + }, + parameter_data: VeryUnbalancedMassActionParameterData { + consumption_rates: v0.place_consumption_rates, + production_rates: v0.place_production_rates, + }, + }; -/// In order for `migrate_stock_flow_mass_action_v0_to_v1` to have a well-defined return type, we -/// unify the three mass-action semantics for stock-flow diagrams into a single struct. -#[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 StockFlowMassActionProblemData { - balanced: ODESemanticsProblemData, - unbalanced: ODESemanticsProblemData, + PetriNetMassActionProblemData { + variant: MassActionVariant::Balanced, + balanced, + unbalanced, + very_unbalanced, + } } /// Migration for problem data for mass-action on a stock-flow diagram. pub fn migrate_stock_flow_mass_action_v0_to_v1( - v0: MassActionProblemData, + v0: v0::mass_action::MassActionProblemData, ) -> StockFlowMassActionProblemData { - let balanced: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: v0.initial_values.clone(), - duration: v0.duration, - parameter_data: MassActionParameterData { rates: v0.transition_rates }, + let balanced = BalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: v0.initial_values.clone(), + duration: v0.duration, + }, + parameter_data: BalancedMassActionParameterData { rates: v0.transition_rates }, }; - let unbalanced: ODESemanticsProblemData = - ODESemanticsProblemData { + let unbalanced = UnbalancedMassActionProblemData { + general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values.clone(), duration: v0.duration, - parameter_data: UnbalancedMassActionParameterData { - consumption_rates: v0.transition_consumption_rates, - production_rates: v0.transition_production_rates, - }, - }; - StockFlowMassActionProblemData { balanced, unbalanced } + }, + parameter_data: UnbalancedMassActionParameterData { + consumption_rates: v0.transition_consumption_rates, + production_rates: v0.transition_production_rates, + }, + }; + StockFlowMassActionProblemData { + variant: MassActionVariant::Balanced, + balanced, + unbalanced, + } } #[cfg(test)] @@ -145,10 +124,9 @@ mod test { stdlib::{ analyses::ode::{ LinearODEAnalysis, LotkaVolterraAnalysis, ODESemanticsAnalysis, - PetriNetMassActionAnalysis, PetriNetUnbalancedMassActionAnalysis, + PetriNetBalancedMassActionAnalysis, PetriNetUnbalancedMassActionAnalysis, PetriNetVeryUnbalancedMassActionAnalysis, PolynomialODEAnalysis, - StockFlowMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, - v0::mass_action::MassActionEquationsData, + StockFlowBalancedMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, }, backward_link, catalyzed_reaction, negative_feedback, th_category_links, th_polynomial_ode_system, th_signed_category, th_sym_monoidal_category, @@ -163,7 +141,7 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let v0_data = LinearODEProblemData { + let v0_data = v0::linear_ode::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, @@ -172,7 +150,7 @@ mod test { let v1_data = migrate_linear_ode_v0_to_v1(v0_data); let system = LinearODEAnalysis::default().build_system(&model); - let analysis = v1_data.extend_scalars(system); + let analysis = v1_data.parameter_data.extend_scalars(system); let expected = expect!([r#" dx = -2 y dy = 3 x @@ -185,7 +163,7 @@ mod test { let th = Rc::new(th_signed_category()); let model = negative_feedback(th); - let v0_data = LotkaVolterraProblemData { + let v0_data = v0::lotka_volterra::LotkaVolterraProblemData { interaction_coeffs: [(name("positive"), 1.0), (name("negative"), 1.0)] .into_iter() .collect(), @@ -197,7 +175,7 @@ mod test { let v1_data = migrate_lotka_volterra_v0_to_v1(v0_data); let system = LotkaVolterraAnalysis::default().build_system(&model); - let analysis = v1_data.extend_scalars(system); + let analysis = v1_data.parameter_data.extend_scalars(system); let expected = expect!([r#" dx = 2 x - x y dy = x y - y @@ -210,7 +188,7 @@ mod test { let th = Rc::new(th_polynomial_ode_system()); let model = unsigned_lotka_volterra_dynamics(th); - let v0_data = PolynomialODEProblemData { + let v0_data = v0::polynomial_ode::PolynomialODEProblemData { coefficients: [ (name("A_growth"), 1.0), (name("B_growth"), 2.0), @@ -231,7 +209,7 @@ mod test { let v1_data = migrate_polynomial_ode_v0_to_v1(v0_data); let system = PolynomialODEAnalysis::default().build_system(&model); - let analysis = v1_data.extend_scalars(system); + let analysis = v1_data.parameter_data.extend_scalars(system); let expected = expect!([r#" dA = A - 2 A B dB = 1.5 A B + 2 B - 3 B C @@ -247,14 +225,13 @@ mod test { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let v0_data = MassActionProblemData { + let v0_data = v0::mass_action::MassActionProblemData { initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] .into_iter() .collect(), duration: 10.0, - equations_data: MassActionEquationsData { - mass_conservation_type: - crate::stdlib::analyses::ode::v0::mass_action::MassConservationType::Balanced, + equations_data: v0::mass_action::MassActionEquationsData { + mass_conservation_type: v0::mass_action::MassConservationType::Balanced, }, transition_rates: [(name("f"), 1.5)].into_iter().collect(), transition_consumption_rates: [(name("f"), 3.5)].into_iter().collect(), @@ -275,8 +252,8 @@ mod test { let v1_data = migrate_petri_net_mass_action_v0_to_v1(v0_data); - let balanced_system = PetriNetMassActionAnalysis::default().build_system(&model); - let balanced_analysis = v1_data.balanced.extend_scalars(balanced_system); + let balanced_system = PetriNetBalancedMassActionAnalysis::default().build_system(&model); + let balanced_analysis = v1_data.balanced.parameter_data.extend_scalars(balanced_system); let balanced_expected = expect!([r#" dx = -1.5 c x dy = 1.5 c x @@ -286,7 +263,8 @@ mod test { let unbalanced_system = PetriNetUnbalancedMassActionAnalysis::default().build_system(&model); - let unbalanced_analysis = v1_data.unbalanced.extend_scalars(unbalanced_system); + let unbalanced_analysis = + v1_data.unbalanced.parameter_data.extend_scalars(unbalanced_system); let unbalanced_expected = expect!([r#" dx = -3.5 c x dy = 4 c x @@ -297,7 +275,7 @@ mod test { let very_unbalanced_system = PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); let very_unbalanced_analysis = - v1_data.very_unbalanced.extend_scalars(very_unbalanced_system); + v1_data.very_unbalanced.parameter_data.extend_scalars(very_unbalanced_system); let very_unbalanced_expected = expect!([r#" dx = -2 c x dy = 1.5 c x @@ -311,10 +289,10 @@ mod test { let th = Rc::new(th_category_links()); let model = backward_link(th); - let v0_data = MassActionProblemData { + let v0_data = v0::mass_action::MassActionProblemData { initial_values: [(name("x"), 1.0), (name("y"), 1.0)].into_iter().collect(), duration: 10.0, - equations_data: MassActionEquationsData { + equations_data: v0::mass_action::MassActionEquationsData { mass_conservation_type: crate::stdlib::analyses::ode::v0::mass_action::MassConservationType::Balanced, }, @@ -327,12 +305,13 @@ mod test { let v1_data = migrate_stock_flow_mass_action_v0_to_v1(v0_data); - let balanced_system = StockFlowMassActionAnalysis::default().build_system(&model); - let balanced_analysis = v1_data.balanced.extend_scalars(balanced_system); + let balanced_system = StockFlowBalancedMassActionAnalysis::default().build_system(&model); + let balanced_analysis = v1_data.balanced.parameter_data.extend_scalars(balanced_system); let unbalanced_system = StockFlowUnbalancedMassActionAnalysis::default().build_system(&model); - let unbalanced_analysis = v1_data.unbalanced.extend_scalars(unbalanced_system); + let unbalanced_analysis = + v1_data.unbalanced.parameter_data.extend_scalars(unbalanced_system); let expected_balanced = expect!([r#" dx = -3 x y diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs index 4fe62d3d6a..389ea5b705 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs @@ -28,8 +28,9 @@ use crate::{ latex::{Latex, ToLatexWithMap}, simulate::ode::PolynomialSystem, stdlib::analyses::ode::{ - ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsScalarExtension, - Parameter, PolynomialODESystemBuilder, + ODEParameterType, ODESemantics, ODESemanticsAnalysis, ODESemanticsGeneralProblemData, + ODESemanticsProblemData, ODESemanticsScalarExtension, Parameter, + PolynomialODESystemBuilder, }, zero::{QualifiedName, alg::Polynomial, name, rig::Monomial}, }; @@ -226,6 +227,7 @@ impl PolynomialODEAnalysis { feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] +#[derive(Clone)] pub struct PolynomialODEParameterData { /// Map from morphism IDs to coefficients (nonnegative reals). pub(crate) coefficients: HashMap, @@ -254,6 +256,30 @@ impl ODESemanticsScalarExtension<::Param } } +#[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) +)] +#[derive(Clone)] +pub struct PolynomialODEProblemData { + #[cfg_attr(feature = "serde", serde(rename = "generalData"))] + pub general_data: ODESemanticsGeneralProblemData, + #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] + pub parameter_data: PolynomialODEParameterData, +} + +impl ODESemanticsProblemData for PolynomialODEProblemData { + type ParameterData = PolynomialODEParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + // ┌-------┐ // | TESTS | // └-------┘ @@ -266,7 +292,7 @@ mod tests { use super::*; use crate::{ latex::{Latex, LatexEquation, LatexEquations, wrap_with_backslash_text}, - stdlib::{analyses::ode::ODESemanticsProblemData, models::*, theories::*}, + stdlib::{analyses::ode::ODESemanticsGeneralProblemData, models::*, theories::*}, tt, }; @@ -289,11 +315,13 @@ mod tests { fn numerical() { let th = Rc::new(th_polynomial_ode_system()); let model = unsigned_lotka_volterra_dynamics(th); - let data: ODESemanticsProblemData = ODESemanticsProblemData { - initial_values: [(name("a"), 1.0), (name("b"), 1.0), (name("c"), 1.0)] - .into_iter() - .collect(), - duration: 10.0, + let data = PolynomialODEProblemData { + general_data: ODESemanticsGeneralProblemData { + initial_values: [(name("a"), 1.0), (name("b"), 1.0), (name("c"), 1.0)] + .into_iter() + .collect(), + duration: 10.0, + }, parameter_data: PolynomialODEParameterData { coefficients: [ (name("A_growth"), 1.0), @@ -309,7 +337,7 @@ mod tests { }, }; let sys = PolynomialODEAnalysis::default().build_system(&model); - let analysis = data.extend_scalars(sys); + let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dA = A - 2 A B dB = 1.5 A B + 2 B - 3 B C diff --git a/packages/frontend/src/analysis/document.ts b/packages/frontend/src/analysis/document.ts index 7fc5f13917..2d0634633d 100644 --- a/packages/frontend/src/analysis/document.ts +++ b/packages/frontend/src/analysis/document.ts @@ -164,6 +164,7 @@ the set of fields changes. It allow new fields to be added. Renaming or removing existing fields is *not* supported. */ function migrateAnalysis(liveAnalysis: LiveAnalysisDoc) { + // TODO: use `catlog::src::stdlib::analyses::ode::v1::migrate` const theory = theoryForLiveAnalysis(liveAnalysis); const getAnalysisMeta = (analysisId: string) => { diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index d997c0a2c8..11d07ebe49 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,10 +1,9 @@ import { lazy } from "solid-js"; import type { - MassActionEquationsData, + MassActionVariant, MorType, ObType, - StochasticMassActionProblemData, } from "catlog-wasm"; import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import * as GraphLayoutConfig from "../visualization/graph_layout_config"; @@ -125,9 +124,13 @@ export function linearODE( help, component: (props) => , initialContent: () => ({ - coefficients: {}, - initialValues: {}, - duration: 10, + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + coefficients: {} + } }), }; } @@ -177,10 +180,14 @@ export function lotkaVolterra( help, component: (props) => , initialContent: () => ({ - interactionCoefficients: {}, - growthRates: {}, - initialValues: {}, - duration: 10, + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + interactionCoefficients: {}, + growthRates: {}, + } }), }; } @@ -218,7 +225,7 @@ export function massAction( stateType?: ObType; transitionType?: MorType; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "mass-action", name = "Mass-action dynamics", @@ -233,14 +240,36 @@ export function massAction( help, component: (props) => , initialContent: () => ({ - equationsData: { massConservationType: { type: "Balanced" } }, - rates: {}, - transitionProductionRates: {}, - transitionConsumptionRates: {}, - placeProductionRates: {}, - placeConsumptionRates: {}, - initialValues: {}, - duration: 10, + variant: "Balanced", + balanced: { + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + rates: {}, + }, + }, + unbalanced: { + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + productionRates: {}, + consumptionRates: {}, + }, + }, + veryUnbalanced: { + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + productionRates: {}, + consumptionRates: {}, + }, + }, }), }; } @@ -252,7 +281,7 @@ export function massActionEquations( ratesHaveGranularity: boolean; getEquations: Simulators.MassActionEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "mass-action-equations", name = "Mass-action dynamics equations", @@ -268,9 +297,9 @@ export function massActionEquations( component: (props) => ( ), - initialContent: () => ({ - massConservationType: { type: "Balanced" }, - }), + initialContent: () => ( + "Balanced" + ), }; } const MassActionEquationsDisplay = lazy(() => import("./analyses/mass_action_equations")); @@ -281,7 +310,7 @@ export function stochasticMassAction( stateType?: ObType; transitionType?: MorType; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "stochastic-mass-action", name = "Stochastic mass-action dynamics", @@ -463,9 +492,14 @@ export function polynomialODESimulation( help, component: (props) => , initialContent: () => ({ - coefficients: {}, - initialValues: {}, - duration: 10, + generalData: + { + initialValues: {}, + duration: 10, + }, + parameterData: { + coefficients: {}, + } }), }; } diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 5c73fcbbb5..5839a3a6e0 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -12,7 +12,7 @@ import { } from "catcolab-ui-components"; import { collectProduct, - type MassActionProblemData, + type PetriNetMassActionProblemData, type MorType, type ObType, type QualifiedName, @@ -28,8 +28,7 @@ import "./simulation.css"; /** Analyze a model using mass-action dynamics. */ export default function MassAction( - // TODO: switch to GeneralMassActionProblemData = MassActionProblemData | PPMassActionProblemData | PTMassActionProblemData - props: ModelAnalysisProps & { + props: ModelAnalysisProps & { ratesHaveGranularity: boolean; simulate: MassActionSimulator; stateType?: ObType; @@ -39,9 +38,9 @@ export default function MassAction( ) { const elaboratedModel = () => props.liveModel.elaboratedModel(); - // Irrelevant of the value of massConservationType, we only ever need a single - // schema for objects: each object needs to be assigned an initial value. + const variant = () => props.content.variant || "Balanced"; + // Each object needs to be assigned an initial value. const obGenerators = createMemo(() => { const model = elaboratedModel(); if (!model) { @@ -58,16 +57,35 @@ export default function MassAction( }, createNumericalColumn({ name: "Initial value", - data: (id) => props.content.initialValues[id], + data: (id) => { + switch (variant()) { + case "Balanced": + return props.content.balanced.initialValues[id]; + case "Unbalanced": + return props.content.unbalanced.initialValues[id]; + case "VeryUnbalanced": + return props.content.veryUnbalanced.initialValues[id]; + } + }, validate: (_, data) => data >= 0, setData: (id, data) => props.changeContent((content) => { - content.initialValues[id] = data; + switch (variant()) { + case "Balanced": + content.balanced.initialValues[id] = data; + break; + case "Unbalanced": + content.unbalanced.initialValues[id] = data; + break; + case "VeryUnbalanced": + content.veryUnbalanced.initialValues[id] = data; + break; + } }), }), ]; - // For morphisms, the data that we need now does depend on massConservationType. + // For morphisms, the data that we need depends on `variant: MassActionVariant`. // We don't simply want to get a list of morphism generators, but instead // account for the entire *interface* of each morphism. In a Petri net, this // consists of a list of input places and a list of output places for each @@ -140,9 +158,9 @@ export default function MassAction( }); // The schema that we use for the JSX element depends on the - // value of MassConservationType. We might as well construct all possibilities. + // value of `variant: MassActionVariant`. We might as well construct all possibilities. - // Firstly, the case MassConservationType = Balanced + // Firstly, the case `variant = Balanced`. const morSchema: ColumnSchema[] = [ { contentType: "string", @@ -151,17 +169,17 @@ export default function MassAction( }, createNumericalColumn({ name: "Rate (𝑟)", - data: (mor) => props.content.rates[mor], + data: (mor) => props.content.balanced.parameterData.rates[mor], default: 1, validate: (_, data) => data >= 0, setData: (mor, data) => props.changeContent((content) => { - content.rates[mor] = data; + content.balanced.parameterData.rates[mor] = data; }), }), ]; - // Secondly, the case MassConservationType = Unbalanced(PerTransition) + // Secondly, the case `MassActionVariant = Unbalanced`. const morInputSchema: ColumnSchema[] = [ { contentType: "string", @@ -170,12 +188,12 @@ export default function MassAction( }, createNumericalColumn({ name: "Consumption (𝜅)", - data: (mor) => props.content.transitionConsumptionRates[mor], + data: (mor) => props.content.unbalanced.parameterData.consumptionRates[mor], default: 1, validate: (_, data) => data >= 0, setData: (mor, data) => props.changeContent((content) => { - content.transitionConsumptionRates[mor] = data; + content.unbalanced.parameterData.consumptionRates[mor] = data; }), }), ]; @@ -187,17 +205,17 @@ export default function MassAction( }, createNumericalColumn({ name: "Production (𝜌)", - data: (mor) => props.content.transitionProductionRates[mor], + data: (mor) => props.content.unbalanced.parameterData.productionRates[mor], default: 1, validate: (_, data) => data >= 0, setData: (mor, data) => props.changeContent((content) => { - content.transitionProductionRates[mor] = data; + content.unbalanced.parameterData.productionRates[mor] = data; }), }), ]; - // Finally, the case MassConservationType = Unbalanced(PerPlace) + // Finally, the case `MassActionVariant = VeryUnbalanced`. const morInputsSchema: ColumnSchema<[QualifiedName, QualifiedName]>[] = [ { contentType: "string", @@ -211,15 +229,18 @@ export default function MassAction( }, createNumericalColumn({ name: "Consumption (𝜅)", - data: ([mor, input]) => props.content.placeConsumptionRates[mor]?.[input], + data: ([mor, input]) => + props.content.veryUnbalanced.parameterData.consumptionRates[mor]?.[input], default: 1, validate: (_, data) => data >= 0, setData: ([mor, input], data) => props.changeContent((content) => { - if (content.placeConsumptionRates[mor]) { - content.placeConsumptionRates[mor][input] = data; + if (content.veryUnbalanced.parameterData.consumptionRates[mor]) { + content.veryUnbalanced.parameterData.consumptionRates[mor][input] = data; } else { - content.placeConsumptionRates[mor] = { [input]: data }; + content.veryUnbalanced.parameterData.consumptionRates[mor] = { + [input]: data, + }; } }), }), @@ -237,15 +258,18 @@ export default function MassAction( }, createNumericalColumn({ name: "Production (𝜌)", - data: ([mor, output]) => props.content.placeProductionRates[mor]?.[output], + data: ([mor, output]) => + props.content.veryUnbalanced.parameterData.productionRates[mor]?.[output], default: 1, validate: (_, data) => data >= 0, setData: ([mor, output], data) => props.changeContent((content) => { - if (content.placeProductionRates[mor]) { - content.placeProductionRates[mor][output] = data; + if (content.veryUnbalanced.parameterData.productionRates[mor]) { + content.veryUnbalanced.parameterData.productionRates[mor][output] = data; } else { - content.placeProductionRates[mor] = { [output]: data }; + content.veryUnbalanced.parameterData.productionRates[mor] = { + [output]: data, + }; } }), }), @@ -254,24 +278,14 @@ export default function MassAction( // Now we can generate the parameter tables that will actually be rendered. const ParameterTables = () => ( - + - + - + @@ -282,18 +296,47 @@ export default function MassAction( const toplevelSchema: ColumnSchema[] = [ createNumericalColumn({ name: "Duration", - data: (_) => props.content.duration, + data: (_) => { + switch (variant()) { + case "Balanced": + return props.content.balanced.duration; + case "Unbalanced": + return props.content.unbalanced.duration; + case "VeryUnbalanced": + return props.content.veryUnbalanced.duration; + } + }, validate: (_, data) => data >= 0, setData: (_, data) => props.changeContent((content) => { - content.duration = data; + switch (variant()) { + case "Balanced": + content.balanced.duration = data; + break; + case "Unbalanced": + content.unbalanced.duration = data; + break; + case "VeryUnbalanced": + content.veryUnbalanced.duration = data; + break; + } }), }), ]; const result = createModelODEPlotWithEquations( () => props.liveModel.validatedModel(), - (model) => props.simulate(model, props.content), + (model) => props.simulate(model, props.content.balanced), + // (model) => { + // switch (variant()) { + // case "Balanced": + // return props.simulate(model, props.content.balanced); + // case "Unbalanced": + // return props.simulate(model, props.content.unbalanced); + // case "VeryUnbalanced": + // return props.simulate(model, props.content.veryUnbalanced); + // } + // }, ); const plotResult = () => result()?.plotData; 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 ef8feb2c0a..1e83b6974a 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -1,13 +1,15 @@ import { Show } from "solid-js"; import { CheckboxField, FormGroup, SelectField } from "catcolab-ui-components"; -import type { MassActionEquationsData, MassActionProblemData, RateGranularity } from "catlog-wasm"; +import type { MassActionVariant, PetriNetMassActionProblemData } from "catlog-wasm"; -/** Configuration of a mass-action analysis. */ -export type Config = MassActionEquationsData | MassActionProblemData; +/** Configuration of a mass-action analysis. Note that we need to be able to accept either an + * entire `PetriNetMassActionProblemData` or just a `MassActionVariant`, depending on whether we + * are in the simulation analysis widget or the equations one (respectively). */ +export type Config = MassActionVariant | PetriNetMassActionProblemData; -function isMassActionProblemData(config: Config): config is MassActionProblemData { - return (config as MassActionProblemData).equationsData !== undefined; +function isProblemData(config: Config): config is PetriNetMassActionProblemData { + return (config as PetriNetMassActionProblemData).variant !== undefined; } /** Form to configure a mass-action analysis. */ @@ -16,65 +18,72 @@ export function MassActionConfigForm(props: { changeConfig: (f: (config: Config) => void) => void; enableGranularity: boolean; }) { - function massActionEquationsData(): MassActionEquationsData { - if (isMassActionProblemData(props.config)) { - return props.config.equationsData; + function massActionVariant(): MassActionVariant { + if (isProblemData(props.config)) { + return props.config.variant || "Balanced"; } else { - return props.config; + return props.config || "Balanced"; } } - const massConservation = () => massActionEquationsData().massConservationType; const massConservationGranularity = () => { - const massConversarvation = massActionEquationsData().massConservationType; - return massConversarvation.type === "Unbalanced" - ? massConversarvation.granularity - : undefined; + switch (massActionVariant()) { + case "Balanced": + return "PerTransition"; + case "Unbalanced": + return "PerTransition"; + case "VeryUnbalanced": + return "PerPlace"; + } }; return ( { props.changeConfig((content) => { - let massActionEquationsData: MassActionEquationsData; - if (isMassActionProblemData(content)) { - massActionEquationsData = content.equationsData; - } else { - massActionEquationsData = content; - } - if (evt.currentTarget.checked) { - massActionEquationsData.massConservationType = { - type: "Balanced", - }; + if (isProblemData(content)) { + if (evt.currentTarget.checked) { + content.variant = "Balanced"; + } else { + content.variant = "Unbalanced"; + } } else { - massActionEquationsData.massConservationType = { - type: "Unbalanced", - granularity: "PerPlace", - }; + if (evt.currentTarget.checked) { + content = "Balanced"; + } else { + content = "Unbalanced"; + } } }); }} /> - + { props.changeConfig((content) => { - let massActionEquationsData: MassActionEquationsData; - if (isMassActionProblemData(content)) { - massActionEquationsData = content.equationsData; + if (isProblemData(content)) { + switch (evt.currentTarget.value) { + case "PerTransition": + content.variant = "Unbalanced"; + break; + case "PerPlace": + content.variant = "VeryUnbalanced"; + break; + } } else { - massActionEquationsData = content; - } - if ( - massActionEquationsData.massConservationType.type === "Unbalanced" - ) { - massActionEquationsData.massConservationType.granularity = evt - .currentTarget.value as RateGranularity; + switch (evt.currentTarget.value) { + case "PerTransition": + content = "Unbalanced"; + break; + case "PerPlace": + content = "VeryUnbalanced"; + break; + } } }); }} diff --git a/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx b/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx index 318769fe82..49d5a92b01 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx @@ -1,5 +1,5 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import type { MassActionEquationsData } from "catlog-wasm"; +import type { MassActionVariant } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { MassActionConfigForm } from "./mass_action_config_form"; import { createModelODELatex } from "./model_ode_plot"; @@ -9,8 +9,8 @@ import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function MassActionEquationsDisplay( - props: ModelAnalysisProps & { - content: MassActionEquationsData; + props: ModelAnalysisProps & { + content: MassActionVariant; getEquations: MassActionEquations; ratesHaveGranularity: boolean; title?: string; @@ -18,7 +18,7 @@ export default function MassActionEquationsDisplay( ) { 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 cdd3c0f357..b99c3adc55 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -2,10 +2,9 @@ import type { DblModel, KuramotoProblemData, LatexEquations, - LinearODEProblemData, LotkaVolterraProblemData, - MassActionEquationsData, - MassActionProblemData, + LinearODEProblemData, + PetriNetMassActionProblemData, ODEResult, ODEResultWithEquations, PolynomialODEProblemData, @@ -14,10 +13,11 @@ import type { export type { KuramotoProblemData, - LinearODEProblemData, LotkaVolterraProblemData, - MassActionProblemData, + LinearODEProblemData, + PetriNetMassActionProblemData, PolynomialODEProblemData, + StochasticMassActionProblemData, }; export type KuramotoSimulator = (model: DblModel, data: KuramotoProblemData) => ODEResult; @@ -33,12 +33,9 @@ export type LotkaVolterraSimulator = ( export type LotkaVolterraEquations = (model: DblModel) => LatexEquations; export type MassActionSimulator = ( model: DblModel, - data: MassActionProblemData, + data: PetriNetMassActionProblemData, ) => ODEResultWithEquations; -export type MassActionEquations = ( - model: DblModel, - data: MassActionEquationsData, -) => LatexEquations; +export type MassActionEquations = (model: DblModel) => LatexEquations; export type StochasticMassActionSimulator = ( model: DblModel, data: StochasticMassActionProblemData, diff --git a/packages/frontend/src/stdlib/theories/petri-net.ts b/packages/frontend/src/stdlib/theories/petri-net.ts index e584783df4..5407848824 100644 --- a/packages/frontend/src/stdlib/theories/petri-net.ts +++ b/packages/frontend/src/stdlib/theories/petri-net.ts @@ -79,8 +79,8 @@ export default function createPetriNetTheory(theoryMeta: TheoryMeta): Theory { }), analyses.massActionEquations({ ratesHaveGranularity: true, - getEquations(model, data) { - return thSymMonoidalCategory.massActionEquations(model, data); + getEquations(model) { + return thSymMonoidalCategory.massActionEquations(model); }, }), analyses.stochasticMassAction({ diff --git a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts index ad31b91bf8..745d207774 100644 --- a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts @@ -58,22 +58,23 @@ export default function createPrimitiveStockFlowTheory(theoryMeta: TheoryMeta): description: "Visualize the stock and flow diagram", help: "visualization", }), - analyses.massAction({ - ratesHaveGranularity: false, - simulate(model, data) { - return thCategoryLinks.massAction(model, data); - }, - transitionType: { - tag: "Hom", - content: { tag: "Basic", content: "Object" }, - }, - }), - analyses.massActionEquations({ - ratesHaveGranularity: false, - getEquations(model, data) { - return thCategoryLinks.massActionEquations(model, data); - }, - }), + // TODO: fix mass-action for stock-flow + // analyses.massAction({ + // ratesHaveGranularity: false, + // simulate(model, data) { + // return thCategoryLinks.massAction(model, data); + // }, + // transitionType: { + // tag: "Hom", + // content: { tag: "Basic", content: "Object" }, + // }, + // }), + // analyses.massActionEquations({ + // ratesHaveGranularity: false, + // getEquations(model, data) { + // return thCategoryLinks.massActionEquations(model, data); + // }, + // }), ], }); } From af83c7e8fcf378ba199457d3ff49c7e5e35c98a8 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Fri, 21 Aug 2026 19:55:39 +0100 Subject: [PATCH 05/83] WIP: Fixed all front-end analyses --- CHANGELOG.md | 5 - packages/catlog-wasm/src/analyses.rs | 9 +- packages/catlog-wasm/src/latex.rs | 5 +- packages/catlog-wasm/src/theories.rs | 163 +++++++++++++----- packages/catlog/src/latex.rs | 2 +- .../catlog/src/simulate/ode/polynomial.rs | 15 +- .../src/stdlib/analyses/ode/ode_semantics.rs | 77 +++++++-- .../src/stdlib/analyses/ode/v1/linear_ode.rs | 3 + .../stdlib/analyses/ode/v1/lotka_volterra.rs | 3 + .../src/stdlib/analyses/ode/v1/mass_action.rs | 158 ++++++++++------- .../src/stdlib/analyses/ode/v1/migrate.rs | 61 ++++--- .../stdlib/analyses/ode/v1/polynomial_ode.rs | 3 + packages/catlog/src/stdlib/models.rs | 4 +- .../src/help/analysis/mass-action.mdx | 12 +- .../frontend/src/help/logics/petri-net.mdx | 8 +- .../src/help/logics/primitive-stock-flow.mdx | 6 +- packages/frontend/src/stdlib/analyses.tsx | 58 +++++-- .../src/stdlib/analyses/linear_ode.tsx | 12 +- .../src/stdlib/analyses/lotka_volterra.tsx | 16 +- .../src/stdlib/analyses/mass_action.tsx | 72 ++++---- .../analyses/mass_action_config_form.tsx | 94 ++-------- .../stdlib/analyses/mass_action_equations.tsx | 8 +- .../analyses/ode_semantics_equations.tsx | 1 - .../analyses/polynomial_ode_simulation.tsx | 12 +- .../src/stdlib/analyses/simulator_types.ts | 11 +- .../frontend/src/stdlib/theories/petri-net.ts | 4 +- .../stdlib/theories/primitive-stock-flow.ts | 33 ++-- 27 files changed, 492 insertions(+), 363 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 052745e765..3765838e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,6 @@ 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. -## [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 - Specifying path equations (equational axioms) in ologs and schemas diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 406799dcd9..9c2a4ad621 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -264,12 +264,11 @@ pub(crate) mod tests { 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 = ode::PetriNetVeryUnbalancedMassActionAnalysis::default() + let system = ode::PetriNetPerPlaceMassActionAnalysis::default() .build_system(model.modal_unital().unwrap()); - let equations = ode_semantics_equations::( - &model, system, - ) - .unwrap(); + let equations = + ode_semantics_equations::(&model, system) + .unwrap(); let expected = LatexEquations(vec![ LatexEquation { diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index d080ebf81e..f0c9f5db00 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -46,7 +46,7 @@ mod tests { use catlog::latex::{Latex, LatexEquation, LatexEquations}; use catlog::stdlib::analyses::ode::{ LinearODEAnalysis, LotkaVolterraAnalysis, PetriNetBalancedMassActionAnalysis, - PetriNetUnbalancedMassActionAnalysis, PetriNetVeryUnbalancedMassActionAnalysis, + PetriNetPerPlaceMassActionAnalysis, PetriNetUnbalancedMassActionAnalysis, StockFlowBalancedMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, ode_semantics::*, }; @@ -217,11 +217,10 @@ mod tests { // 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 = PetriNetVeryUnbalancedMassActionAnalysis::default() + let equations = PetriNetPerPlaceMassActionAnalysis::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()), diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 19632ce059..18c38af047 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -342,34 +342,69 @@ impl ThCategoryLinks { DblTheory(self.0.clone().into()) } - // TODO: fix for stock-flow - // /// Simulates the mass-action ODE system derived from a model. - // #[wasm_bindgen(js_name = "massAction")] - // pub fn mass_action( - // &self, - // model: &DblModel, - // data: StockFlowMassActionProblemData, - // ) -> Result { - // let system = analyses::ode::StockFlowMassActionAnalysis::default() - // .build_system(model.discrete_tab()?); - // ode_semantics_simulation::( - // model, - // data.balanced, - // system, - // ) - // } - - // /// Returns the symbolic mass-action equations in LaTeX format. - // #[wasm_bindgen(js_name = "massActionEquations")] - // pub fn mass_action_equations( - // &self, - // model: &DblModel, - // data: MassActionVariant, - // ) -> Result { - // let system = analyses::ode::StockFlowMassActionAnalysis::default() - // .build_system(model.discrete_tab()?); - // ode_semantics_equations::(model, system) - // } + /// 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::RestrictedMassActionProblemData, + ) -> Result { + match data.variant { + analyses::ode::MassActionVariant::Balanced => { + let balanced_system = analyses::ode::StockFlowBalancedMassActionAnalysis::default() + .build_system(model.discrete_tab()?); + ode_semantics_simulation::< + analyses::ode::StockFlowBalancedMassActionSemantics, + analyses::ode::BalancedMassActionProblemData, + >(model, data.balanced, balanced_system) + } + analyses::ode::MassActionVariant::Unbalanced => { + let unbalanced_system = + analyses::ode::StockFlowUnbalancedMassActionAnalysis::default() + .build_system(model.discrete_tab()?); + ode_semantics_simulation::< + analyses::ode::StockFlowUnbalancedMassActionSemantics, + analyses::ode::UnbalancedMassActionProblemData, + >(model, data.unbalanced, unbalanced_system) + } + catlog::stdlib::analyses::ode::MassActionVariant::PerPlace => { + Err("Categories with links do not support fully unbalanced mass-action semantics." + .to_string()) + } + } + } + + /// Returns the symbolic mass-action equations in LaTeX format. + #[wasm_bindgen(js_name = "massActionEquations")] + pub fn mass_action_equations( + &self, + model: &DblModel, + data: analyses::ode::RestrictedMassActionProblemData, + ) -> Result { + match data.variant { + analyses::ode::MassActionVariant::Balanced => { + let balanced_system = analyses::ode::StockFlowBalancedMassActionAnalysis::default() + .build_system(model.discrete_tab()?); + ode_semantics_equations::( + model, + balanced_system, + ) + } + analyses::ode::MassActionVariant::Unbalanced => { + let unbalanced_system = + analyses::ode::StockFlowUnbalancedMassActionAnalysis::default() + .build_system(model.discrete_tab()?); + ode_semantics_equations::( + model, + unbalanced_system, + ) + } + analyses::ode::MassActionVariant::PerPlace => { + Err("Categories with links do not support fully unbalanced mass-action semantics." + .to_string()) + } + } + } } /// The theory of categories with signed links. @@ -410,23 +445,71 @@ impl ThSymMonoidalCategory { pub fn mass_action( &self, model: &DblModel, - data: analyses::ode::PetriNetMassActionProblemData, + data: analyses::ode::MassActionProblemData, ) -> Result { - let system = analyses::ode::PetriNetBalancedMassActionAnalysis::default() - .build_system(model.modal_unital()?); - ode_semantics_simulation::< - analyses::ode::PetriNetBalancedMassActionSemantics, - // TODO: fix this: allow PetriNetMassActionProblemData - analyses::ode::BalancedMassActionProblemData, - >(model, data.balanced, system) + match data.variant { + analyses::ode::MassActionVariant::Balanced => { + let balanced_system = analyses::ode::PetriNetBalancedMassActionAnalysis::default() + .build_system(model.modal_unital()?); + ode_semantics_simulation::< + analyses::ode::PetriNetBalancedMassActionSemantics, + analyses::ode::BalancedMassActionProblemData, + >(model, data.balanced, balanced_system) + } + analyses::ode::MassActionVariant::Unbalanced => { + let unbalanced_system = + analyses::ode::PetriNetUnbalancedMassActionAnalysis::default() + .build_system(model.modal_unital()?); + ode_semantics_simulation::< + analyses::ode::PetriNetUnbalancedMassActionSemantics, + analyses::ode::UnbalancedMassActionProblemData, + >(model, data.unbalanced, unbalanced_system) + } + analyses::ode::MassActionVariant::PerPlace => { + let per_place_system = analyses::ode::PetriNetPerPlaceMassActionAnalysis::default() + .build_system(model.modal_unital()?); + ode_semantics_simulation::< + analyses::ode::PetriNetPerPlaceMassActionSemantics, + analyses::ode::PerPlaceMassActionProblemData, + >(model, data.per_place, per_place_system) + } + } } /// Returns the symbolic mass-action equations in LaTeX format. #[wasm_bindgen(js_name = "massActionEquations")] - pub fn mass_action_equations(&self, model: &DblModel) -> Result { - let system = analyses::ode::PetriNetBalancedMassActionAnalysis::default() - .build_system(model.modal_unital()?); - ode_semantics_equations::(model, system) + pub fn mass_action_equations( + &self, + model: &DblModel, + data: analyses::ode::MassActionProblemData, + ) -> Result { + match data.variant { + analyses::ode::MassActionVariant::Balanced => { + let balanced_system = analyses::ode::PetriNetBalancedMassActionAnalysis::default() + .build_system(model.modal_unital()?); + ode_semantics_equations::( + model, + balanced_system, + ) + } + analyses::ode::MassActionVariant::Unbalanced => { + let unbalanced_system = + analyses::ode::PetriNetUnbalancedMassActionAnalysis::default() + .build_system(model.modal_unital()?); + ode_semantics_equations::( + model, + unbalanced_system, + ) + } + analyses::ode::MassActionVariant::PerPlace => { + let per_place_system = analyses::ode::PetriNetPerPlaceMassActionAnalysis::default() + .build_system(model.modal_unital()?); + ode_semantics_equations::( + model, + per_place_system, + ) + } + } } /// Simulates the stochastic mass-action system derived from a model. diff --git a/packages/catlog/src/latex.rs b/packages/catlog/src/latex.rs index 3e316cc3f8..5c1a48bb9b 100644 --- a/packages/catlog/src/latex.rs +++ b/packages/catlog/src/latex.rs @@ -70,7 +70,7 @@ pub trait ToLatex { /// 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` +/// to the frontend. For an example implementation see e.g. `analyses::ode::v1::mass_action` /// where this is implemented for `MassActionParameter`. pub trait ToLatexWithMap { /// Convert the object to its Latex representation, after applying the provided function `f` to diff --git a/packages/catlog/src/simulate/ode/polynomial.rs b/packages/catlog/src/simulate/ode/polynomial.rs index 281e7a2fa7..80f536a59a 100644 --- a/packages/catlog/src/simulate/ode/polynomial.rs +++ b/packages/catlog/src/simulate/ode/polynomial.rs @@ -102,20 +102,19 @@ 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 + /// Converts to equations as Latex strings, after applying some `f : &QualifiedName -> String` + /// to each of the variables and coefficients. This is 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`. + /// details, see `catlog-wasm::src::latex` where we use `to_latex_equations_with_map` with argument + /// `catlog-wasm::src::latex_names`. pub fn to_latex_equations_with_map String>( &self, f: F, ) -> LatexEquations where + // It would be much cleaner to only implement `to_latex_equations_with_map` in the case where + // `Var = QualifiedName` and `Coef = Parameter`, but I believe that Rust only + // allows one implementation per trait+type pair, irrelevant of generics. Var: Display + ToLatexWithMap, Coef: Display + ToLatexWithMap + DisplayCoef + Clone + PartialEq + One + Neg, Exp: Display + ToLatex + PartialEq + One, diff --git a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs index 35aa41d605..f493cc4288 100644 --- a/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs +++ b/packages/catlog/src/stdlib/analyses/ode/ode_semantics.rs @@ -3,21 +3,21 @@ //! 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 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()`. +//! is then passed to [`ode::v1::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. +//! In short, this module constructs multicategories from models, and [`ode::v1::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 a simple example see [`ode::lotka_volterra`], and for a more -//! complicated example see [`ode::mass_action`]. +//! see [`ode::v1::polynomial_ode`]; for a simple example see [`ode::v1::lotka_volterra`], and for a +//! more complicated example see [`ode::v1::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 +//! [`ode::v1::polynomial_ode`]: crate::stdlib::analyses::ode::v1::polynomial_ode +//! [`ode::v1::polynomial_ode::PolynomialODEAnalysis`]: crate::stdlib::analyses::ode::v1::polynomial_ode::PolynomialODEAnalysis +//! [`ode::v1::lotka_volterra`]: crate::stdlib::analyses::ode::v1::lotka_volterra +//! [`ode::v1::mass_action`]: crate::stdlib::analyses::ode::v1::mass_action use indexmap::IndexMap; use nalgebra::DVector; @@ -55,7 +55,8 @@ pub trait ODESemantics { /// 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`. + /// instructive example, see `mass_action::MassActionParameter` or + /// `lotka_volterra::LotkaVolterraParameter`. type ParameterType: ODEParameterType; /// The data describing the things that the ODE semantics "cares about". See the documentation /// for `ODESemanticsAnalysis` for more details. @@ -86,6 +87,45 @@ impl DblModelForODESemantics for ModalDblModel {} // | 1. ParameterType | // └------------------┘ +// Our way of viewing multicategories as polynomial ODE systems leads to a specific choice of +// interpretation of *parameters*, or coefficients. As explained in `ode::v1::polynomial_ode`, we +// build up our system of equations by giving *contributions*, i.e. monomials to add to the equation +// describing the first-order time derivative of a specific variable. But in order to be able to +// express arbitrary polynomial ODEs, we need the ability to multiply these monomials by scalar +// coefficients. However, rather than reducing directly to *numerical* equations, it is useful to +// be able to express "algebraic" equations. That is, rather than specifying e.g. the contributions +// +// d/dt(A) += 5 A^2 B +// d/dt(B) += 2 A B +// +// we would like to be able to specify the contributions +// +// d/dt(A) += c A^2 B +// d/dt(B) += d A B +// +// where c and d are scalars left unspecified up until the moment that we want to numerically +// simulate the system. +// +// But we also care about how to render these parameters. For example, consider Lotka-Volterra +// semantics, where we have two types of contributions: +// +// d/dt(B) += g B <- "growth" +// d/dt(B) += k A B <- "interaction" +// +// for parameters g and k. For clarity, it's helpful to make clear that the growth parameter "comes +// from" an object B, whereas the interaction parameter "comes from" an arrow A -> B, writing +// something like +// +// d/dt(B) += (g_B) B +// d/db(B) += (k_A^B) A B. +// +// To support this, we allow parameters to make explicit their dependencies, which enables the use +// of `ToLatexWithMap` for more intricate rendering of parameters (as above), but also gives the +// bonus of allowing us to specify that certain parameters should be made equal to one another. +// For the latter, consider the case of taking all of the k_A^B above and instead simply writing +// them as k_A. More precisely, we can appeal to this extra structure of explicit dependencies in +// `ODESemanticsScalarExtension::extend_scalars` (see documentation there below). + /// The type of the parameters in the ODE system need to be sufficiently nice, though /// (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 @@ -217,8 +257,8 @@ impl PolynomialODESystemBuilder

{ // | 2. AnalysisType | // └-----------------┘ -/// This trait is where we define the actual ODE semantics, in the implementation of -/// `build_system_builder()`, whereas `build_system()` will almost certainly always use the default +/// 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 @@ -246,6 +286,11 @@ pub trait ODESemanticsAnalysis: // | 3. ParameterData | // └------------------┘ +// As the counterpart to `ParameterType` above, we make formal here the data needed in order to turn +// a system of equations with "algebraic" parameters (with explicit dependencies) into numerical +// parameters (i.e. coefficients). For a useful example, see the implementations of balanced versus +// unbalanced mass-action semantics in `ode::v1::mass_action`. + /// This trait is required to be implemented for the `ParameterData` type, and is used to convert /// the formal parameters of type `ODEParameterType` to floats. pub trait ODESemanticsScalarExtension { @@ -298,8 +343,14 @@ impl ODESemanticsGeneralProblemData { } } +/// Data for a numerical ODE semantics problem, which contains data generic across semantics and +/// also data specific to the ODE semantics in question. pub trait ODESemanticsProblemData: Clone { + /// The type of parameter data, which must implement `ODESemanticsScalarExtension::extend_scalars`. type ParameterData: ODESemanticsScalarExtension; + /// General data associated to an ODE problem: initial values and duration. fn general_data(self) -> ODESemanticsGeneralProblemData; + /// Parameter-specific data associated to an ODE problem, depending on the associated type of + /// parameter data. fn parameter_data(self) -> Self::ParameterData; } diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs index 3801d01e40..c251adbc76 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/linear_ode.rs @@ -192,6 +192,7 @@ impl ODESemanticsScalarExtension<::Parameter } } +/// Data for a numerical linear ODE system. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( @@ -200,8 +201,10 @@ impl ODESemanticsScalarExtension<::Parameter )] #[derive(Clone)] pub struct LinearODEProblemData { + /// Data common to all ODE problems. #[cfg_attr(feature = "serde", serde(rename = "generalData"))] pub general_data: ODESemanticsGeneralProblemData, + /// Data specific to linear ODE problems. #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] pub parameter_data: LinearODEParameterData, } diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs index d4b5becc7e..476b44059b 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/lotka_volterra.rs @@ -225,6 +225,7 @@ impl ODESemanticsScalarExtension<::Param } } +/// Data for a numerical Lotka-Volterra system. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( @@ -233,8 +234,10 @@ impl ODESemanticsScalarExtension<::Param )] #[derive(Clone)] pub struct LotkaVolterraProblemData { + /// Data common to all ODE problems. #[cfg_attr(feature = "serde", serde(rename = "generalData"))] pub general_data: ODESemanticsGeneralProblemData, + /// Data specific to Lotka-Volterra problems. #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] pub parameter_data: LotkaVolterraParameterData, } diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs index 4c87fe11e9..158d4ae014 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/mass_action.rs @@ -46,15 +46,14 @@ use crate::{ // // The variations of mass-action determine the coefficients of these contributions: // -// - In the balanced (i.e. classical) case, all four contributions will have the same coefficient. +// - In the *balanced* (i.e. classical) case, all four contributions will have the same coefficient. // -// - In the unbalanced (per-transition) case, the two positive contributions will have the same +// - In the *unbalanced* (per-transition) case, the two positive contributions will have the same // coefficient (the "production rate" of the transition) and the two negative contributions will // have the same coefficient (the "consumption rate" of the transition). // -// - In the very unbalanced (per-place) case, the production (resp. consumption) rates from the -// unbalanced case are now potentially distinct, i.e. each coefficient depends on the specific -// (transition, place) pair. +// - In the *per-place* case, the production (resp. consumption) rates from the unbalanced case are +// now potentially distinct, i.e. each coefficient depends on a *pair* (transition, place). // // For stock-flow diagrams, 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 @@ -312,6 +311,7 @@ impl ODESemanticsScalarExtension for BalancedMassAc } } +/// Data for a numerical balanced mass-action system. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( @@ -320,8 +320,10 @@ impl ODESemanticsScalarExtension for BalancedMassAc )] #[derive(Clone)] pub struct BalancedMassActionProblemData { + /// Data common to all ODE problems. #[cfg_attr(feature = "serde", serde(rename = "generalData"))] pub general_data: ODESemanticsGeneralProblemData, + /// Data specific to balanced mass-action problems. #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] pub parameter_data: BalancedMassActionParameterData, } @@ -338,6 +340,18 @@ impl ODESemanticsProblemData } } +impl ODESemanticsProblemData + for BalancedMassActionProblemData +{ + type ParameterData = BalancedMassActionParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + // ┌-------------------------┐ // | B. UNBALANCED SEMANTICS | // └-------------------------┘ @@ -611,6 +625,7 @@ impl ODESemanticsScalarExtension } } +/// Data for a numerical unbalanced mass-action system. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( @@ -619,8 +634,10 @@ impl ODESemanticsScalarExtension )] #[derive(Clone)] pub struct UnbalancedMassActionProblemData { + /// Data common to all ODE problems. #[cfg_attr(feature = "serde", serde(rename = "generalData"))] pub general_data: ODESemanticsGeneralProblemData, + /// Data specific to unbalanced mass-action problems. #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] pub parameter_data: UnbalancedMassActionParameterData, } @@ -637,28 +654,40 @@ impl ODESemanticsProblemData } } -// ┌------------------------------┐ -// | C. VERY UNBALANCED SEMANTICS | -// └------------------------------┘ +impl ODESemanticsProblemData + for UnbalancedMassActionProblemData +{ + type ParameterData = UnbalancedMassActionParameterData; + fn general_data(self) -> ODESemanticsGeneralProblemData { + self.general_data + } + fn parameter_data(self) -> Self::ParameterData { + self.parameter_data + } +} + +// ┌------------------------┐ +// | C. PER-PLACE SEMANTICS | +// └------------------------┘ -/// Unbalanced ("per-place") mass-action semantics for Petri nets. -pub struct PetriNetVeryUnbalancedMassActionSemantics; -impl ODESemantics for PetriNetVeryUnbalancedMassActionSemantics { +/// Per-place mass-action semantics for Petri nets. +pub struct PetriNetPerPlaceMassActionSemantics; +impl ODESemantics for PetriNetPerPlaceMassActionSemantics { type ModelType = ModalDblModel; - type ParameterType = VeryUnbalancedMassActionParameter; - type AnalysisType = PetriNetVeryUnbalancedMassActionAnalysis; - type ParameterData = VeryUnbalancedMassActionParameterData; + type ParameterType = PerPlaceMassActionParameter; + type AnalysisType = PetriNetPerPlaceMassActionAnalysis; + type ParameterData = PerPlaceMassActionParameterData; } // ┌--------------------┐ // | C.1. ParameterType | // └--------------------┘ -/// Parameters for very unbalanced ("per-place") mass-action semantics, where each transition has +/// Parameters for per-place mass-action semantics, where each transition has /// individual consumption (resp. production) rates for each of its input (resp. output) stock. Note /// that this only makes sense for Petri nets, not stock-flow diagrams. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum VeryUnbalancedMassActionParameter { +pub enum PerPlaceMassActionParameter { /// The consumption parameter associated to a specific input stock to a transition. Consumption { /// The transition in question. @@ -675,47 +704,47 @@ pub enum VeryUnbalancedMassActionParameter { }, } -impl fmt::Display for VeryUnbalancedMassActionParameter { +impl fmt::Display for PerPlaceMassActionParameter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => { + PerPlaceMassActionParameter::Consumption { transition, input_place } => { write!(f, "Consumption([{}] <- {})", transition, input_place) } - VeryUnbalancedMassActionParameter::Production { transition, output_place } => { + PerPlaceMassActionParameter::Production { transition, output_place } => { write!(f, "Production([{}] -> {})", transition, output_place) } } } } -impl ToLatexWithMap for VeryUnbalancedMassActionParameter { +impl ToLatexWithMap for PerPlaceMassActionParameter { fn to_latex_with_map String>(&self, f: T) -> Latex { match self { - VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => { + PerPlaceMassActionParameter::Consumption { transition, input_place } => { Latex(format!("\\kappa_{{{}}}^{{{}}}", f(transition), f(input_place))) } - VeryUnbalancedMassActionParameter::Production { transition, output_place } => { + PerPlaceMassActionParameter::Production { transition, output_place } => { Latex(format!("\\rho_{{{}}}^{{{}}}", f(transition), f(output_place))) } } } } -impl ODEParameterType for VeryUnbalancedMassActionParameter {} +impl ODEParameterType for PerPlaceMassActionParameter {} // ┌-------------------┐ // | C.2. AnalysisType | // └-------------------┘ /// Unbalanced ("per-transition") mass-action ODE analysis for Petri nets. -pub struct PetriNetVeryUnbalancedMassActionAnalysis { +pub struct PetriNetPerPlaceMassActionAnalysis { /// Object type for places. pub place_ob_type: ModalObType, /// Morphism type for transitions. pub transition_mor_type: ModalMorType, } -impl Default for PetriNetVeryUnbalancedMassActionAnalysis { +impl Default for PetriNetPerPlaceMassActionAnalysis { fn default() -> Self { let ob_type = ModalObType::new(name("Object")); Self { @@ -727,14 +756,14 @@ impl Default for PetriNetVeryUnbalancedMassActionAnalysis { impl ODESemanticsAnalysis< - ::ModelType, - ::ParameterType, - > for PetriNetVeryUnbalancedMassActionAnalysis + ::ModelType, + ::ParameterType, + > for PetriNetPerPlaceMassActionAnalysis { fn build_system_builder( &self, model: &ModalDblModel, - ) -> PolynomialODESystemBuilder { + ) -> PolynomialODESystemBuilder { let mut builder = PolynomialODESystemBuilder::new(); // For each place, we create a variable. @@ -754,7 +783,7 @@ impl for output in outputs.clone() { let id = output.cons(name_seg("ToOutput")).cons(transition.only().unwrap()); - let output_parameter = VeryUnbalancedMassActionParameter::Production { + let output_parameter = PerPlaceMassActionParameter::Production { transition: transition.clone(), output_place: output.clone(), }; @@ -769,7 +798,7 @@ impl for input in inputs.clone() { let id = input.cons(name_seg("FromInput")).cons(transition.only().unwrap()); - let input_parameter = VeryUnbalancedMassActionParameter::Consumption { + let input_parameter = PerPlaceMassActionParameter::Consumption { transition: transition.clone(), input_place: input.clone(), }; @@ -799,7 +828,7 @@ impl tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] #[derive(Clone)] -pub struct VeryUnbalancedMassActionParameterData { +pub struct PerPlaceMassActionParameterData { /// Map from morphism IDs to (map from input objects to consumption rate coefficients). #[cfg_attr(feature = "serde", serde(rename = "consumptionRates"))] pub(crate) consumption_rates: HashMap>, @@ -809,22 +838,20 @@ pub struct VeryUnbalancedMassActionParameterData { pub(crate) production_rates: HashMap>, } -impl ODESemanticsScalarExtension - for VeryUnbalancedMassActionParameterData -{ +impl ODESemanticsScalarExtension for PerPlaceMassActionParameterData { fn extend_scalars( &self, - sys: PolynomialSystem, i8>, + sys: PolynomialSystem, i8>, ) -> PolynomialSystem { let sys = sys.extend_scalars(|poly| { poly.eval(|parameter| match parameter { - VeryUnbalancedMassActionParameter::Consumption { transition, input_place } => self + PerPlaceMassActionParameter::Consumption { transition, input_place } => self .consumption_rates .get(transition) .and_then(|rate| rate.get(input_place)) .copied() .unwrap_or_default(), - VeryUnbalancedMassActionParameter::Production { transition, output_place } => self + PerPlaceMassActionParameter::Production { transition, output_place } => self .production_rates .get(transition) .and_then(|rate| rate.get(output_place)) @@ -837,6 +864,7 @@ impl ODESemanticsScalarExtension } } +/// Data for a numerical per-place mass-action system. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( @@ -844,17 +872,19 @@ impl ODESemanticsScalarExtension tsify(into_wasm_abi, from_wasm_abi, hashmap_as_object) )] #[derive(Clone)] -pub struct VeryUnbalancedMassActionProblemData { +pub struct PerPlaceMassActionProblemData { + /// Data common to all ODE problems. #[cfg_attr(feature = "serde", serde(rename = "generalData"))] pub general_data: ODESemanticsGeneralProblemData, + /// Data specific to per-place mass-action problems. #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] - pub parameter_data: VeryUnbalancedMassActionParameterData, + pub parameter_data: PerPlaceMassActionParameterData, } -impl ODESemanticsProblemData - for VeryUnbalancedMassActionProblemData +impl ODESemanticsProblemData + for PerPlaceMassActionProblemData { - type ParameterData = VeryUnbalancedMassActionParameterData; + type ParameterData = PerPlaceMassActionParameterData; fn general_data(self) -> ODESemanticsGeneralProblemData { self.general_data } @@ -863,9 +893,9 @@ impl ODESemanticsProblemData } } -// ┌------------┐ -// | MIGRATIONS | -// └------------┘ +// ┌------------------------------┐ +// | MIGRATIONS AND WASM BINDINGS | +// └------------------------------┘ // For backwards compatibility to when there was a *single* mass-action semantics with three // internal variants, we give here some wrappers that will be useful in `analyses::ode::v1::migrate` @@ -877,35 +907,45 @@ impl ODESemanticsProblemData #[cfg_attr(feature = "serde-wasm", tsify(into_wasm_abi, from_wasm_abi))] #[derive(PartialEq, Eq, Hash, Clone)] pub enum MassActionVariant { + /// The balanced (i.e. classical) case. Balanced, + /// The unbalanced ("per-flow"/"per-transition") case. Unbalanced, - VeryUnbalanced, + /// The per-place case. + PerPlace, } -/// In order for `migrate_petri_net_mass_action_v0_to_v1` to have a well-defined return type, we -/// unify the three mass-action semantics for Petri nets into a single struct. +/// For `migrate_stock_flow_mass_action_v0_to_v1` to have a well-defined return type, we unify both +/// balanced and unbalanced mass-action semantics into a single struct. #[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 PetriNetMassActionProblemData { +pub struct RestrictedMassActionProblemData { + /// The mass-action variant of interest, which may be switched at any point. pub variant: MassActionVariant, + /// Problem data for balanced mass-action. pub balanced: BalancedMassActionProblemData, + /// Problem data for unbalanced mass-action. pub unbalanced: UnbalancedMassActionProblemData, - #[cfg_attr(feature = "serde", serde(rename = "veryUnbalanced"))] - pub very_unbalanced: VeryUnbalancedMassActionProblemData, } -/// In order for `migrate_stock_flow_mass_action_v0_to_v1` to have a well-defined return type, we -/// unify the two mass-action semantics for stock-flow diagrams into a single struct. +/// For `migrate_petri_net_mass_action_v0_to_v1` to have a well-defined return type, we unify the +/// all three mass-action semantics into a single struct. #[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 StockFlowMassActionProblemData { +pub struct MassActionProblemData { + /// The mass-action variant of interest, which may be switched at any point. pub variant: MassActionVariant, + /// Problem data for balanced mass-action. pub balanced: BalancedMassActionProblemData, + /// Problem data for unbalanced mass-action. pub unbalanced: UnbalancedMassActionProblemData, + /// Problem data for per-place mass-action. + #[cfg_attr(feature = "serde", serde(rename = "perPlace"))] + pub per_place: PerPlaceMassActionProblemData, } // ┌-------┐ @@ -983,7 +1023,7 @@ mod tests { fn unbalanced_petri_per_place() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let sys = PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); + let sys = PetriNetPerPlaceMassActionAnalysis::default().build_system(&model); let expected = expect!([r#" dx = -Consumption([f] <- x) c x dy = Production([f] -> y) c x @@ -1096,14 +1136,14 @@ mod tests { fn unbalanced_petri_per_place_numerical() { let th = Rc::new(th_sym_monoidal_category()); let model = catalyzed_reaction(th); - let data = VeryUnbalancedMassActionProblemData { + let data = PerPlaceMassActionProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: [(name("x"), 1.0), (name("y"), 1.5), (name("c"), 2.0)] .into_iter() .collect(), duration: 10.0, }, - parameter_data: VeryUnbalancedMassActionParameterData { + parameter_data: PerPlaceMassActionParameterData { consumption_rates: [( name("f"), [(name("x"), 2.0), (name("c"), 3.0)].into_iter().collect(), @@ -1118,7 +1158,7 @@ mod tests { .collect(), }, }; - let sys = PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); + let sys = PetriNetPerPlaceMassActionAnalysis::default().build_system(&model); let analysis = data.parameter_data.extend_scalars(sys); let expected = expect!([r#" dx = -2 c x diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs index 9556b65d1f..3a6233f4a3 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/migrate.rs @@ -6,21 +6,20 @@ use crate::stdlib::analyses::ode::*; pub fn migrate_linear_ode_v0_to_v1( v0: v0::linear_ode::LinearODEProblemData, ) -> LinearODEProblemData { - let v1 = LinearODEProblemData { + LinearODEProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values, duration: v0.duration, }, parameter_data: LinearODEParameterData { coefficients: v0.coefficients }, - }; - v1 + } } /// Migration for problem data for Lotka-Volterra. pub fn migrate_lotka_volterra_v0_to_v1( v0: v0::lotka_volterra::LotkaVolterraProblemData, ) -> LotkaVolterraProblemData { - let v1 = LotkaVolterraProblemData { + LotkaVolterraProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values, duration: v0.duration, @@ -29,28 +28,38 @@ pub fn migrate_lotka_volterra_v0_to_v1( interaction_coeffs: v0.interaction_coeffs, growth_rates: v0.growth_rates, }, - }; - v1 + } } /// Migration for problem data for polynomial ODE. pub fn migrate_polynomial_ode_v0_to_v1( v0: v0::polynomial_ode::PolynomialODEProblemData, ) -> PolynomialODEProblemData { - let v1 = PolynomialODEProblemData { + PolynomialODEProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values, duration: v0.duration, }, parameter_data: PolynomialODEParameterData { coefficients: v0.coefficients }, - }; - v1 + } +} + +fn migrate_mass_action_variant(v0: v0::mass_action::MassConservationType) -> MassActionVariant { + match v0 { + v0::mass_action::MassConservationType::Balanced => MassActionVariant::Balanced, + v0::mass_action::MassConservationType::Unbalanced(rate_granularity) => { + match rate_granularity { + v0::mass_action::RateGranularity::PerTransition => MassActionVariant::Unbalanced, + v0::mass_action::RateGranularity::PerPlace => MassActionVariant::PerPlace, + } + } + } } /// Migration for problem data for mass-action on a Petri net. pub fn migrate_petri_net_mass_action_v0_to_v1( v0: v0::mass_action::MassActionProblemData, -) -> PetriNetMassActionProblemData { +) -> MassActionProblemData { let balanced = BalancedMassActionProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values.clone(), @@ -68,29 +77,29 @@ pub fn migrate_petri_net_mass_action_v0_to_v1( production_rates: v0.transition_production_rates, }, }; - let very_unbalanced = VeryUnbalancedMassActionProblemData { + let per_place = PerPlaceMassActionProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values, duration: v0.duration, }, - parameter_data: VeryUnbalancedMassActionParameterData { + parameter_data: PerPlaceMassActionParameterData { consumption_rates: v0.place_consumption_rates, production_rates: v0.place_production_rates, }, }; - PetriNetMassActionProblemData { - variant: MassActionVariant::Balanced, + MassActionProblemData { + variant: migrate_mass_action_variant(v0.equations_data.mass_conservation_type), balanced, unbalanced, - very_unbalanced, + per_place, } } /// Migration for problem data for mass-action on a stock-flow diagram. pub fn migrate_stock_flow_mass_action_v0_to_v1( v0: v0::mass_action::MassActionProblemData, -) -> StockFlowMassActionProblemData { +) -> RestrictedMassActionProblemData { let balanced = BalancedMassActionProblemData { general_data: ODESemanticsGeneralProblemData { initial_values: v0.initial_values.clone(), @@ -108,8 +117,8 @@ pub fn migrate_stock_flow_mass_action_v0_to_v1( production_rates: v0.transition_production_rates, }, }; - StockFlowMassActionProblemData { - variant: MassActionVariant::Balanced, + RestrictedMassActionProblemData { + variant: migrate_mass_action_variant(v0.equations_data.mass_conservation_type), balanced, unbalanced, } @@ -124,8 +133,8 @@ mod test { stdlib::{ analyses::ode::{ LinearODEAnalysis, LotkaVolterraAnalysis, ODESemanticsAnalysis, - PetriNetBalancedMassActionAnalysis, PetriNetUnbalancedMassActionAnalysis, - PetriNetVeryUnbalancedMassActionAnalysis, PolynomialODEAnalysis, + PetriNetBalancedMassActionAnalysis, PetriNetPerPlaceMassActionAnalysis, + PetriNetUnbalancedMassActionAnalysis, PolynomialODEAnalysis, StockFlowBalancedMassActionAnalysis, StockFlowUnbalancedMassActionAnalysis, }, backward_link, catalyzed_reaction, negative_feedback, th_category_links, @@ -218,8 +227,6 @@ mod test { expected.assert_eq(&analysis.to_string()); } - // TODO: Petri net mass-action migration tests. - #[test] fn petri_net_mass_action_v0_to_v1_migration() { let th = Rc::new(th_sym_monoidal_category()); @@ -272,16 +279,14 @@ mod test { "#]); unbalanced_expected.assert_eq(&unbalanced_analysis.to_string()); - let very_unbalanced_system = - PetriNetVeryUnbalancedMassActionAnalysis::default().build_system(&model); - let very_unbalanced_analysis = - v1_data.very_unbalanced.parameter_data.extend_scalars(very_unbalanced_system); - let very_unbalanced_expected = expect!([r#" + let per_place_system = PetriNetPerPlaceMassActionAnalysis::default().build_system(&model); + let per_place_analysis = v1_data.per_place.parameter_data.extend_scalars(per_place_system); + let per_place_expected = expect!([r#" dx = -2 c x dy = 1.5 c x dc = -0.5 c x "#]); - very_unbalanced_expected.assert_eq(&very_unbalanced_analysis.to_string()); + per_place_expected.assert_eq(&per_place_analysis.to_string()); } #[test] diff --git a/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs index 389ea5b705..cb6e5ca2d9 100644 --- a/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs +++ b/packages/catlog/src/stdlib/analyses/ode/v1/polynomial_ode.rs @@ -256,6 +256,7 @@ impl ODESemanticsScalarExtension<::Param } } +/// Data for a numerical polynomial ODE system. #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde-wasm", derive(Tsify))] #[cfg_attr( @@ -264,8 +265,10 @@ impl ODESemanticsScalarExtension<::Param )] #[derive(Clone)] pub struct PolynomialODEProblemData { + /// Data common to all ODE problems. #[cfg_attr(feature = "serde", serde(rename = "generalData"))] pub general_data: ODESemanticsGeneralProblemData, + /// Data specific to polynomial ODE problems. #[cfg_attr(feature = "serde", serde(rename = "parameterData"))] pub parameter_data: PolynomialODEParameterData, } diff --git a/packages/catlog/src/stdlib/models.rs b/packages/catlog/src/stdlib/models.rs index d9f34e369f..ac7d5106d3 100644 --- a/packages/catlog/src/stdlib/models.rs +++ b/packages/catlog/src/stdlib/models.rs @@ -132,7 +132,7 @@ fn backward_link_of_type(th: Rc, link_type: TabMorType) -> Di model } -/// A reaction involving three species, one playing the role of a catalyst: [x,c] -> [y,c]. +/// A reaction involving three species, one playing the role of a catalyst: `[x,c] -> [y,c]`. /// /// A free symmetric monoidal category, viewed as a reaction network. pub fn catalyzed_reaction(th: Rc>) -> ModalDblModel { @@ -151,7 +151,7 @@ pub fn catalyzed_reaction(th: Rc>) -> ModalDblModel [I, I]. +/// The SIR model viewed as a reaction network: `[S,I] -> [I, I]`. pub fn sir_petri(th: Rc>) -> ModalDblModel { let (ob_type, op) = (ModalObType::new(name("Object")), name("tensor")); let mut model = ModalDblModel::new(th); diff --git a/packages/frontend/src/help/analysis/mass-action.mdx b/packages/frontend/src/help/analysis/mass-action.mdx index 29c2980889..66ce182106 100644 --- a/packages/frontend/src/help/analysis/mass-action.mdx +++ b/packages/frontend/src/help/analysis/mass-action.mdx @@ -3,16 +3,14 @@

Initial value: $\mathbb{R}_{\geqslant0}$
The initial value of the concentration
-
Mass conservation: `True | False`
-
Whether or not flows should preserve mass
+
Rate granularity: `Mass conserving | Unbalanced | Per place`
+
Should a single rate be prescribed per flow (mass conserving), or should separate consumption and production rates be given; in the latter case, should they be given per flow (unbalanced) or per input/output place (per place)
Rate: $\mathbb{R}_{\geqslant0}$
-
*(Only if **Mass conservation** = `True`)* The rate coefficient ($r$) of the reaction
-
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
+
*(Only if **RateGranularity** = Mass conserving)* The rate coefficient ($r$) of the reaction
Consumption: $\mathbb{R}_{\geqslant0}$
-
The consumption rate coefficient ($\kappa$), either per flow or per stock (input/output) depending on **Mass conservation**
+
The consumption rate coefficient ($\kappa$), either per flow or per (input/output) place, depending on **RateGranularity**
Production: $\mathbb{R}_{\geqslant0}$
-
The production rate coefficient ($\rho$), either per flow or per stock (input/output) depending on **Mass conservation**
+
The production rate coefficient ($\rho$), either per flow or per (input/output) place, depending on **RateGranularity**
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 3ac06d8acc..c48c73b340 100644 --- a/packages/frontend/src/help/logics/petri-net.mdx +++ b/packages/frontend/src/help/logics/petri-net.mdx @@ -43,9 +43,9 @@ Each place is given an **initial value**. Since this is mass-action dynamics, we make the assumption that transitions depend only on their input places; we also make the assumption that transitions are determined by constants (namely their rate coefficients). Equations governing transitions are also _non-linear_, depending multiplicatively on their inputs. -The rest of the analysis depends on whether **mass conservation** is checked as true or false. +The rest of the analysis depends on the setting of **rate granularity**. -- If **mass conservation** is checked as _true_, a transition $A\xrightarrow{T}B$ between places $A$ and $B$ is interpreted as the equations $\{\dot{A}=-r_TA,\dot{B}=r_TA\}$, i.e. the transition $T$ goes out of $A$ and into $B$. Here, $r_T$ is the **rate coefficient** of the transition. +- If **rate granularity** is set to _mass conserving_, then a transition $A\xrightarrow{T}B$ between places $A$ and $B$ is interpreted as the equations $\{\dot{A}=-r_TA,\dot{B}=r_TA\}$, i.e. the transition $T$ goes out of $A$ and into $B$. Here, $r_T$ is the **rate coefficient** of the transition. A transition $[A,B]\xrightarrow{T}[X,Y]$ gives the equations - $\dot{A}=-r_T AB$ @@ -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 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. +- If **rate granularity** is set to _unbalanced_, 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 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$. +- If **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$. A transition $[A,B]\xrightarrow{T}[X,Y]$ gives the equations - $\dot{A}=-\kappa_T^A AB$ diff --git a/packages/frontend/src/help/logics/primitive-stock-flow.mdx b/packages/frontend/src/help/logics/primitive-stock-flow.mdx index 1c5fd4a0c5..24c5e809e6 100644 --- a/packages/frontend/src/help/logics/primitive-stock-flow.mdx +++ b/packages/frontend/src/help/logics/primitive-stock-flow.mdx @@ -36,11 +36,11 @@ Each stock is given an **initial value**. Since this is mass-action dynamics, we make the assumption that flows depend only on their input stocks; since our stock-flow diagrams are _primitive_, flows are determined by constants (namely their rate coefficients). -The rest of the analysis depends on whether **mass conservation** is checked as true or false. +The rest of the analysis depends on the setting of **rate granularity**. -- If **mass conservation** is checked as _true_, a flow $S\overset{f}{\Rightarrow}T$ between stocks $S$ and $T$ gives the equations $\dot{S}=-r_fS$ and $\dot{T}=r_fS$, i.e. the flow $f$ goes out of $S$ and into $T$. Here, $r_f$ is the **rate coefficient** of the flow. +- If **rate granularity** is set to _mass conserving_, then a flow $S\overset{f}{\Rightarrow}T$ between stocks $S$ and $T$ gives the equations $\dot{S}=-r_fS$ and $\dot{T}=r_fS$, i.e. the flow $f$ goes out of $S$ and into $T$. Here, $r_f$ is the **rate coefficient** of the flow. -- If **mass conservation** is checked as _false_, then a flow has distinct **consumption** and **production**, giving the equations $\dot{S}=-\kappa_f$ and $\dot{T}=\rho_f$. Here, $\kappa_f$ and $\rho_f$ are the **consumption** and **production** rate coefficients of the flow. +- If **rate granularity** is set to _unbalanced_, then a flow has distinct **consumption** and **production**, giving the equations $\dot{S}=-\kappa_f$ and $\dot{T}=\rho_f$. Here, $\kappa_f$ and $\rho_f$ are the **consumption** and **production** rate coefficients of the flow. In both cases, flows add linearly, i.e. in an arbitrary stock-flow diagram (without links), the equation governing a stock $S$ is $\dot{S}=\sum_{S_\text{in}}f_i - \sum_{S_\text{out}}f_j$, where $S_\text{in}$ (resp. $S_\text{out}$) is the set of all flows whose target (resp. source) is $S$. diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 11d07ebe49..d89df1bba2 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,10 +1,6 @@ import { lazy } from "solid-js"; -import type { - MassActionVariant, - MorType, - ObType, -} from "catlog-wasm"; +import type { MorType, ObType } from "catlog-wasm"; import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import * as GraphLayoutConfig from "../visualization/graph_layout_config"; import type * as Checkers from "./analyses/checker_types"; @@ -129,8 +125,8 @@ export function linearODE( duration: 10, }, parameterData: { - coefficients: {} - } + coefficients: {}, + }, }), }; } @@ -187,7 +183,7 @@ export function lotkaVolterra( parameterData: { interactionCoefficients: {}, growthRates: {}, - } + }, }), }; } @@ -225,7 +221,7 @@ export function massAction( stateType?: ObType; transitionType?: MorType; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "mass-action", name = "Mass-action dynamics", @@ -260,7 +256,7 @@ export function massAction( consumptionRates: {}, }, }, - veryUnbalanced: { + perPlace: { generalData: { initialValues: {}, duration: 10, @@ -281,7 +277,7 @@ export function massActionEquations( ratesHaveGranularity: boolean; getEquations: Simulators.MassActionEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "mass-action-equations", name = "Mass-action dynamics equations", @@ -297,9 +293,38 @@ export function massActionEquations( component: (props) => ( ), - initialContent: () => ( - "Balanced" - ), + initialContent: () => ({ + variant: "Balanced", + balanced: { + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + rates: {}, + }, + }, + unbalanced: { + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + productionRates: {}, + consumptionRates: {}, + }, + }, + perPlace: { + generalData: { + initialValues: {}, + duration: 10, + }, + parameterData: { + productionRates: {}, + consumptionRates: {}, + }, + }, + }), }; } const MassActionEquationsDisplay = lazy(() => import("./analyses/mass_action_equations")); @@ -492,14 +517,13 @@ export function polynomialODESimulation( help, component: (props) => , initialContent: () => ({ - generalData: - { + generalData: { initialValues: {}, duration: 10, }, parameterData: { coefficients: {}, - } + }, }), }; } diff --git a/packages/frontend/src/stdlib/analyses/linear_ode.tsx b/packages/frontend/src/stdlib/analyses/linear_ode.tsx index 94c42538bb..8a02a1a06b 100644 --- a/packages/frontend/src/stdlib/analyses/linear_ode.tsx +++ b/packages/frontend/src/stdlib/analyses/linear_ode.tsx @@ -33,11 +33,11 @@ export default function LinearODE( }, createNumericalColumn({ name: "Initial value", - data: (id) => props.content.initialValues[id], + data: (id) => props.content.generalData.initialValues[id], validate: (_, data) => data >= 0, setData: (id, data) => props.changeContent((content) => { - content.initialValues[id] = data; + content.generalData.initialValues[id] = data; }), }), ]; @@ -50,12 +50,12 @@ export default function LinearODE( }, createNumericalColumn({ name: "Coefficient", - data: (id) => props.content.coefficients[id], + data: (id) => props.content.parameterData.coefficients[id], default: 1, validate: (_, data) => data >= 0, setData: (id, data) => props.changeContent((content) => { - content.coefficients[id] = data; + content.parameterData.coefficients[id] = data; }), }), ]; @@ -63,11 +63,11 @@ export default function LinearODE( const toplevelSchema: ColumnSchema[] = [ createNumericalColumn({ name: "Duration", - data: (_) => props.content.duration, + data: (_) => props.content.generalData.duration, validate: (_, data) => data >= 0, setData: (_, data) => props.changeContent((content) => { - content.duration = data; + content.generalData.duration = data; }), }), ]; diff --git a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx index 062f281897..d9ddb0e84c 100644 --- a/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx +++ b/packages/frontend/src/stdlib/analyses/lotka_volterra.tsx @@ -33,19 +33,19 @@ export default function LotkaVolterra( }, createNumericalColumn({ name: "Initial value", - data: (id) => props.content.initialValues[id], + data: (id) => props.content.generalData.initialValues[id], validate: (_, data) => data >= 0, setData: (id, data) => props.changeContent((content) => { - content.initialValues[id] = data; + content.generalData.initialValues[id] = data; }), }), createNumericalColumn({ name: "Growth/decay", - data: (id) => props.content.growthRates[id], + data: (id) => props.content.parameterData.growthRates[id], setData: (id, data) => props.changeContent((content) => { - content.growthRates[id] = data; + content.parameterData.growthRates[id] = data; }), }), ]; @@ -58,12 +58,12 @@ export default function LotkaVolterra( }, createNumericalColumn({ name: "Interaction", - data: (id) => props.content.interactionCoefficients[id], + data: (id) => props.content.parameterData.interactionCoefficients[id], default: 1, validate: (_, data) => data >= 0, setData: (id, data) => props.changeContent((content) => { - content.interactionCoefficients[id] = data; + content.parameterData.interactionCoefficients[id] = data; }), }), ]; @@ -71,11 +71,11 @@ export default function LotkaVolterra( const toplevelSchema: ColumnSchema[] = [ createNumericalColumn({ name: "Duration", - data: (_) => props.content.duration, + data: (_) => props.content.generalData.duration, validate: (_, data) => data >= 0, setData: (_, data) => props.changeContent((content) => { - content.duration = data; + content.generalData.duration = data; }), }), ]; diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index 5839a3a6e0..e5180cff17 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -12,7 +12,7 @@ import { } from "catcolab-ui-components"; import { collectProduct, - type PetriNetMassActionProblemData, + type MassActionProblemData, type MorType, type ObType, type QualifiedName, @@ -28,7 +28,7 @@ import "./simulation.css"; /** Analyze a model using mass-action dynamics. */ export default function MassAction( - props: ModelAnalysisProps & { + props: ModelAnalysisProps & { ratesHaveGranularity: boolean; simulate: MassActionSimulator; stateType?: ObType; @@ -60,11 +60,11 @@ export default function MassAction( data: (id) => { switch (variant()) { case "Balanced": - return props.content.balanced.initialValues[id]; + return props.content.balanced.generalData.initialValues[id]; case "Unbalanced": - return props.content.unbalanced.initialValues[id]; - case "VeryUnbalanced": - return props.content.veryUnbalanced.initialValues[id]; + return props.content.unbalanced.generalData.initialValues[id]; + case "PerPlace": + return props.content.perPlace.generalData.initialValues[id]; } }, validate: (_, data) => data >= 0, @@ -72,13 +72,13 @@ export default function MassAction( props.changeContent((content) => { switch (variant()) { case "Balanced": - content.balanced.initialValues[id] = data; + content.balanced.generalData.initialValues[id] = data; break; case "Unbalanced": - content.unbalanced.initialValues[id] = data; + content.unbalanced.generalData.initialValues[id] = data; break; - case "VeryUnbalanced": - content.veryUnbalanced.initialValues[id] = data; + case "PerPlace": + content.perPlace.generalData.initialValues[id] = data; break; } }), @@ -158,8 +158,8 @@ export default function MassAction( }); // The schema that we use for the JSX element depends on the - // value of `variant: MassActionVariant`. We might as well construct all possibilities. - + // value of `variant: MassActionVariant`. + // // Firstly, the case `variant = Balanced`. const morSchema: ColumnSchema[] = [ { @@ -215,7 +215,7 @@ export default function MassAction( }), ]; - // Finally, the case `MassActionVariant = VeryUnbalanced`. + // Finally, the case `MassActionVariant = PerPlace`. const morInputsSchema: ColumnSchema<[QualifiedName, QualifiedName]>[] = [ { contentType: "string", @@ -230,15 +230,15 @@ export default function MassAction( createNumericalColumn({ name: "Consumption (𝜅)", data: ([mor, input]) => - props.content.veryUnbalanced.parameterData.consumptionRates[mor]?.[input], + props.content.perPlace.parameterData.consumptionRates[mor]?.[input], default: 1, validate: (_, data) => data >= 0, setData: ([mor, input], data) => props.changeContent((content) => { - if (content.veryUnbalanced.parameterData.consumptionRates[mor]) { - content.veryUnbalanced.parameterData.consumptionRates[mor][input] = data; + if (content.perPlace.parameterData.consumptionRates[mor]) { + content.perPlace.parameterData.consumptionRates[mor][input] = data; } else { - content.veryUnbalanced.parameterData.consumptionRates[mor] = { + content.perPlace.parameterData.consumptionRates[mor] = { [input]: data, }; } @@ -259,15 +259,15 @@ export default function MassAction( createNumericalColumn({ name: "Production (𝜌)", data: ([mor, output]) => - props.content.veryUnbalanced.parameterData.productionRates[mor]?.[output], + props.content.perPlace.parameterData.productionRates[mor]?.[output], default: 1, validate: (_, data) => data >= 0, setData: ([mor, output], data) => props.changeContent((content) => { - if (content.veryUnbalanced.parameterData.productionRates[mor]) { - content.veryUnbalanced.parameterData.productionRates[mor][output] = data; + if (content.perPlace.parameterData.productionRates[mor]) { + content.perPlace.parameterData.productionRates[mor][output] = data; } else { - content.veryUnbalanced.parameterData.productionRates[mor] = { + content.perPlace.parameterData.productionRates[mor] = { [output]: data, }; } @@ -285,7 +285,7 @@ export default function MassAction(
- + @@ -299,11 +299,11 @@ export default function MassAction( data: (_) => { switch (variant()) { case "Balanced": - return props.content.balanced.duration; + return props.content.balanced.generalData.duration; case "Unbalanced": - return props.content.unbalanced.duration; - case "VeryUnbalanced": - return props.content.veryUnbalanced.duration; + return props.content.unbalanced.generalData.duration; + case "PerPlace": + return props.content.perPlace.generalData.duration; } }, validate: (_, data) => data >= 0, @@ -311,13 +311,13 @@ export default function MassAction( props.changeContent((content) => { switch (variant()) { case "Balanced": - content.balanced.duration = data; + content.balanced.generalData.duration = data; break; case "Unbalanced": - content.unbalanced.duration = data; + content.unbalanced.generalData.duration = data; break; - case "VeryUnbalanced": - content.veryUnbalanced.duration = data; + case "PerPlace": + content.perPlace.generalData.duration = data; break; } }), @@ -326,17 +326,7 @@ export default function MassAction( const result = createModelODEPlotWithEquations( () => props.liveModel.validatedModel(), - (model) => props.simulate(model, props.content.balanced), - // (model) => { - // switch (variant()) { - // case "Balanced": - // return props.simulate(model, props.content.balanced); - // case "Unbalanced": - // return props.simulate(model, props.content.unbalanced); - // case "VeryUnbalanced": - // return props.simulate(model, props.content.veryUnbalanced); - // } - // }, + (model) => props.simulate(model, props.content), ); const plotResult = () => result()?.plotData; 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 1e83b6974a..21f3b944de 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_config_form.tsx @@ -1,97 +1,35 @@ import { Show } from "solid-js"; -import { CheckboxField, FormGroup, SelectField } from "catcolab-ui-components"; -import type { MassActionVariant, PetriNetMassActionProblemData } from "catlog-wasm"; - -/** Configuration of a mass-action analysis. Note that we need to be able to accept either an - * entire `PetriNetMassActionProblemData` or just a `MassActionVariant`, depending on whether we - * are in the simulation analysis widget or the equations one (respectively). */ -export type Config = MassActionVariant | PetriNetMassActionProblemData; - -function isProblemData(config: Config): config is PetriNetMassActionProblemData { - return (config as PetriNetMassActionProblemData).variant !== undefined; -} +import { FormGroup, SelectField } from "catcolab-ui-components"; +import type { MassActionVariant, MassActionProblemData } from "catlog-wasm"; /** Form to configure a mass-action analysis. */ export function MassActionConfigForm(props: { - config: Config; - changeConfig: (f: (config: Config) => void) => void; + config: MassActionProblemData; + changeConfig: (f: (config: MassActionProblemData) => void) => void; enableGranularity: boolean; }) { function massActionVariant(): MassActionVariant { - if (isProblemData(props.config)) { - return props.config.variant || "Balanced"; - } else { - return props.config || "Balanced"; - } + return props.config.variant || "Balanced"; } - const massConservationGranularity = () => { - switch (massActionVariant()) { - case "Balanced": - return "PerTransition"; - case "Unbalanced": - return "PerTransition"; - case "VeryUnbalanced": - return "PerPlace"; - } - }; - return ( - { props.changeConfig((content) => { - if (isProblemData(content)) { - if (evt.currentTarget.checked) { - content.variant = "Balanced"; - } else { - content.variant = "Unbalanced"; - } - } else { - if (evt.currentTarget.checked) { - content = "Balanced"; - } else { - content = "Unbalanced"; - } - } + content.variant = evt.currentTarget.value as MassActionVariant; }); }} - /> - - { - props.changeConfig((content) => { - if (isProblemData(content)) { - switch (evt.currentTarget.value) { - case "PerTransition": - content.variant = "Unbalanced"; - break; - case "PerPlace": - content.variant = "VeryUnbalanced"; - break; - } - } else { - switch (evt.currentTarget.value) { - case "PerTransition": - content = "Unbalanced"; - break; - case "PerPlace": - content = "VeryUnbalanced"; - break; - } - } - }); - }} - > - - - - + > + + + + + + ); } diff --git a/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx b/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx index 49d5a92b01..0a4f0565b0 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action_equations.tsx @@ -1,16 +1,14 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import type { MassActionVariant } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { MassActionConfigForm } from "./mass_action_config_form"; import { createModelODELatex } from "./model_ode_plot"; -import type { MassActionEquations } from "./simulator_types"; +import type { MassActionEquations, MassActionProblemData } from "./simulator_types"; import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function MassActionEquationsDisplay( - props: ModelAnalysisProps & { - content: MassActionVariant; + props: ModelAnalysisProps & { getEquations: MassActionEquations; ratesHaveGranularity: boolean; title?: string; @@ -18,7 +16,7 @@ export default function MassActionEquationsDisplay( ) { const latexEquations = createModelODELatex( () => props.liveModel.validatedModel(), - (model) => props.getEquations(model), + (model) => props.getEquations(model, props.content), ); return ( diff --git a/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx index 774a37d857..6672484643 100644 --- a/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx @@ -8,7 +8,6 @@ import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function ODESemanticsEquationsDisplay( props: ModelAnalysisProps & { - content: null; getEquations: (model: DblModel) => LatexEquations; title?: string; }, diff --git a/packages/frontend/src/stdlib/analyses/polynomial_ode_simulation.tsx b/packages/frontend/src/stdlib/analyses/polynomial_ode_simulation.tsx index 81668ae4c9..733f8bd1de 100644 --- a/packages/frontend/src/stdlib/analyses/polynomial_ode_simulation.tsx +++ b/packages/frontend/src/stdlib/analyses/polynomial_ode_simulation.tsx @@ -67,10 +67,10 @@ export default function PolynomialODESimulation( }, createNumericalColumn({ name: "Initial value", - data: (id) => props.content.initialValues[id], + data: (id) => props.content.generalData.initialValues[id], setData: (id, data) => props.changeContent((content) => { - content.initialValues[id] = data; + content.generalData.initialValues[id] = data; }), }), ]; @@ -92,12 +92,12 @@ export default function PolynomialODESimulation( }, createNumericalColumn({ name: "Coefficient (𝜆)", - data: (mor) => props.content.coefficients[mor], + data: (mor) => props.content.parameterData.coefficients[mor], validate: validateSign(), default: 1, setData: (mor, data) => props.changeContent((content) => { - content.coefficients[mor] = data; + content.parameterData.coefficients[mor] = data; }), }), ]; @@ -106,11 +106,11 @@ export default function PolynomialODESimulation( const toplevelSchema: ColumnSchema[] = [ createNumericalColumn({ name: "Duration", - data: (_) => props.content.duration, + data: (_) => props.content.generalData.duration, validate: (_, data) => data >= 0, setData: (_, data) => props.changeContent((content) => { - content.duration = data; + content.generalData.duration = data; }), }), ]; diff --git a/packages/frontend/src/stdlib/analyses/simulator_types.ts b/packages/frontend/src/stdlib/analyses/simulator_types.ts index b99c3adc55..edace9909b 100644 --- a/packages/frontend/src/stdlib/analyses/simulator_types.ts +++ b/packages/frontend/src/stdlib/analyses/simulator_types.ts @@ -4,7 +4,7 @@ import type { LatexEquations, LotkaVolterraProblemData, LinearODEProblemData, - PetriNetMassActionProblemData, + MassActionProblemData, ODEResult, ODEResultWithEquations, PolynomialODEProblemData, @@ -15,7 +15,7 @@ export type { KuramotoProblemData, LotkaVolterraProblemData, LinearODEProblemData, - PetriNetMassActionProblemData, + MassActionProblemData, PolynomialODEProblemData, StochasticMassActionProblemData, }; @@ -33,9 +33,12 @@ export type LotkaVolterraSimulator = ( export type LotkaVolterraEquations = (model: DblModel) => LatexEquations; export type MassActionSimulator = ( model: DblModel, - data: PetriNetMassActionProblemData, + data: MassActionProblemData, ) => ODEResultWithEquations; -export type MassActionEquations = (model: DblModel) => LatexEquations; +export type MassActionEquations = ( + model: DblModel, + variant: MassActionProblemData, +) => LatexEquations; export type StochasticMassActionSimulator = ( model: DblModel, data: StochasticMassActionProblemData, diff --git a/packages/frontend/src/stdlib/theories/petri-net.ts b/packages/frontend/src/stdlib/theories/petri-net.ts index 5407848824..e584783df4 100644 --- a/packages/frontend/src/stdlib/theories/petri-net.ts +++ b/packages/frontend/src/stdlib/theories/petri-net.ts @@ -79,8 +79,8 @@ export default function createPetriNetTheory(theoryMeta: TheoryMeta): Theory { }), analyses.massActionEquations({ ratesHaveGranularity: true, - getEquations(model) { - return thSymMonoidalCategory.massActionEquations(model); + getEquations(model, data) { + return thSymMonoidalCategory.massActionEquations(model, data); }, }), analyses.stochasticMassAction({ diff --git a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts index 745d207774..ad31b91bf8 100644 --- a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts @@ -58,23 +58,22 @@ export default function createPrimitiveStockFlowTheory(theoryMeta: TheoryMeta): description: "Visualize the stock and flow diagram", help: "visualization", }), - // TODO: fix mass-action for stock-flow - // analyses.massAction({ - // ratesHaveGranularity: false, - // simulate(model, data) { - // return thCategoryLinks.massAction(model, data); - // }, - // transitionType: { - // tag: "Hom", - // content: { tag: "Basic", content: "Object" }, - // }, - // }), - // analyses.massActionEquations({ - // ratesHaveGranularity: false, - // getEquations(model, data) { - // return thCategoryLinks.massActionEquations(model, data); - // }, - // }), + analyses.massAction({ + ratesHaveGranularity: false, + simulate(model, data) { + return thCategoryLinks.massAction(model, data); + }, + transitionType: { + tag: "Hom", + content: { tag: "Basic", content: "Object" }, + }, + }), + analyses.massActionEquations({ + ratesHaveGranularity: false, + getEquations(model, data) { + return thCategoryLinks.massActionEquations(model, data); + }, + }), ], }); } From 99124b3c7248bdc378bae40dc7d06d85fc60395f Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 25 Aug 2026 20:38:10 +0100 Subject: [PATCH 06/83] fix bad rebase --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3765838e6c..7b83334d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ 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] + ### Added - Specifying path equations (equational axioms) in ologs and schemas From 963edcc1c504607c32919503a42d64805c148d08 Mon Sep 17 00:00:00 2001 From: Tim Hosgood Date: Tue, 25 Aug 2026 21:06:32 +0100 Subject: [PATCH 07/83] FIX: Lots of live testing to catch edge cases --- packages/catlog-wasm/src/analyses.rs | 11 +++++++++++ packages/catlog-wasm/src/latex.rs | 11 +++++++---- packages/frontend/src/stdlib/analyses.tsx | 14 +++++++------- .../frontend/src/stdlib/analyses/mass_action.tsx | 4 ++-- .../stdlib/analyses/ode_semantics_equations.tsx | 4 ++-- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 9c2a4ad621..11b5a0dc53 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -55,6 +55,17 @@ pub(crate) fn ode_semantics_equations( Ok(system.to_latex_equations_with_map(|param| latex_names(model)(param))) } +/// This type is simply to accommodate for analyses that require no input data. This is because +/// `initialContent` for `ModelAnalysisMeta` in `frontend/src/stdlib/analyses.tsx` really needs to +/// be an entire JavaScript object, so we can't simply work with `ModelAnalysisMeta` +/// and set `initialContent: () => null`. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi, missing_as_null)] +pub struct NullWrapper { + /// Trivial data. + pub content: (), +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/packages/catlog-wasm/src/latex.rs b/packages/catlog-wasm/src/latex.rs index f0c9f5db00..d3fbb84abc 100644 --- a/packages/catlog-wasm/src/latex.rs +++ b/packages/catlog-wasm/src/latex.rs @@ -13,13 +13,13 @@ use super::model::DblModel; pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { |id: &QualifiedName| { if let Some(ob_label) = model.ob_namespace.label(id) { + // If the uuid has a name in the object namespace. wrap_with_backslash_text(ob_label.to_string()) } else if let Some(mor_label) = model.mor_namespace.label(id) { + // If the uuid has a name in the morphism namespace. wrap_with_backslash_text(mor_label.to_string()) - } else { - let (dom, cod) = model - .mor_generator_dom_cod(id) - .expect("Morphism in equation system should have domain and codomain."); + } else if let Some((dom, cod)) = model.mor_generator_dom_cod(id) { + // If the uuid corresponds to a morphism without a name. let dom_labels: Vec = model .get_ob_label(&dom) .expect("Object in equation system should have a label.") @@ -37,6 +37,9 @@ pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String list_object_as_latex(dom_labels), list_object_as_latex(cod_labels) ) + } else { + // If the uuid corresponds to an unnamed (e.g. freshly-created) object. + "\\text{unnamed}".to_string() } } } diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index d89df1bba2..a36bed33b8 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -1,6 +1,6 @@ import { lazy } from "solid-js"; -import type { MorType, ObType } from "catlog-wasm"; +import type { MorType, ObType, NullWrapper } from "catlog-wasm"; import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import * as GraphLayoutConfig from "../visualization/graph_layout_config"; import type * as Checkers from "./analyses/checker_types"; @@ -137,7 +137,7 @@ export function linearODEEquations( options: Partial & { getEquations: Simulators.LinearODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "linear-ode-equations", name = "Linear ODE equations", @@ -153,7 +153,7 @@ export function linearODEEquations( component: (props) => ( ), - initialContent: () => null, + initialContent: () => ({ content: null }), }; } @@ -194,7 +194,7 @@ export function lotkaVolterraEquations( options: Partial & { getEquations: Simulators.LotkaVolterraEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "lotka-volterra-equations", name = "Lotka–Volterra equations", @@ -210,7 +210,7 @@ export function lotkaVolterraEquations( component: (props) => ( ), - initialContent: () => null, + initialContent: () => ({ content: null }), }; } @@ -475,7 +475,7 @@ export function polynomialODEEquations( options: Partial & { getEquations: Simulators.PolynomialODEEquations; }, -): ModelAnalysisMeta { +): ModelAnalysisMeta { const { id = "polynomial-ode-equations", name = "Polynomial ODE equations", @@ -491,7 +491,7 @@ export function polynomialODEEquations( component: (props) => ( ), - initialContent: () => null, + initialContent: () => ({ content: null }), }; } diff --git a/packages/frontend/src/stdlib/analyses/mass_action.tsx b/packages/frontend/src/stdlib/analyses/mass_action.tsx index e5180cff17..07514ea73c 100644 --- a/packages/frontend/src/stdlib/analyses/mass_action.tsx +++ b/packages/frontend/src/stdlib/analyses/mass_action.tsx @@ -70,7 +70,7 @@ export default function MassAction( validate: (_, data) => data >= 0, setData: (id, data) => props.changeContent((content) => { - switch (variant()) { + switch (content.variant) { case "Balanced": content.balanced.generalData.initialValues[id] = data; break; @@ -309,7 +309,7 @@ export default function MassAction( validate: (_, data) => data >= 0, setData: (_, data) => props.changeContent((content) => { - switch (variant()) { + switch (content.variant) { case "Balanced": content.balanced.generalData.duration = data; break; diff --git a/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx index 6672484643..7e75d4def9 100644 --- a/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx +++ b/packages/frontend/src/stdlib/analyses/ode_semantics_equations.tsx @@ -1,5 +1,5 @@ import { BlockTitle, ExpandableTable, KatexDisplay } from "catcolab-ui-components"; -import { DblModel, LatexEquations } from "catlog-wasm"; +import { DblModel, LatexEquations, NullWrapper } from "catlog-wasm"; import type { ModelAnalysisProps } from "../../analysis"; import { createModelODELatex } from "./model_ode_plot"; @@ -7,7 +7,7 @@ import "./simulation.css"; /** Display the symbolic mass-action dynamics equations for a model. */ export default function ODESemanticsEquationsDisplay( - props: ModelAnalysisProps & { + props: ModelAnalysisProps & { getEquations: (model: DblModel) => LatexEquations; title?: string; }, From bbb105f4188966180c48a4f54a866d57f6bfe791 Mon Sep 17 00:00:00 2001 From: tslil-topos Date: Thu, 27 Aug 2026 16:05:18 +0100 Subject: [PATCH 08/83] OBEE-42 integrate catcolab-documents with LLM, add validation enforcement, enhance retry logic (#1424) This change moves LLM turn execution onto the new catcolab-documents APIs. The new `runLLMConversationTurn` and `retryLastLLMConversationResponse` handle all scoping, prompting, and validation concerns so that callers can be agnostic with respect to the underlying details. ```typescript const result = await runLLMConversationTurn( conversation, store, inferenceKey, { content: userMessage, files, }, streamingCallback, ); ``` The result tells the caller how to proceed: ```typescript type LLMConversationTurnResult = | { tag: "Completed"; content: string } | { tag: "Incomplete"; reason: string } | { tag: "Failed"; error: string } | { tag: "Retryable"; error: string; attempts: readonly LLMInteraction[]; }; ``` and the streaming operates on ```typescript export type ChatTurnEvent = | { tag: "Streaming"; delta: string; snapshot: string } | { tag: "RunTool"; code: string } | { tag: "ToolResult"; code: string; result: EvalResult }; ``` Under the hood, document edits are performed on isolated in-memory copies with pseudo-transaction semantics. If all documents in the scope pass validity and the LLM is done, then we update the contents of the real documents that were passed in to reflect the changes. The current logic is roughly as follows: * runLLMConversationTurn: persists one user message and invokes runChatTurn exactly once. * runChatTurn: invokes the inference provider via the SDK's runTools, limited MAX_PROVIDER_REQUESTS_PER_ATTEMPT * `contextExec` runs JS locally against in-memory copies of the relevant documents, if execution succeeds a validation hook runs and reports any errors back for the llm to fix. * when runChatTurn finishes (because the LLM is done or because it exhausted its attempts) we inspect the situation by running validate and looking at the reason that runChatTurn finished Persisting the LLM responses to the conversation and committing the changes is governed by the following policy | Outcome | Persist model/tool messages | Commit document edits | Result | |----------------------------------------------|-----------------------------|-----------------------|-------------------------------------| | Final response, valid scope | Yes | Yes | `Completed` | | Provider limit/incomplete response, valid scope | Yes | Yes | `Incomplete` | | Validation failure | No | No | `Retryable` | | Network/protocol failure | No | No | `Retryable` | Because we're reporting errors via a hook in the tool call, these will be persisted in the actual LLMConversation document for later inspection whenever the interactions are persisted. We are making the conscious trade-off for simplicity here: if the documents are in an invalid state and the LLM does something unrelated it will be told again that they are in an invalid state. Future changes might enable damage tracking or other sophisticated mechanisms which would allow us to turn down the "noise" on this process. --- packages/documents/test/instances.test.ts | 6 +- packages/frontend/default.nix | 6 + packages/frontend/package.json | 2 + packages/frontend/pnpm-lock.yaml | 6 + packages/frontend/src/inference/chat.test.ts | 16 +- packages/frontend/src/inference/chat.ts | 96 ++-- .../src/inference/context_exec.test.ts | 7 +- .../frontend/src/inference/context_exec.ts | 7 +- .../src/inference/llm_conversation_adapter.ts | 14 +- .../src/llm_conversation/document.test.ts | 432 +++++++++--------- .../frontend/src/llm_conversation/document.ts | 255 +++++------ .../llm_conversation/document_transaction.ts | 82 ++++ .../src/llm_conversation/execution_scope.ts | 178 ++++++++ .../src/llm_conversation/scoped_document.ts | 104 +++++ packages/logics/src/cell-types.ts | 22 + packages/logics/src/petri-net.ts | 6 + packages/logics/src/simple-olog.ts | 5 + packages/logics/src/simple-schema.ts | 8 + 18 files changed, 856 insertions(+), 396 deletions(-) create mode 100644 packages/frontend/src/llm_conversation/document_transaction.ts create mode 100644 packages/frontend/src/llm_conversation/execution_scope.ts create mode 100644 packages/frontend/src/llm_conversation/scoped_document.ts create mode 100644 packages/logics/src/cell-types.ts diff --git a/packages/documents/test/instances.test.ts b/packages/documents/test/instances.test.ts index 0f984a2503..ca01818658 100644 --- a/packages/documents/test/instances.test.ts +++ b/packages/documents/test/instances.test.ts @@ -202,7 +202,7 @@ describe("instance schema validation", () => { }); describe("tabular instances", () => { - test("tables and headers are derived and rows can be edited", { timeout: 10_000 }, async () => { + test("tables and headers are derived and rows can be edited", { timeout: 20_000 }, async () => { const binder = createBinder(); const schema = await binder.createNotebook(SimpleSchema, { title: "Company schema", @@ -333,7 +333,7 @@ describe("tabular instances", () => { test( "mistyped row references and literals report their field paths", - { timeout: 10_000 }, + { timeout: 20_000 }, async () => { const binder = createBinder(); const schema = await binder.createNotebook(SimpleSchema, { @@ -430,7 +430,7 @@ describe("tabular instances", () => { test( "deleting a referenced row reports a dangling field path", - { timeout: 10_000 }, + { timeout: 20_000 }, async () => { const binder = createBinder(); const schema = await binder.createNotebook(SimpleSchema, { diff --git a/packages/frontend/default.nix b/packages/frontend/default.nix index 903eab3f55..6c7c34cedc 100644 --- a/packages/frontend/default.nix +++ b/packages/frontend/default.nix @@ -39,6 +39,8 @@ let "packages/frontend" = ../frontend/pnpm-lock.yaml; "packages/ui-components" = ../ui-components/pnpm-lock.yaml; "packages/document-methods" = ../document-methods/pnpm-lock.yaml; + "packages/documents" = ../documents/pnpm-lock.yaml; + "packages/logics" = ../logics/pnpm-lock.yaml; "packages/backend/pkg" = ../backend/pkg/pnpm-lock.yaml; "tools/vite-plugin-monorepo-dedupe" = ../../tools/vite-plugin-monorepo-dedupe/pnpm-lock.yaml; }; @@ -79,6 +81,8 @@ let ../../tools/vite-plugin-monorepo-dedupe ../../tools/oxlint-plugin-catcolab ../../packages/document-methods + ../../packages/documents + ../../packages/logics ../../packages/backend/pkg ]; }; @@ -210,6 +214,8 @@ let cp -r tools/vite-plugin-monorepo-dedupe $out/tools/ cp -r tools/oxlint-plugin-catcolab $out/tools/ cp -r packages/document-methods $out/packages/ + cp -r packages/documents $out/packages/ + cp -r packages/logics $out/packages/ mkdir -p $out/packages/document-types cp -r packages/document-types/pkg $out/packages/document-types/ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index fbd84988d8..b7188638ff 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -50,6 +50,7 @@ "catcolab-api": "link:../backend/pkg", "catcolab-document-methods": "link:../document-methods", "catcolab-document-types": "link:../document-types/pkg", + "catcolab-documents": "link:../documents", "catcolab-ui-components": "link:../ui-components", "catlog-wasm": "link:../catlog-wasm/dist/pkg-browser", "echarts": "^6.1.0", @@ -84,6 +85,7 @@ "@types/html-escaper": "^3.0.4", "@types/mdx": "^2.0.13", "@types/node": "^24.0.0", + "catcolab-logics": "link:../logics", "eslint-plugin-solid": "^0.14.5", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", diff --git a/packages/frontend/pnpm-lock.yaml b/packages/frontend/pnpm-lock.yaml index bc0f97dc39..287db274e1 100644 --- a/packages/frontend/pnpm-lock.yaml +++ b/packages/frontend/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: catcolab-document-types: specifier: link:../document-types/pkg version: link:../document-types/pkg + catcolab-documents: + specifier: link:../documents + version: link:../documents catcolab-ui-components: specifier: link:../ui-components version: link:../ui-components @@ -183,6 +186,9 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.10.0 + catcolab-logics: + specifier: link:../logics + version: link:../logics eslint-plugin-solid: specifier: ^0.14.5 version: 0.14.5(eslint@9.39.4)(typescript@6.0.3) diff --git a/packages/frontend/src/inference/chat.test.ts b/packages/frontend/src/inference/chat.test.ts index 6f6f6da68b..551854ae24 100644 --- a/packages/frontend/src/inference/chat.test.ts +++ b/packages/frontend/src/inference/chat.test.ts @@ -5,9 +5,9 @@ import { afterAll, assert, beforeAll, describe, test } from "vitest"; import { createFetchWithAuth, createRpcClient } from "../api/rpc.ts"; import { createTestUserAuth } from "../util/test_util.ts"; -import { createInferenceClient, runOpenAIChatTurn, type OpenAITranscript } from "./chat.ts"; +import { createInferenceClient, runChatTurn, type ChatTranscript } from "./chat.ts"; -// When inference is configured, these tests exercise a live `runOpenAIChatTurn` +// When inference is configured, these tests exercise a live `runChatTurn` // against OpenRouter, using a free model. A backend without // `OPENROUTER_PROVISIONING_KEY` is expected to report inference as unavailable, // in which case the OpenRouter assertions have nothing to exercise. @@ -57,19 +57,20 @@ describe("chat turn over OpenRouter", () => { return; } - const transcript: OpenAITranscript = [ + const transcript: ChatTranscript = [ { role: "user", content: "Reply with exactly the word: pong" }, ]; - const result = await runOpenAIChatTurn(client, transcript, {}, undefined, testModel); + const result = await runChatTurn(client, transcript, {}, undefined, testModel); assert.ok(result.content.length > 0, "final content should be a non-empty string"); + assert.deepStrictEqual(result.termination, { tag: "FinalResponse" }); assert.strictEqual(transcript[0]?.role, "user"); assert.strictEqual(result.generatedMessageDelta.at(-1)?.role, "assistant"); }, ); test( - "records the contextExec call and result as OpenAI messages", + "records the contextExec call and result as chat messages", { timeout: 120000 }, async ({ skip }) => { if (!client) { @@ -77,14 +78,15 @@ describe("chat turn over OpenRouter", () => { return; } - const transcript: OpenAITranscript = [ + const transcript: ChatTranscript = [ { role: "user", content: "Use contextExec to evaluate 6 * 7, then tell me the result.", }, ]; - const result = await runOpenAIChatTurn(client, transcript, {}, undefined, testModel); + const result = await runChatTurn(client, transcript, {}, undefined, testModel); + assert.deepStrictEqual(result.termination, { tag: "FinalResponse" }); const assistant = result.generatedMessageDelta.find( (message) => message.role === "assistant" && diff --git a/packages/frontend/src/inference/chat.ts b/packages/frontend/src/inference/chat.ts index 94a86190ab..9eb0081f3e 100644 --- a/packages/frontend/src/inference/chat.ts +++ b/packages/frontend/src/inference/chat.ts @@ -10,40 +10,55 @@ import type { import { type ContextExecScope, type EvalResult, contextExec } from "./context_exec"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; + +export type InferenceClient = OpenAI; /** Default LLM used for newly created CatColab conversations. */ export const DEFAULT_LLM_MODEL = "z-ai/glm-5.2"; -/** Maximum model completions, including tool-use continuations, in one user turn. */ -export const MAX_CHAT_COMPLETIONS_PER_TURN = 10; +/** Maximum provider requests made while resolving one inference attempt. */ +const MAX_PROVIDER_REQUESTS_PER_ATTEMPT = 32; const EMPTY_FILES = Object.freeze(Object.create(null)) as Readonly>; const SYSTEM_PROMPT = "You are an assistant embedded in CatColab. Use `contextExec` when you need to inspect, compute with, or act on the current CatColab context. It executes JavaScript with the available context values as local bindings. The read-only `files` binding maps available filenames to their UTF-8 content; use `Object.keys(files)` to inspect its keys. Use `return` when you need to observe a value; `await` can be used directly. Only use bindings and APIs explicitly described as available. Answer the user's request clearly and concisely, using tool results when relevant."; -/** Message in the OpenAI-compatible transcript sent for an inference request. */ -export type OpenAITranscriptMessage = +/** Message in the transcript sent for an inference request. */ +export type ChatTranscriptMessage = | ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam | ChatCompletionAssistantMessageParam | ChatCompletionToolMessageParam; -/** Ephemeral OpenAI-compatible transcript sent for an inference request. */ -export type OpenAITranscript = OpenAITranscriptMessage[]; +/** Ephemeral transcript sent for an inference request. */ +export type ChatTranscript = ChatTranscriptMessage[]; -/** Assistant or tool message newly generated during one OpenAI inference request. */ -export type GeneratedOpenAIMessage = +/** Assistant or tool message newly generated during one inference attempt. */ +export type GeneratedChatMessage = | ChatCompletionAssistantMessageParam | ChatCompletionToolMessageParam; -/** Completed OpenAI inference output, not including the request transcript. */ -export type OpenAIChatTurnResult = { +/** Why an inference attempt stopped producing provider responses. */ +export type ChatTurnTermination = + | { tag: "FinalResponse" } + | { tag: "ProviderRequestLimit" } + | { tag: "IncompleteResponse"; reason: string }; + +/** Completed inference output, not including the request transcript. */ +export type ChatTurnResult = { content: string; - generatedMessageDelta: GeneratedOpenAIMessage[]; + generatedMessageDelta: GeneratedChatMessage[]; + termination: ChatTurnTermination; }; -/** Create an OpenAI client configured for OpenRouter. */ -export function createInferenceClient(apiKey: string): OpenAI { +/** Event emitted while an inference attempt runs. */ +export type ChatTurnEvent = + | { tag: "Streaming"; delta: string; snapshot: string } + | { tag: "RunTool"; code: string } + | { tag: "ToolResult"; code: string; result: EvalResult }; + +/** Create an inference client configured for OpenRouter. */ +export function createInferenceClient(apiKey: string): InferenceClient { return new OpenAI({ baseURL: OPENROUTER_BASE_URL, apiKey, @@ -51,19 +66,27 @@ export function createInferenceClient(apiKey: string): OpenAI { }); } -/** Run one OpenAI inference turn against a complete, ephemeral request transcript. */ -export async function runOpenAIChatTurn( - client: OpenAI, - openAITranscript: readonly OpenAITranscriptMessage[], +/** + * Run one inference attempt against a complete, ephemeral transcript. + * The provider request budget includes any continuations after tool calls. + */ +export async function runChatTurn( + client: InferenceClient, + transcript: readonly ChatTranscriptMessage[], scope: ContextExecScope, - onContent?: (delta: string, snapshot: string) => void, + onEvent?: (event: ChatTurnEvent) => void, model = DEFAULT_LLM_MODEL, -): Promise { + systemPromptSuffix?: string, + onSuccessHook?: () => Promise, +): Promise { const contextScope: ContextExecScope = { files: EMPTY_FILES, ...scope }; + const systemPrompt = systemPromptSuffix + ? `${SYSTEM_PROMPT}\n\n${systemPromptSuffix}` + : SYSTEM_PROMPT; const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: SYSTEM_PROMPT }, - ...openAITranscript, + { role: "system", content: systemPrompt }, + ...transcript, ]; const runner = client.chat.completions.runTools( @@ -96,21 +119,30 @@ export async function runOpenAIChatTurn( if (!args) { return { tag: "Err", error: "Invalid contextExec arguments" }; } - return await contextExec(args.code, contextScope); + onEvent?.({ tag: "RunTool", code: args.code }); + const result = await contextExec( + args.code, + contextScope, + onSuccessHook, + ); + onEvent?.({ tag: "ToolResult", code: args.code, result }); + return result; }, }, }, ], }, - { maxChatCompletions: MAX_CHAT_COMPLETIONS_PER_TURN }, + { maxChatCompletions: MAX_PROVIDER_REQUESTS_PER_ATTEMPT }, ); - if (onContent) { - runner.on("content", onContent); + if (onEvent) { + runner.on("content", (delta: string, snapshot: string) => + onEvent({ tag: "Streaming", delta, snapshot }), + ); } const content = (await runner.finalContent()) ?? ""; - const generatedMessageDelta: GeneratedOpenAIMessage[] = []; + const generatedMessageDelta: GeneratedChatMessage[] = []; for (const message of runner.messages.slice(messages.length)) { if (message.role !== "assistant" && message.role !== "tool") { throw new Error(`Unexpected generated message role: ${message.role}`); @@ -118,10 +150,18 @@ export async function runOpenAIChatTurn( generatedMessageDelta.push(message); } - return { content, generatedMessageDelta }; + const finishReason = (await runner.finalChatCompletion()).choices[0]?.finish_reason; + const termination: ChatTurnTermination = + finishReason === "stop" + ? { tag: "FinalResponse" } + : finishReason === "tool_calls" + ? { tag: "ProviderRequestLimit" } + : { tag: "IncompleteResponse", reason: finishReason ?? "unknown" }; + + return { content, generatedMessageDelta, termination }; } -/** Parse the arguments of an OpenAI `contextExec` function call. */ +/** Parse the arguments of a `contextExec` function call. */ export function parseContextExecArguments(rawArgs: string): { code: string } | undefined { try { const value = JSON.parse(rawArgs) as { code?: unknown } | null; diff --git a/packages/frontend/src/inference/context_exec.test.ts b/packages/frontend/src/inference/context_exec.test.ts index 60763af501..5ccb4e2b12 100644 --- a/packages/frontend/src/inference/context_exec.test.ts +++ b/packages/frontend/src/inference/context_exec.test.ts @@ -8,7 +8,7 @@ describe("contextExec", () => { assert.deepStrictEqual(result, { tag: "Ok", value: "3" }); }); - test("captures runtime and syntax errors", async () => { + test("captures syntax, runtime, and success hook errors", async () => { const runtimeError = await contextExec("throw new Error('boom')", {}); assert.strictEqual(runtimeError.tag, "Err"); if (runtimeError.tag === "Err") { @@ -17,6 +17,11 @@ describe("contextExec", () => { const syntaxError = await contextExec("return (", {}); assert.strictEqual(syntaxError.tag, "Err"); + + const hookError = await contextExec("return 'done'", {}, async () => { + throw new Error("validation failed"); + }); + assert.deepStrictEqual(hookError, { tag: "Err", error: "validation failed" }); }); test("always returns a string for successful execution", async () => { diff --git a/packages/frontend/src/inference/context_exec.ts b/packages/frontend/src/inference/context_exec.ts index 88482bd8b0..3a1d9e0956 100644 --- a/packages/frontend/src/inference/context_exec.ts +++ b/packages/frontend/src/inference/context_exec.ts @@ -19,7 +19,11 @@ The generated function does not close over the caller's lexical scope. As with any function created using `Function`, JavaScript globals remain available; this is not a sandbox. */ -export async function contextExec(code: string, scope: ContextExecScope): Promise { +export async function contextExec( + code: string, + scope: ContextExecScope, + onSuccessHook?: () => Promise, +): Promise { const bindings = Object.entries(scope); try { @@ -31,6 +35,7 @@ export async function contextExec(code: string, scope: ContextExecScope): Promis // oxlint-enable no-implied-eval const value = await fn(...bindings.map(([, value]) => value)); + await onSuccessHook?.(); return { tag: "Ok", value: truncateResult(stringify(value)) }; } catch (error) { return { tag: "Err", error: truncateResult(errorMessage(error)) }; diff --git a/packages/frontend/src/inference/llm_conversation_adapter.ts b/packages/frontend/src/inference/llm_conversation_adapter.ts index 9408620090..3720f47dcd 100644 --- a/packages/frontend/src/inference/llm_conversation_adapter.ts +++ b/packages/frontend/src/inference/llm_conversation_adapter.ts @@ -1,22 +1,22 @@ import type { LLMConversationDocument } from "catcolab-document-methods"; import type { UserMessage } from "catcolab-document-types"; import { assertExhaustive } from "../util/assert_exhaustive.ts"; -import type { OpenAITranscript } from "./chat.ts"; +import type { ChatTranscript } from "./chat.ts"; /** Files available to `contextExec`, indexed by their filenames. */ export type ConversationFiles = Readonly>; -/** The OpenAI request data derived from a persisted LLM conversation. */ +/** The inference request data derived from a persisted LLM conversation. */ export type LLMConversationInferenceContext = { - transcript: OpenAITranscript; + transcript: ChatTranscript; files: ConversationFiles; }; -/** Derive the OpenAI request data for a persisted LLM conversation. */ +/** Derive the inference request data for a persisted LLM conversation. */ export function prepareLLMConversationInference( conversation: LLMConversationDocument, ): LLMConversationInferenceContext { - const transcript: OpenAITranscript = []; + const transcript: ChatTranscript = []; const files: Record = Object.create(null) as Record< string, string | number[] @@ -25,7 +25,7 @@ export function prepareLLMConversationInference( for (const interaction of conversation.interactions) { switch (interaction.tag) { case "user-message": - transcript.push({ role: "user", content: userMessageToOpenAIContent(interaction) }); + transcript.push({ role: "user", content: userMessageToChatContent(interaction) }); for (const file of interaction.files) { files[file.filename] = decodeFile(file.mediaType, file.content); } @@ -90,7 +90,7 @@ export function prepareLLMConversationInference( } /** Render a stored user message as the text sent to the LLM. */ -function userMessageToOpenAIContent(message: UserMessage): string { +function userMessageToChatContent(message: UserMessage): string { if (message.files.length === 0) { return message.content; } diff --git a/packages/frontend/src/llm_conversation/document.test.ts b/packages/frontend/src/llm_conversation/document.test.ts index 7466dc86d5..d6a9198698 100644 --- a/packages/frontend/src/llm_conversation/document.test.ts +++ b/packages/frontend/src/llm_conversation/document.test.ts @@ -1,33 +1,17 @@ -import { Repo } from "@automerge/automerge-repo"; -import { assert, describe, test, vi } from "vitest"; +import { Entity, SimpleSchema } from "catcolab-logics/simple-schema"; +import { assert, beforeEach, describe, test, vi } from "vitest"; -import { LLMConversation, type LLMConversationDocument } from "catcolab-document-methods"; -import type { InlineFile } from "catcolab-document-types"; -import { makeLiveDoc } from "../api"; -import type { - GeneratedOpenAIMessage, - OpenAIChatTurnResult, - OpenAITranscript, -} from "../inference/chat.ts"; +import type { Document } from "catcolab-document-types"; +import { createBinder, type Instance, type Notebook, type Result } from "catcolab-documents"; +import type { ChatTurnResult } from "../inference/chat.ts"; import type { ContextExecScope } from "../inference/context_exec.ts"; -import { - type LiveLLMConversationDoc, - retryLastLLMConversationResponse, - runLLMConversationTurn, -} from "./document.ts"; +import { retryLastLLMConversationResponse, runLLMConversationTurn } from "./document.ts"; + +type RunChatTurn = (typeof import("../inference/chat.ts"))["runChatTurn"]; const inference = vi.hoisted(() => ({ createInferenceClient: vi.fn<(apiKey: string) => unknown>(), - runOpenAIChatTurn: - vi.fn< - ( - client: unknown, - transcript: OpenAITranscript, - scope: ContextExecScope, - onContent?: (delta: string, snapshot: string) => void, - model?: string, - ) => Promise - >(), + runChatTurn: vi.fn(), })); vi.mock("../inference/chat.ts", async (importOriginal) => ({ @@ -35,228 +19,250 @@ vi.mock("../inference/chat.ts", async (importOriginal) => ({ ...inference, })); -const modelRef = { - _id: "0198b0e0-2085-1000-8000-000000000001", - _version: null, - _server: "example.test", -}; +type Fixture = Awaited>; + +async function makeFixture(withInstance = false) { + const binder = createBinder(); + const schema = await binder.createNotebook(SimpleSchema, { title: "Company schema" }); + let attachment: typeof schema | Instance = schema; + let instance: Instance | undefined; + + if (withInstance) { + instance = expectOk(await binder.createInstance(schema, { title: "Company data" })); + attachment = instance; + } + + const conversation = await binder.createLLMConversation(attachment, "test-model", { + title: "Conversation", + }); + return { binder, schema, instance, conversation }; +} -function makeLiveConversation(): LiveLLMConversationDoc { - const repo = new Repo(); - const document = LLMConversation.newLLMConversationDocument(modelRef, "test-model"); - const liveDoc = makeLiveDoc(repo.create(document), "llmconversation"); +async function makeInvalidInstance(fixture: Fixture) { + const instance = fixture.instance; + assert(instance); + fixture.schema.add(Entity, { label: "Person" }); + const table = expectOk(await instance.tables())[0]; + assert(table); + const row = expectOk(await instance.addRow(table)); + fixture.binder.store.changeDocument(instance.handle, (document) => { + assert.strictEqual(document.type, "instance"); + if (document.type !== "instance") { + return; + } + const storedRow = document.tables[table.id]?.rows[row.id]; + assert(storedRow); + storedRow.fields.unexpected = { String: "value" }; + }); + return { table, row }; +} +function expectOk(result: Result): T { + if (result.tag === "Err") { + throw new Error(`Expected Ok, got ${JSON.stringify(result.content)}`); + } + return result.content; +} + +function schemaBinding(scope: ContextExecScope): Notebook { + const schema = scope.document_Company_schema; + assert(schema); + return schema as Notebook; +} + +function response(content: string): ChatTurnResult { return { - type: "llmconversation", - liveDoc, - docRef: { - refId: "0198b0e0-2085-1000-8000-000000000010", - permissions: { anyone: null, user: "Own", users: null }, - isDeleted: false, - }, - liveModel: {} as LiveLLMConversationDoc["liveModel"], + content, + generatedMessageDelta: [{ role: "assistant", content }], + termination: { tag: "FinalResponse" }, }; } -const inputFile: InlineFile = { - filename: "values.csv", - mediaType: "text/csv", - content: Array.from(new TextEncoder().encode("left,right\n0,1\n")), -}; - -const generatedMessageDelta: GeneratedOpenAIMessage[] = [ - { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_multiply", - type: "function", - function: { - name: "contextExec", - arguments: JSON.stringify({ code: "return files['values.csv'];" }), - }, - }, - ], - }, - { - role: "tool", - tool_call_id: "call_multiply", - content: JSON.stringify({ tag: "Ok", value: "left,right\n0,1\n" }), - }, - { role: "assistant", content: "The values are 0 and 1." }, -]; +async function runTurn(fixture: Fixture) { + return runLLMConversationTurn( + fixture.conversation, + fixture.binder.store, + { tag: "Ready", key: "inference-key" }, + { content: "Inspect the document.", files: [] }, + ); +} -describe("LLM conversation turns", () => { - test("persists a completed user, tool, and assistant turn", async () => { - const conversation = makeLiveConversation(); +describe("LLM conversation turns", { timeout: 30_000 }, () => { + beforeEach(() => { + inference.createInferenceClient.mockReset(); + inference.runChatTurn.mockReset(); inference.createInferenceClient.mockReturnValue({}); - inference.runOpenAIChatTurn.mockResolvedValue({ - content: "The values are 0 and 1.", - generatedMessageDelta, + }); + + test("runs against an isolated document scope and commits valid changes", async () => { + const fixture = await makeFixture(); + inference.runChatTurn.mockImplementation(async (_client, _transcript, scope) => { + schemaBinding(scope).update({ title: "Updated schema" }); + assert.strictEqual(fixture.schema.title, "Company schema"); + return response("Done."); }); + assert.deepStrictEqual(await runTurn(fixture), { tag: "Completed", content: "Done." }); + assert.strictEqual(fixture.schema.title, "Updated schema"); + + const call = inference.runChatTurn.mock.calls[0]!; + assert.strictEqual(call[4], "test-model"); + assert.match(call[5] ?? "", /attached document "Company schema"/); assert.deepStrictEqual( - await runLLMConversationTurn( - conversation, - { tag: "Ready", key: "inference-key" }, - { - content: "Inspect the attached file.", - files: [inputFile], - }, - {}, - ), - { tag: "Completed", content: "The values are 0 and 1." }, + fixture.conversation.interactions().map((interaction) => interaction.tag), + ["user-message", "llm-message"], ); + }); - const scope = inference.runOpenAIChatTurn.mock.calls[0]?.[2] as ContextExecScope; - assert.deepStrictEqual(scope.files, { - "values.csv": "left,right\n0,1\n", - }); - assert.strictEqual(inference.runOpenAIChatTurn.mock.calls[0]?.[4], "test-model"); + test("persists complete validation feedback from linked document tools", async () => { + const fixture = await makeFixture(true); + const { table, row } = await makeInvalidInstance(fixture); + inference.runChatTurn.mockImplementation( + async ( + _client, + _transcript, + scope, + _onContent, + _model, + systemPromptSuffix, + onSuccessHook, + ) => { + const attachedDocument = scope.document_Company_data as + | Instance + | undefined; + assert(attachedDocument); + assert(scope.document_Company_schema); + assert.match(systemPromptSuffix ?? "", /Company data.*instanceOf.*Company schema/s); + assert(onSuccessHook); + + let validationError: unknown; + try { + await onSuccessHook(); + } catch (error) { + validationError = error; + } + assert(validationError instanceof Error); + const feedback = validationError.message; + assert.match(feedback, /"issueType":"MistypedLiteral"/); + assert.ok( + feedback.includes( + JSON.stringify([table.id, "rows", row.id, "fields", "unexpected"]), + ), + ); - const interactions = conversation.liveDoc.docHandle.doc().interactions; + attachedDocument.deleteRow(table.id, row.id); + await onSuccessHook(); + return { + content: "Repaired.", + generatedMessageDelta: [ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call-inspect", + type: "function", + function: { + name: "contextExec", + arguments: JSON.stringify({ code: "return 'inspection';" }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call-inspect", + content: JSON.stringify({ tag: "Err", error: feedback }), + }, + { role: "assistant", content: "Repaired." }, + ], + termination: { tag: "FinalResponse" }, + }; + }, + ); + + assert.deepStrictEqual(await runTurn(fixture), { + tag: "Completed", + content: "Repaired.", + }); + assert.strictEqual(inference.runChatTurn.mock.calls.length, 1); + const interactions = fixture.conversation.interactions(); assert.deepStrictEqual( interactions.map((interaction) => interaction.tag), ["user-message", "llm-code-execution", "llm-message"], ); - const [user, execution, response] = interactions; - assert(user?.tag === "user-message"); + const execution = interactions[1]; assert(execution?.tag === "llm-code-execution"); - assert(response?.tag === "llm-message"); - assert.strictEqual(user.content, "Inspect the attached file."); - assert.strictEqual(execution.toolCallId, "call_multiply"); - assert.strictEqual(response.content, "The values are 0 and 1."); + assert.strictEqual(execution.result.tag, "Err"); + if (execution.result.tag === "Err") { + assert.match(execution.result.error, /"issueType":"MistypedLiteral"/); + } }); - test("rejects pending feedback requests when a user sends a new message", async () => { - const conversation = makeLiveConversation(); - conversation.liveDoc.changeDoc((doc) => { - doc.interactions.push({ - tag: "user-feedback-request", - id: "feedback-request", - timestamp: new Date().toISOString(), - codeExecution: "code-execution", - content: "Apply the proposed changes?", - resolution: "unresolved", - }); - }); - inference.createInferenceClient.mockReturnValue({}); - inference.runOpenAIChatTurn.mockResolvedValue({ - content: "A response.", - generatedMessageDelta: [{ role: "assistant", content: "A response." }], + test("commits valid progress when the provider request limit is reached", async () => { + const fixture = await makeFixture(); + inference.runChatTurn.mockImplementation(async (_client, _transcript, scope) => { + schemaBinding(scope).update({ title: "Unfinished update" }); + return { + content: "", + generatedMessageDelta: [ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call-update", + type: "function", + function: { + name: "contextExec", + arguments: JSON.stringify({ + code: "document_Company_schema.update({ title: 'Unfinished update' });", + }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call-update", + content: JSON.stringify({ tag: "Ok", value: "undefined" }), + }, + ], + termination: { tag: "ProviderRequestLimit" }, + }; }); - await runLLMConversationTurn( - conversation, - { tag: "Ready", key: "inference-key" }, - { content: "Do something else.", files: [] }, - {}, - ); - - const request = conversation.liveDoc.docHandle.doc().interactions[0]; - assert(request?.tag === "user-feedback-request"); - assert.strictEqual(request.resolution, "rejected"); - }); - - test("persists tool output when the model produces no final text", async () => { - const conversation = makeLiveConversation(); - inference.createInferenceClient.mockReturnValue({}); - inference.runOpenAIChatTurn.mockResolvedValue({ - content: "", - generatedMessageDelta: generatedMessageDelta.slice(0, 2), + assert.deepStrictEqual(await runTurn(fixture), { + tag: "Incomplete", + reason: "The model exhausted the provider request budget before producing a final response.", }); - + assert.strictEqual(fixture.schema.title, "Unfinished update"); assert.deepStrictEqual( - await runLLMConversationTurn( - conversation, - { tag: "Ready", key: "inference-key" }, - { content: "Inspect the attached file.", files: [inputFile] }, - {}, - ), - { tag: "Completed", content: "" }, + fixture.conversation.interactions().map((interaction) => interaction.tag), + ["user-message", "llm-code-execution"], ); - - const interactions = conversation.liveDoc.docHandle.doc().interactions; assert.deepStrictEqual( - interactions.map((interaction) => interaction.tag), - ["user-message", "llm-code-execution"], + await retryLastLLMConversationResponse(fixture.conversation, fixture.binder.store, { + tag: "Ready", + key: "inference-key", + }), + { tag: "Failed", error: "The latest interaction is not a user message." }, ); + assert.strictEqual(inference.runChatTurn.mock.calls.length, 1); }); test("retains the user message when inference fails", async () => { - const conversation = makeLiveConversation(); - inference.createInferenceClient.mockReturnValue({}); - inference.runOpenAIChatTurn.mockRejectedValue(new Error("network failed")); - - const result = await runLLMConversationTurn( - conversation, - { tag: "Ready", key: "inference-key" }, - { content: "What is the meaning of life?", files: [] }, - {}, - ); - assert.deepStrictEqual(result, { tag: "Retryable", error: "network failed" }); - - const interactions = conversation.liveDoc.docHandle.doc().interactions; - assert.strictEqual(interactions.length, 1); - const user = interactions[0]; - assert(user?.tag === "user-message"); - assert.strictEqual(user.content, "What is the meaning of life?"); - }); + const fixture = await makeFixture(); + inference.runChatTurn.mockRejectedValue(new Error("network failed")); - test("retries without duplicating the persisted user message", async () => { - const conversation = makeLiveConversation(); - const savedUserMessage = LLMConversation.newUserMessage("Please answer this.", []); - conversation.liveDoc.changeDoc((doc) => { - LLMConversation.appendLLMInteraction(doc, savedUserMessage); + assert.deepStrictEqual(await runTurn(fixture), { + tag: "Retryable", + error: "network failed", + attempts: [], }); - inference.createInferenceClient.mockReturnValue({}); - inference.runOpenAIChatTurn.mockReset(); - inference.runOpenAIChatTurn.mockResolvedValue({ - content: "A usable response.", - generatedMessageDelta: [{ role: "assistant", content: "A usable response." }], - }); - assert.deepStrictEqual( - await retryLastLLMConversationResponse( - conversation, - { tag: "Ready", key: "inference-key" }, - {}, - ), - { tag: "Completed", content: "A usable response." }, - ); - const interactions = conversation.liveDoc.docHandle.doc().interactions; - assert.deepStrictEqual( - interactions.map((interaction) => interaction.tag), - ["user-message", "llm-message"], + fixture.conversation.interactions().map((interaction) => interaction.tag), + ["user-message"], ); - assert.strictEqual(inference.runOpenAIChatTurn.mock.calls.length, 1); - const retryTranscript = inference.runOpenAIChatTurn.mock.calls[0]?.[1]; - assert.deepStrictEqual(retryTranscript, [{ role: "user", content: "Please answer this." }]); - }); - - test("does not retry when the latest interaction is not a user message", async () => { - const conversation = makeLiveConversation(); - conversation.liveDoc.changeDoc((doc) => { - LLMConversation.appendLLMInteraction( - doc, - LLMConversation.newLLMMessage("Already answered."), - ); - }); - inference.createInferenceClient.mockClear(); - inference.runOpenAIChatTurn.mockClear(); - - const result = await retryLastLLMConversationResponse( - conversation, - { tag: "Ready", key: "inference-key" }, - {}, - ); - - assert.deepStrictEqual(result, { - tag: "Failed", - error: "The latest interaction is not a user message.", - }); - assert.strictEqual(inference.createInferenceClient.mock.calls.length, 0); - assert.strictEqual(inference.runOpenAIChatTurn.mock.calls.length, 0); }); }); diff --git a/packages/frontend/src/llm_conversation/document.ts b/packages/frontend/src/llm_conversation/document.ts index f8a21ba641..c83946eddd 100644 --- a/packages/frontend/src/llm_conversation/document.ts +++ b/packages/frontend/src/llm_conversation/document.ts @@ -1,37 +1,28 @@ -import { LLMConversation, type LLMConversationDocument } from "catcolab-document-methods"; +import { LLMConversation } from "catcolab-document-methods"; +import type { InlineFile, LLMInteraction } from "catcolab-document-types"; import type { - FeedbackResolution, - InlineFile, - LLMInteraction, - StableRef, - Uuid, -} from "catcolab-document-types"; + DocumentStore, + LLMConversation as LLMConversationAPI, + LLMConversationAttachment, + Shape, +} from "catcolab-documents"; import type { JsResult } from "catlog-wasm"; -import type { Api, DocRef, LiveDoc } from "../api"; import { createInferenceClient, + type ChatTurnEvent, parseContextExecArguments, parseContextExecResult, - runOpenAIChatTurn, - type GeneratedOpenAIMessage, + runChatTurn, + type GeneratedChatMessage, } from "../inference/chat.ts"; -import type { ContextExecScope } from "../inference/context_exec.ts"; import * as LLMConversationAdapter from "../inference/llm_conversation_adapter.ts"; -import type { LiveModelDoc, ModelLibrary } from "../model"; import type { InferenceKeyResult } from "../user/inference_key_context.tsx"; import { errorMessage } from "../util/error.ts"; import { type ConversationAttachmentMetadata, validateConversationAttachments, } from "./conversation_attachment_policy.ts"; - -/** A live LLM conversation and the model it is attached to. */ -export type LiveLLMConversationDoc = { - type: "llmconversation"; - liveDoc: LiveDoc; - docRef: DocRef; - liveModel: LiveModelDoc; -}; +import { createLLMConversationExecutionScope } from "./execution_scope.ts"; /** Input for a user message submitted to an LLM conversation. */ export type LLMConversationUserInput = { @@ -40,7 +31,7 @@ export type LLMConversationUserInput = { }; /** Project a persisted inline file into attachment policy metadata. */ -export function inlineFileMetadata(file: InlineFile): ConversationAttachmentMetadata { +function inlineFileMetadata(file: InlineFile): ConversationAttachmentMetadata { return { filename: file.filename, mediaType: file.mediaType, @@ -49,7 +40,7 @@ export function inlineFileMetadata(file: InlineFile): ConversationAttachmentMeta } /** Project all persisted conversation attachments into attachment policy metadata. */ -export function conversationAttachmentMetadata( +function conversationAttachmentMetadata( interactions: readonly LLMInteraction[], ): ConversationAttachmentMetadata[] { const result: ConversationAttachmentMetadata[] = []; @@ -64,159 +55,153 @@ export function conversationAttachmentMetadata( /** Outcome of attempting one LLM conversation turn. */ export type LLMConversationTurnResult = | { tag: "Completed"; content: string } + | { tag: "Incomplete"; reason: string } | { tag: "Failed"; error: string } - | { tag: "Retryable"; error: string }; - -/** Create a new LLM conversation attached to a model. */ -export function createLLMConversation( - api: Api, - modelRef: StableRef, - llmModel: string, -): Promise { - return api.createDoc(LLMConversation.newLLMConversationDocument(modelRef, llmModel)); -} - -/** Retrieve an LLM conversation and its parent model for live editing. */ -export async function getLiveLLMConversation( - refId: Uuid, - api: Api, - models: ModelLibrary, -): Promise { - const { liveDoc, docRef } = await api.getLiveDoc( - refId, - "llmconversation", - ); - const liveModel = await models.getLiveModel(liveDoc.doc.llmConversationOf._id); - - return { type: "llmconversation", liveDoc, docRef, liveModel }; -} - -/** Resolve a pending feedback request in a live conversation. */ -export function resolveLLMConversationFeedback( - conversation: LiveLLMConversationDoc, - requestId: Uuid, - resolution: Exclude, -) { - let resolved = false; - conversation.liveDoc.changeDoc((doc) => { - resolved = LLMConversation.resolveUserFeedbackRequest(doc, requestId, resolution); - }); - return resolved; -} + | { + tag: "Retryable"; + error: string; + attempts: readonly LLMInteraction[]; + }; -/** - * Persist a user message, run one OpenAI turn, then persist its completed output. - * Streaming assistant text is reported only through `onContent` and is never persisted. - */ -export async function runLLMConversationTurn( - conversation: LiveLLMConversationDoc, +/** Persist a user message, run one model turn, and apply its valid document edits. */ +export async function runLLMConversationTurn< + Handle, + Attachment extends LLMConversationAttachment, +>( + conversation: LLMConversationAPI, + store: DocumentStore, inferenceKey: InferenceKeyResult, userInput: LLMConversationUserInput, - contextExecScope: ContextExecScope, - onContent?: (delta: string, snapshot: string) => void, + onEvent?: (event: ChatTurnEvent) => void, ): Promise { - if (conversation.docRef.isDeleted) { - return { tag: "Failed", error: "This LLM conversation has been deleted." }; - } if (inferenceKey.tag !== "Ready") { return { tag: "Failed", error: "Inference is unavailable." }; } - const inputValidation = validateUserInput(conversation.liveDoc.doc.interactions, userInput); + const inputValidation = validateUserInput(conversation.interactions(), userInput); if (inputValidation.tag === "Err") { return { tag: "Failed", error: inputValidation.content }; } try { - const userInteraction = LLMConversation.newUserMessage(userInput.content, userInput.files); - conversation.liveDoc.changeDoc((doc) => { - LLMConversation.rejectPendingFeedbackRequests(doc); - LLMConversation.appendLLMInteraction(doc, userInteraction); - }); - - return generateLLMConversationResponse( - conversation, - inferenceKey, - contextExecScope, - onContent, + conversation.rejectPendingFeedbackRequests(); + conversation.appendInteraction( + LLMConversation.newUserMessage(userInput.content, userInput.files), ); + return generateLLMConversationResponse(conversation, store, inferenceKey, onEvent); } catch (error) { return { tag: "Failed", error: errorMessage(error) }; } } -/** Retry the latest persisted user message without adding it to the conversation again. */ -export async function retryLastLLMConversationResponse( - conversation: LiveLLMConversationDoc, +/** Retry the latest persisted user message without adding it again. */ +export async function retryLastLLMConversationResponse< + Handle, + Attachment extends LLMConversationAttachment, +>( + conversation: LLMConversationAPI, + store: DocumentStore, inferenceKey: InferenceKeyResult, - contextExecScope: ContextExecScope, - onContent?: (delta: string, snapshot: string) => void, + onEvent?: (event: ChatTurnEvent) => void, ): Promise { - if (conversation.docRef.isDeleted) { - return { tag: "Failed", error: "This LLM conversation has been deleted." }; - } if (inferenceKey.tag !== "Ready") { return { tag: "Failed", error: "Inference is unavailable." }; } - const latestInteraction = conversation.liveDoc.doc.interactions.at(-1); - if (latestInteraction?.tag !== "user-message") { + + if (conversation.interactions().at(-1)?.tag !== "user-message") { return { tag: "Failed", error: "The latest interaction is not a user message." }; } - return generateLLMConversationResponse(conversation, inferenceKey, contextExecScope, onContent); + return generateLLMConversationResponse(conversation, store, inferenceKey, onEvent); } -/** Generate and persist a response to the current persisted conversation. */ -async function generateLLMConversationResponse( - conversation: LiveLLMConversationDoc, +async function generateLLMConversationResponse< + Handle, + Attachment extends LLMConversationAttachment, +>( + conversation: LLMConversationAPI, + store: DocumentStore, inferenceKey: Extract, - contextExecScope: ContextExecScope, - onContent?: (delta: string, snapshot: string) => void, + onEvent?: (event: ChatTurnEvent) => void, ): Promise { try { - const persistedConversation = conversation.liveDoc.docHandle.doc(); - const context = - LLMConversationAdapter.prepareLLMConversationInference(persistedConversation); - const result = await runOpenAIChatTurn( - createInferenceClient(inferenceKey.key), + const executionScope = await createLLMConversationExecutionScope(conversation, store); + const context = LLMConversationAdapter.prepareLLMConversationInference(conversation.dump()); + const client = createInferenceClient(inferenceKey.key); + const result = await runChatTurn( + client, context.transcript, - { ...contextExecScope, files: context.files }, - onContent, - persistedConversation.llmModel, - ); - const generated = generatedOpenAIMessageDeltaToLLMInteractions( - result.generatedMessageDelta, + { ...executionScope.bindings, files: context.files }, + onEvent, + conversation.document.llmModel, + executionScope.systemPromptSuffix, + async () => { + const problems = await executionScope.validate(); + if (problems.length > 0) { + throw new Error(validationFeedback(problems)); + } + }, ); + const problems = await executionScope.validate(); + const generated = generatedChatMessageDeltaToLLMInteractions(result.generatedMessageDelta); if (generated.tag === "Err") { - return { tag: "Retryable", error: generated.content }; + return { tag: "Retryable", error: generated.content, attempts: [] }; } - if (generated.content.length === 0 && result.content.trim().length === 0) { - return { tag: "Retryable", error: "The model produced no usable output." }; + if (problems.length > 0) { + return { + tag: "Retryable", + error: validationFeedback(problems), + attempts: generated.content, + }; + } + if ( + result.termination.tag === "FinalResponse" && + generated.content.length === 0 && + result.content.trim().length === 0 + ) { + return { + tag: "Retryable", + error: "The model produced no usable output.", + attempts: [], + }; } - conversation.liveDoc.changeDoc((doc) => { - for (const interaction of generated.content) { - LLMConversation.appendLLMInteraction(doc, interaction); - } - if ( - !generated.content.some((interaction) => interaction.tag === "llm-message") && - result.content.trim().length > 0 - ) { - LLMConversation.appendLLMInteraction( - doc, - LLMConversation.newLLMMessage(result.content), - ); - } - }); + for (const interaction of generated.content) { + conversation.appendInteraction(interaction); + } + if ( + !generated.content.some((interaction) => interaction.tag === "llm-message") && + result.content.trim().length > 0 + ) { + conversation.appendInteraction(LLMConversation.newLLMMessage(result.content)); + } + executionScope.commit(); + + if (result.termination.tag === "ProviderRequestLimit") { + return { + tag: "Incomplete", + reason: "The model exhausted the provider request budget before producing a final response.", + }; + } + if (result.termination.tag === "IncompleteResponse") { + return { + tag: "Incomplete", + reason: `The provider stopped before producing a complete response: ${result.termination.reason}.`, + }; + } return { tag: "Completed", content: result.content }; } catch (error) { - return { tag: "Retryable", error: errorMessage(error) }; + return { tag: "Retryable", error: errorMessage(error), attempts: [] }; } } -/** Convert generated OpenAI assistant/tool messages to persisted LLM interactions. */ -function generatedOpenAIMessageDeltaToLLMInteractions( - messages: readonly GeneratedOpenAIMessage[], +function validationFeedback(problems: ReadonlyArray): string { + return `The documents have validation problems:\n${problems.map((problem) => `- ${problem}`).join("\n")}\nFix all problems before completing the turn.`; +} + +/** Convert generated assistant/tool messages to persisted LLM interactions. */ +function generatedChatMessageDeltaToLLMInteractions( + messages: readonly GeneratedChatMessage[], ): JsResult { const interactions: LLMInteraction[] = []; @@ -245,11 +230,9 @@ function generatedOpenAIMessageDeltaToLLMInteractions( if (toolCalls.length !== 1) { return { tag: "Err", content: "Expected exactly one contextExec tool call" }; } - if (message.content !== null && message.content !== undefined && message.content !== "") { - return { - tag: "Err", - content: "Cannot store assistant content alongside a contextExec tool call", - }; + if (typeof message.content === "string" && message.content.trim().length > 0) { + // sometimes we have narration and tools calls together, persist the text + interactions.push(LLMConversation.newLLMMessage(message.content)); } const toolCall = toolCalls[0]!; diff --git a/packages/frontend/src/llm_conversation/document_transaction.ts b/packages/frontend/src/llm_conversation/document_transaction.ts new file mode 100644 index 0000000000..96c1df691c --- /dev/null +++ b/packages/frontend/src/llm_conversation/document_transaction.ts @@ -0,0 +1,82 @@ +import type { Document } from "catcolab-document-types"; +import type { DocumentStore } from "catcolab-documents"; + +/** + * Transaction semantics for staging edits to a document behind a working copy, + * and later committing that copy back over the original. This file is expected + * to be replaced with a more principled implementation that is kinder to + * automerge in the future. + */ + +/** + * Overwrites every key of `handle`'s document with the corresponding key from + * `replacement`, except for `preservedKeys`, whose current values are kept + * as-is. + */ +export function overwriteDocument( + store: DocumentStore, + handle: Handle, + replacement: Document, + preservedKeys: ReadonlyArray = [], +): void { + store.changeDocument(handle, (document) => { + const mutable = document as unknown as Record; + const preserved = Object.fromEntries(preservedKeys.map((key) => [key, mutable[key]])); + for (const key of Object.keys(mutable)) { + delete mutable[key]; + } + Object.assign(mutable, replacement, preserved); + }); +} + +/** + * A handle for coordinating transactions between documents. + */ +export type DocumentTransaction = { + /** Overwrites the working copy with the current contents of the source. */ + stage(): void; + /** Overwrites the source document with the current contents of the working copy. */ + commit(): void; +}; + +function dumpDocument(store: DocumentStore, handle: Handle): Document { + return store.copyValue(handle, store.getDocumentView(handle)); +} + +/** + * Given {copy, commit}{Store,Handle} create a document transaction between the + * copy and the commit. Both sides are read fresh via the stores on every + * `stage`/`commit` call. + * + * `preservedKeys` names top-level keys that should not be overwritten in + * either direction, e.g. `instanceOf`, which should keep pointing at the + * working copy's own schema while staged, and at the real schema once + * committed. + */ +export function createDocumentTransaction(options: { + copyStore: DocumentStore; + copyHandle: Document; + commitStore: DocumentStore; + commitHandle: Handle; + preservedKeys?: ReadonlyArray; +}): DocumentTransaction { + const preservedKeys = options.preservedKeys ?? []; + return { + stage() { + overwriteDocument( + options.copyStore, + options.copyHandle, + dumpDocument(options.commitStore, options.commitHandle), + preservedKeys, + ); + }, + commit() { + overwriteDocument( + options.commitStore, + options.commitHandle, + dumpDocument(options.copyStore, options.copyHandle), + preservedKeys, + ); + }, + }; +} diff --git a/packages/frontend/src/llm_conversation/execution_scope.ts b/packages/frontend/src/llm_conversation/execution_scope.ts new file mode 100644 index 0000000000..f2b0647283 --- /dev/null +++ b/packages/frontend/src/llm_conversation/execution_scope.ts @@ -0,0 +1,178 @@ +import { cellTypesForTheory } from "catcolab-logics/cell-types"; + +import { + createBinder, + type Instance, + type LLMConversation, + type LLMConversationAttachment, + modelNotebookFromStore, + type ModelDocument, + type Notebook, + type DocumentStore, + type Shape, +} from "catcolab-documents"; +import type { ContextExecScope } from "../inference/context_exec"; +import { + createScopedDocument, + type ScopedDocument, + type ScopedDocumentRole, +} from "./scoped_document"; + +const API_PROMPT = `The document bindings expose the CatColab document API. A notebook binding has \`title\`, \`cells()\`, \`cellsOf(type)\`, \`add(type, values)\`, \`update(patch)\`, and \`validate()\`. The \`type\` argument of a notebook's \`add\` method must be one of the cell-type bindings in scope; the \`values\` argument is an object: \`{ label }\` to add an object cell, \`{ label, from, to }\` to add a morphism cell (\`from\`/\`to\` are existing object cells), or \`{ content }\` to add an informal text cell. A tabular document binding has \`title\`, \`tables()\`, \`get(path)\`, row editing methods, \`update(patch)\`, and \`validate()\`. These APIs mutate in-memory working copies; changes are applied to the user's documents only after every document validates.`; + +type SourceDocuments = + | { + tag: "SingleDocument"; + attachment: Notebook; + } + | { + tag: "DocumentWithParent"; + attachment: Instance; + parent: Notebook; + }; + +export type LLMConversationExecutionScope = { + bindings: ContextExecScope; + systemPromptSuffix: string; + validate(): Promise>; + commit(): void; +}; + +export async function createLLMConversationExecutionScope< + Handle, + Attachment extends LLMConversationAttachment, +>( + conversation: LLMConversation, + store: DocumentStore, +): Promise { + const { documents, theory } = await createScopedDocuments(conversation.attachment, store); + const descriptions = documents.map((document) => document.description); + const cellTypes = cellTypesForTheory(theory); + const vocabularyDescription = cellTypes + ? `The cell types of the notebook documents, for use with their \`add\` method, are: ${Object.keys( + cellTypes, + ) + .map((name) => `\`${name}\``) + .join(", ")}.` + : undefined; + + return { + bindings: Object.freeze({ + ...Object.fromEntries(documents.map(({ binding, value }) => [binding, value])), + ...cellTypes, + }), + systemPromptSuffix: [ + API_PROMPT, + `The following documents are in scope:\n${descriptions.join("\n")}`, + vocabularyDescription, + ] + .filter((part) => part !== undefined) + .join("\n\n"), + async validate() { + const problems: string[] = []; + for (const document of documents) { + problems.push(...(await document.validate())); + } + return problems; + }, + commit() { + for (const document of documents) { + document.commit(); + } + }, + }; +} + +async function createScopedDocuments( + attachment: LLMConversationAttachment, + store: DocumentStore, +): Promise<{ documents: ReadonlyArray; theory?: string }> { + const sources = await resolveSourceDocuments(attachment, store); + const sourceNotebook = sources.tag === "SingleDocument" ? sources.attachment : sources.parent; + if (!sourceNotebook.shape.theory) { + throw new Error("The document shape does not identify its theory."); + } + + const shape = sourceNotebook.shape as Shape & { readonly theory: string }; + const copyBinder = createBinder(); + const copyNotebook = await copyBinder.createNotebook(shape, { + title: sourceNotebook.title, + }); + const usedBindings = new Set(); + const scopeNotebook = (role: ScopedDocumentRole) => + createScopedDocument({ + binding: copyNotebook, + bindingStore: copyBinder.store, + sourceHandle: sourceNotebook.handle, + sourceStore: store, + role, + usedBindings, + }); + + if (sources.tag === "SingleDocument") { + return { documents: [scopeNotebook("attachment")], theory: shape.theory }; + } + + const created = await copyBinder.createInstance(copyNotebook, { + title: sources.attachment.title, + }); + if (created.tag === "Err") { + throw new Error(JSON.stringify(created.content)); + } + const parentDocument = scopeNotebook("linked"); + const attachedDocument = createScopedDocument({ + binding: created.content, + bindingStore: copyBinder.store, + sourceHandle: sources.attachment.handle, + sourceStore: store, + role: "attachment", + links: [{ name: "instanceOf", targetBinding: parentDocument.binding }], + preservedKeys: ["instanceOf"], + usedBindings, + }); + return { + documents: [attachedDocument, parentDocument], + theory: shape.theory, + }; +} + +async function resolveSourceDocuments( + attachment: LLMConversationAttachment, + store: DocumentStore, +): Promise> { + if (isNotebook(attachment)) { + return { tag: "SingleDocument", attachment }; + } + + const parentHandle = await resolveHandle(store, attachment.document.instanceOf); + const parentDocument = store.getDocumentView(parentHandle); + if (parentDocument.type !== "model") { + throw new Error("The document linked by instanceOf is not a model document."); + } + return { + tag: "DocumentWithParent", + attachment, + parent: modelNotebookFromStore(attachment.shape, store, parentHandle), + }; +} + +function isNotebook( + document: LLMConversationAttachment, +): document is Notebook { + return document.document.type === "model"; +} + +async function resolveHandle( + store: DocumentStore, + ref: { _id: string; _version: string | null; _server: string }, +): Promise { + const result = await store.getHandle({ + id: ref._id, + version: ref._version, + server: ref._server, + }); + if (result.tag === "Err") { + throw new Error(JSON.stringify(result.content)); + } + return result.content; +} diff --git a/packages/frontend/src/llm_conversation/scoped_document.ts b/packages/frontend/src/llm_conversation/scoped_document.ts new file mode 100644 index 0000000000..2a86b5f77d --- /dev/null +++ b/packages/frontend/src/llm_conversation/scoped_document.ts @@ -0,0 +1,104 @@ +import type { Document } from "catcolab-document-types"; +import { type DocumentStore, type Issue, type Result } from "catcolab-documents"; +import type { DblModel } from "catlog-wasm"; +import { createDocumentTransaction } from "./document_transaction"; + +export type DocumentBinding = { + readonly document: Readonly; + readonly handle: Handle; + readonly title: string; + validate(): Promise>>; +}; + +export type ScopedDocumentLink = { + name: string; + targetBinding: string; +}; + +export type ScopedDocumentRole = "attachment" | "linked"; + +type ScopedDocumentDescription = { + binding: string; + title: string; + role: ScopedDocumentRole; + links?: ReadonlyArray; +}; + +export type ScopedDocument = { + binding: string; + value: unknown; + description: string; + validate(): Promise>; + commit(): void; +}; + +/** Create and stage the execution-scope representation of one document. */ +export function createScopedDocument(options: { + binding: DocumentBinding; + bindingStore: DocumentStore; + sourceHandle: Handle; + sourceStore: DocumentStore; + role: ScopedDocumentRole; + links?: ReadonlyArray; + preservedKeys?: ReadonlyArray; + usedBindings: Set; +}): ScopedDocument { + const bindingName = uniqueBinding(options.binding.title, options.usedBindings); + const transaction = createDocumentTransaction({ + copyStore: options.bindingStore, + copyHandle: options.binding.handle, + commitStore: options.sourceStore, + commitHandle: options.sourceHandle, + preservedKeys: options.preservedKeys, + }); + transaction.stage(); + + return { + binding: bindingName, + value: options.binding, + description: describeScopedDocument({ + binding: bindingName, + title: options.binding.title, + role: options.role, + links: options.links, + }), + validate: () => validateScopedDocument(options.binding, bindingName), + commit: () => transaction.commit(), + }; +} + +async function validateScopedDocument( + document: DocumentBinding, + binding: string, +): Promise> { + const result = await document.validate(); + if (result.tag === "Err") { + return [`${binding}: ${JSON.stringify(result.content)}`]; + } + if (document.document.type === "model") { + (result.content as DblModel).free(); + } + return []; +} + +function describeScopedDocument(description: ScopedDocumentDescription): string { + const kind = description.role === "attachment" ? "attached document" : "document"; + const links = (description.links ?? []) + .map((link) => ` Its \`${link.name}\` link points to \`${link.targetBinding}\`.`) + .join(""); + return `\`${description.binding}\` is the ${kind} ${JSON.stringify(description.title)}.${links}`; +} + +function uniqueBinding(title: string, used: Set): string { + const stem = title + .normalize("NFKD") + .replace(/[^A-Za-z0-9_$]+/g, "_") + .replace(/^_+|_+$/g, ""); + const base = `document_${stem || "untitled"}`; + let binding = base; + for (let suffix = 2; used.has(binding); suffix += 1) { + binding = `${base}_${suffix}`; + } + used.add(binding); + return binding; +} diff --git a/packages/logics/src/cell-types.ts b/packages/logics/src/cell-types.ts new file mode 100644 index 0000000000..faa274361c --- /dev/null +++ b/packages/logics/src/cell-types.ts @@ -0,0 +1,22 @@ +import type { MorphismType, ObjectType } from "catcolab-documents"; +import { RichText } from "catcolab-documents"; +import { PetriNet, petriNetCellTypes } from "./petri-net"; +import { SimpleOlog, simpleOlogCellTypes } from "./simple-olog"; +import { SimpleSchema, simpleSchemaCellTypes } from "./simple-schema"; + +export type CellType = ObjectType | MorphismType | typeof RichText; + +export type CellTypeVocabulary = Readonly>; + +const shapeCellTypes: ReadonlyArray<{ theory: string; cellTypes: CellTypeVocabulary }> = [ + { theory: SimpleOlog.theory!, cellTypes: simpleOlogCellTypes }, + { theory: SimpleSchema.theory!, cellTypes: simpleSchemaCellTypes }, + { theory: PetriNet.theory!, cellTypes: petriNetCellTypes }, +]; + +export function cellTypesForTheory(theory?: string): CellTypeVocabulary | undefined { + if (theory === undefined) { + return undefined; + } + return shapeCellTypes.find((entry) => entry.theory === theory)?.cellTypes; +} diff --git a/packages/logics/src/petri-net.ts b/packages/logics/src/petri-net.ts index 25f95da186..6bf1a30ef1 100644 --- a/packages/logics/src/petri-net.ts +++ b/packages/logics/src/petri-net.ts @@ -20,3 +20,9 @@ export const PetriNet = defineShape({ morphisms: [Transition], informal: [RichText], }); + +export const petriNetCellTypes = { + petri_net_place: Place, + petri_net_transition: Transition, + petri_net_rich_text: RichText, +}; diff --git a/packages/logics/src/simple-olog.ts b/packages/logics/src/simple-olog.ts index 6907b5ac78..a06369b3fb 100644 --- a/packages/logics/src/simple-olog.ts +++ b/packages/logics/src/simple-olog.ts @@ -16,3 +16,8 @@ export const SimpleOlog = defineShape({ tableObjects: [Type], }, }); + +export const simpleOlogCellTypes = { + olog_type: Type, + olog_aspect: Aspect, +}; diff --git a/packages/logics/src/simple-schema.ts b/packages/logics/src/simple-schema.ts index 13ba8ebaec..45653b6863 100644 --- a/packages/logics/src/simple-schema.ts +++ b/packages/logics/src/simple-schema.ts @@ -22,3 +22,11 @@ export const SimpleSchema = defineShape({ tableObjects: [Entity], }, }); + +export const simpleSchemaCellTypes = { + schema_entity: Entity, + schema_attr_type: AttrType, + schema_mapping: Mapping, + schema_attr: Attr, + schema_rich_text: RichText, +}; From 7f77f356c79e958adc1daf7da03c6837e4956a04 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 25 Aug 2026 13:53:46 +0100 Subject: [PATCH 09/83] ENH: Implement load from ref methods --- packages/documents/src/binder.ts | 111 ++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/documents/src/binder.ts b/packages/documents/src/binder.ts index ec7a278f02..f415c3010e 100644 --- a/packages/documents/src/binder.ts +++ b/packages/documents/src/binder.ts @@ -4,7 +4,7 @@ import { Model, } from "catcolab-document-methods"; import type { Document } from "catcolab-document-types"; -import type { DocumentStore } from "./document-store"; +import type { DocumentRef, DocumentStore } from "./document-store"; import { createInMemoryStore } from "./document-store"; import { instanceFromStore, type Instance } from "./instance/instance"; import { @@ -26,6 +26,11 @@ export interface Binder { options: { title: string }, ): Promise>; + loadNotebookFromRef( + shape: S, + ref: DocumentRef, + ): Promise>>; + createInstance( schema: Notebook, options: { title: string }, @@ -36,6 +41,11 @@ export interface Binder { llmModel: string, options: { title: string }, ): Promise>; + + loadInstanceFromRef( + schema: Notebook, + ref: DocumentRef, + ): Promise>>; } /* Overloads rather than a single signature `createBinder(store?: @@ -70,12 +80,52 @@ function binderFromStore(store: DocumentStore): Binder { return modelNotebookFromStore(shape, store, handle); }, + async loadNotebookFromRef( + shape: S, + ref: DocumentRef, + ) { + const result = await store.getHandle(ref); + if (result.tag === "Err") { + return result; + } + + const document = store.getDocumentView(result.content); + if (document.type !== "model") { + return { + tag: "Err", + content: [ + { + message: `Cannot load document of type "${document.type}" as a notebook.`, + path: ["type"], + }, + ], + }; + } + if (document.theory !== shape.theory) { + return { + tag: "Err", + content: [ + { + message: + `Cannot load document with theory "${document.theory}"` + + `using shape "${shape.theory}".`, + path: ["theory"], + }, + ], + }; + } + + return { + tag: "Ok", + content: modelNotebookFromStore(shape, store, result.content), + }; + }, async createInstance( schema: Notebook, options: { title: string }, ) { const shape = schema.shape; - if (shape.supportsInstances === undefined) { + if (!shape.supportsInstances) { return { tag: "Err", content: [ @@ -129,5 +179,62 @@ function binderFromStore(store: DocumentStore): Binder { const handle = await store.createHandle(document); return llmConversationFromStore(store, handle, attachment); }, + async loadInstanceFromRef( + schema: Notebook, + ref: DocumentRef, + ) { + if (!schema.shape.supportsInstances) { + return { + tag: "Err", + content: [ + { + message: `Shape${schema.shape.theory ? ' "' + schema.shape.theory + '"' : ""} does not support instances`, + }, + ], + }; + } + + const result = await store.getHandle(ref); + if (result.tag === "Err") { + return result; + } + + const document = store.getDocumentView(result.content); + if (document.type !== "instance") { + return { + tag: "Err", + content: [ + { + message: `Cannot load document of type "${document.type}" as an instance.`, + path: ["type"], + }, + ], + }; + } + + const schemaRef = store.getDocumentRef(schema.handle); + if ( + document.instanceOf._id !== schemaRef.id || + document.instanceOf._version !== schemaRef.version || + document.instanceOf._server !== (schemaRef.server ?? "") + ) { + return { + tag: "Err", + content: [ + { + message: + `Cannot load instance of schema "${document.instanceOf._id}" ` + + `using schema "${schemaRef.id}".`, + path: ["instanceOf"], + }, + ], + }; + } + + return { + tag: "Ok", + content: instanceFromStore(schema.shape, schema, store, result.content), + }; + }, }; } From 950874eb7580ed6768c9e7bf64f641b71649918b Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 25 Aug 2026 14:20:47 +0100 Subject: [PATCH 10/83] TEST: Enable and add loading tests --- .github/workflows/ci.yml | 8 +++++++- packages/documents/test/instances.test.ts | 17 +++++++++++++++++ packages/documents/test/serialization.test.ts | 4 +++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b541eb95e..cbf53dc2f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,13 @@ jobs: - name: Typescript documents package tests run: | - pnpm --filter catcolab-documents exec vitest run test/creating-and-editing.test.ts test/definitions.test.ts test/binder.test-d.ts test/llm-conversation.test.ts + pnpm --filter catcolab-documents exec vitest run \ + test/creating-and-editing.test.ts \ + test/definitions.test.ts \ + test/binder.test-d.ts \ + test/llm-conversation.test.ts \ + test/instances.test.ts \ + test/serialization.test.ts \ - name: Typescript logic package tests run: | diff --git a/packages/documents/test/instances.test.ts b/packages/documents/test/instances.test.ts index ca01818658..8fc0d2f297 100644 --- a/packages/documents/test/instances.test.ts +++ b/packages/documents/test/instances.test.ts @@ -131,6 +131,23 @@ describe("instance document creation", () => { const issues = expectErr(await binder.createInstance(notebook, { title: "Bare instance" })); expect(issues[0]?.message).toMatch(/does not support instances/); }); + + test("an instance can be loaded from a document reference", async () => { + const binder = createBinder(); + const schema = await binder.createNotebook(SimpleSchema, { title: "Company schema" }); + schema.add(Entity, { label: "Company" }); + const instance = expectOk( + await binder.createInstance(schema, { title: "Company instance" }), + ); + + const loaded = expectOk( + await binder.loadInstanceFromRef(schema, refOf(binder.store, instance.handle)), + ); + + expect(loaded.title).toBe("Company instance"); + expect(loaded.shape).toBe(SimpleSchema); + expect(loaded.handle).toBe(instance.handle); + }); }); describe("instance schema validation", () => { diff --git a/packages/documents/test/serialization.test.ts b/packages/documents/test/serialization.test.ts index 34bb24b4c4..b1043a17b0 100644 --- a/packages/documents/test/serialization.test.ts +++ b/packages/documents/test/serialization.test.ts @@ -36,8 +36,10 @@ describe.skip("serialization", () => { 'Cannot load document with theory "petri-net" using a shape with theory "simple-olog".', ); }); +}); - test("loadNotebookFromRef returns an Err when the store cannot resolve the reference", async () => { +describe("loading notebooks from references", () => { + test("returns an Err when the store cannot resolve the reference", async () => { const binder = createBinder(); const loaded = await binder.loadNotebookFromRef(PetriNet, { From 8b198b1c427ac44f442e8ad49faaf7c27b328a14 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 25 Aug 2026 13:17:53 +0100 Subject: [PATCH 11/83] ENH: Implement binder for frontend --- packages/frontend/package.json | 1 + packages/frontend/pnpm-lock.yaml | 3 + packages/frontend/src/App.tsx | 9 +- packages/frontend/src/api/context.ts | 11 ++ .../frontend/src/api/document_store.test.ts | 140 ++++++++++++++++++ packages/frontend/src/api/document_store.ts | 118 +++++++++++++++ packages/frontend/src/api/index.ts | 1 + .../src/instance/live_doc_compatibility.ts | 92 ++++++++++++ 8 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/src/api/document_store.test.ts create mode 100644 packages/frontend/src/api/document_store.ts create mode 100644 packages/frontend/src/instance/live_doc_compatibility.ts diff --git a/packages/frontend/package.json b/packages/frontend/package.json index b7188638ff..9bcd2df7dd 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -51,6 +51,7 @@ "catcolab-document-methods": "link:../document-methods", "catcolab-document-types": "link:../document-types/pkg", "catcolab-documents": "link:../documents", + "catcolab-logics": "link:../logics", "catcolab-ui-components": "link:../ui-components", "catlog-wasm": "link:../catlog-wasm/dist/pkg-browser", "echarts": "^6.1.0", diff --git a/packages/frontend/pnpm-lock.yaml b/packages/frontend/pnpm-lock.yaml index 287db274e1..30560a75dd 100644 --- a/packages/frontend/pnpm-lock.yaml +++ b/packages/frontend/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: catcolab-documents: specifier: link:../documents version: link:../documents + catcolab-logics: + specifier: link:../logics + version: link:../logics catcolab-ui-components: specifier: link:../ui-components version: link:../ui-components diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 5dcbb5f5ec..cc6f506718 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -10,7 +10,7 @@ import invariant from "tiny-invariant"; import * as uuid from "uuid"; import { Button } from "catcolab-ui-components"; -import { Api, ApiContext, useApi } from "./api"; +import { Api, ApiContext, BinderContext, createApiBinder, useApi } from "./api"; import { helpRoutes } from "./help/routes"; import { createModelLibraryWithApi, ModelLibraryContext } from "./model"; import { createModel } from "./model/document"; @@ -45,12 +45,14 @@ const Root = (props: RouteSectionProps) => { const theories = stdTheories; const models = createModelLibraryWithApi(api, theories); + const binder = createApiBinder(api); return ( !v || v === "analysis" || v === "diagram" || v === "model", + subkind: (v?: string) => + !v || v === "analysis" || v === "diagram" || v === "instance" || v === "model", subref: (v?: string) => !v || refIsUUIDFilter.ref(v), }, component: lazy(() => import("./page/document_page")), diff --git a/packages/frontend/src/api/context.ts b/packages/frontend/src/api/context.ts index 9f254eeca2..2a1ed1880d 100644 --- a/packages/frontend/src/api/context.ts +++ b/packages/frontend/src/api/context.ts @@ -1,6 +1,7 @@ import { createContext, useContext } from "solid-js"; import invariant from "tiny-invariant"; +import type { ApiBinder } from "./document_store"; import type { Api } from "./types"; /** Context for the CatColab API. */ @@ -12,3 +13,13 @@ export function useApi(): Api { invariant(api, "CatColab API should be provided as context"); return api; } + +/** Context for the binder shared by the application. */ +export const BinderContext = createContext(); + +/** Retrieve the shared binder from application context. */ +export function useBinder(): ApiBinder { + const binder = useContext(BinderContext); + invariant(binder, "Binder should be provided as context"); + return binder; +} diff --git a/packages/frontend/src/api/document_store.test.ts b/packages/frontend/src/api/document_store.test.ts new file mode 100644 index 0000000000..3cafea49f1 --- /dev/null +++ b/packages/frontend/src/api/document_store.test.ts @@ -0,0 +1,140 @@ +import { Repo } from "@automerge/automerge-repo"; +import { SimpleOlog, Type } from "catcolab-logics/simple-olog"; +import { describe, expect, test } from "vitest"; + +import { Instance as LegacyInstance, Model } from "catcolab-document-methods"; +import { createBinder, defineShape, type InstanceDocument } from "catcolab-documents"; +import { makeLiveDoc } from "./document"; +import { createApiDocumentStore } from "./document_store"; +import type { Api } from "./types"; + +const schemaRef = "schema-ref"; +const instanceRef = "instance-ref"; +const server = "test.catcolab.org"; + +function createFixture() { + const repo = new Repo(); + const schemaAutomergeHandle = repo.create(Model.newModelDocument({ theory: "simple-olog" })); + const instanceAutomergeHandle = repo.create( + LegacyInstance.newInstanceDocument({ + _id: schemaRef, + _version: null, + _server: server, + }), + ); + const instanceLiveDoc = makeLiveDoc(instanceAutomergeHandle); + const api = { + serverHost: server, + async getDocHandle(refId: string) { + if (refId === schemaRef) { + return schemaAutomergeHandle; + } + if (refId === instanceRef) { + return instanceAutomergeHandle; + } + throw new Error(`Unknown document ref: ${refId}`); + }, + } as unknown as Api; + const store = createApiDocumentStore(api); + + return { + binder: createBinder(store), + instanceLiveDoc, + schemaAutomergeHandle, + store, + }; +} + +async function createFixtureWithSchema() { + const fixture = createFixture(); + const schema = await fixture.binder.loadNotebookFromRef(SimpleOlog, { + id: schemaRef, + version: null, + server, + }); + expect(schema.tag).toBe("Ok"); + if (schema.tag !== "Ok") { + throw new Error("expected schema to load"); + } + + return { ...fixture, schema: schema.content }; +} + +describe("API document store", () => { + test("binds instance mutations to existing Automerge handles", async () => { + const { binder, instanceLiveDoc, schema } = await createFixtureWithSchema(); + schema.add(Type, { label: "Person" }); + + const instance = await binder.loadInstanceFromRef(schema, { + id: instanceRef, + version: null, + server, + }); + expect(instance.tag).toBe("Ok"); + if (instance.tag !== "Ok") { + throw new Error("expected instance to load"); + } + + const tables = await instance.content.tables(); + expect(tables.tag).toBe("Ok"); + if (tables.tag !== "Ok") { + throw new Error("expected tables to load"); + } + const table = tables.content.find((table) => table.label === "Person"); + expect(table).toBeDefined(); + if (!table) { + throw new Error("expected entity table"); + } + const added = await instance.content.addRow(table); + expect(added.tag).toBe("Ok"); + // Tables are stored as a record keyed by the schema entity's UUID, + // each with its rows keyed by row UUID alongside an ordering. + expect(Object.keys(instanceLiveDoc.doc.tables)).toHaveLength(1); + const storedTable = instanceLiveDoc.doc.tables[table.id]; + expect(storedTable?.rowOrder).toHaveLength(1); + expect(Object.keys(storedTable?.rows ?? {})).toHaveLength(1); + }); + + test("rejects a document with the wrong type", async () => { + const { binder, schema } = await createFixtureWithSchema(); + + const wrongType = await binder.loadInstanceFromRef(schema, { + id: schemaRef, + version: null, + server, + }); + + expect(wrongType).toMatchObject({ tag: "Err", content: [{ path: ["type"] }] }); + }); + + test("rejects instances of unsupported schema shapes", async () => { + const { binder } = createFixture(); + + const unsupportedSchema = await binder.loadNotebookFromRef( + defineShape({ theory: "simple-olog" }), + { id: schemaRef, version: null, server }, + ); + expect(unsupportedSchema.tag).toBe("Ok"); + if (unsupportedSchema.tag === "Err") { + throw new Error("expected unsupported schema to load as a notebook"); + } + const unsupported = await binder.loadInstanceFromRef(unsupportedSchema.content, { + id: instanceRef, + version: null, + server, + }); + expect(unsupported).toMatchObject({ tag: "Err" }); + }); + + test("resolves local refs to canonical cached handles", async () => { + const { schemaAutomergeHandle, store } = createFixture(); + + const local = await store.getHandle({ id: schemaRef, version: null }); + expect(local.tag).toBe("Ok"); + if (local.tag === "Err") { + throw new Error("expected local ref to resolve"); + } + expect(local.content.automergeHandle).toBe(schemaAutomergeHandle); + expect(local.content.ref.server).toBe(server); + }); +}); diff --git a/packages/frontend/src/api/document_store.ts b/packages/frontend/src/api/document_store.ts new file mode 100644 index 0000000000..6cc12b6353 --- /dev/null +++ b/packages/frontend/src/api/document_store.ts @@ -0,0 +1,118 @@ +import { getBackend, getObjectId } from "@automerge/automerge"; +import type { DocHandle } from "@automerge/automerge-repo"; + +import type { Document } from "catcolab-document-types"; +import { + type Binder, + createBinder, + type DocumentRef, + type DocumentStore, + type Result, +} from "catcolab-documents"; +import type { Api } from "./types"; + +export type ApiDocumentHandle = { + automergeHandle: DocHandle; + ref: DocumentRef; +}; + +export type ApiDocumentStore = DocumentStore; + +/** A binder over the API document store. */ +export type ApiBinder = Binder; + +/** Create a binder over the document store for an API client. + +The binder should be created once per API client and shared across the +application (via context) so that document handles are cached and deduplicated +between loads. + */ +export function createApiBinder(api: Api): ApiBinder { + return createBinder(createApiDocumentStore(api)); +} + +/** Adapt frontend Automerge documents to the storage boundary used by catcolab-documents. */ +export function createApiDocumentStore(api: Api): ApiDocumentStore { + const handles = new Map(); + + const cacheHandle = (ref: DocumentRef, automergeHandle: DocHandle) => { + const existing = handles.get(ref.id); + if (existing) { + existing.ref = ref; + return existing; + } + const handle = { automergeHandle, ref }; + handles.set(ref.id, handle); + return handle; + }; + + return { + async createHandle(initialDoc) { + const refId = await api.createDoc(initialDoc); + const automergeHandle = await api.getDocHandle(refId); + return cacheHandle( + { id: refId, version: null, server: api.serverHost }, + automergeHandle, + ); + }, + getDocumentView: (handle) => handle.automergeHandle.doc(), + changeDocument: (handle, fn) => handle.automergeHandle.change(fn), + subscribe: (handle, callback) => { + handle.automergeHandle.on("change", callback); + return () => handle.automergeHandle.off("change", callback); + }, + copyValue: (handle, value) => { + if (typeof value !== "object" || value === null) { + return value; + } + const objectId = getObjectId(value); + if (!objectId) { + return structuredClone(value); + } + return getBackend(handle.automergeHandle.doc()).materialize(objectId) as typeof value; + }, + getDocumentRef: (handle) => handle.ref, + async getHandle(ref: DocumentRef): Promise> { + if (ref.version !== null) { + return { + tag: "Err", + content: [ + { message: "Pinned document refs are not supported.", path: ["version"] }, + ], + }; + } + if (ref.server && ref.server !== api.serverHost) { + return { + tag: "Err", + content: [ + { + message: `Cannot resolve a document on server "${ref.server}".`, + path: ["server"], + }, + ], + }; + } + const canonicalRef = { ...ref, server: ref.server || api.serverHost }; + + const cached = handles.get(ref.id); + if (cached) { + cached.ref = canonicalRef; + return { tag: "Ok", content: cached }; + } + try { + const automergeHandle = await api.getDocHandle(ref.id); + return { tag: "Ok", content: cacheHandle(canonicalRef, automergeHandle) }; + } catch (error) { + return { + tag: "Err", + content: [ + { + message: error instanceof Error ? error.message : String(error), + path: ["id"], + }, + ], + }; + } + }, + }; +} diff --git a/packages/frontend/src/api/index.ts b/packages/frontend/src/api/index.ts index 7697a6e901..c66e5f19c2 100644 --- a/packages/frontend/src/api/index.ts +++ b/packages/frontend/src/api/index.ts @@ -1,4 +1,5 @@ export * from "./context"; export * from "./document"; +export * from "./document_store"; export * from "./rpc"; export * from "./types"; diff --git a/packages/frontend/src/instance/live_doc_compatibility.ts b/packages/frontend/src/instance/live_doc_compatibility.ts new file mode 100644 index 0000000000..0d4821b1f1 --- /dev/null +++ b/packages/frontend/src/instance/live_doc_compatibility.ts @@ -0,0 +1,92 @@ +import { SimpleOlog } from "catcolab-logics/simple-olog"; +import { SimpleSchema } from "catcolab-logics/simple-schema"; + +import type { Instance, InstanceDocument } from "catcolab-documents"; +import type { Api, ApiBinder, ApiDocumentHandle, DocRef, LiveDoc } from "../api"; +import type { LiveModelDoc, ModelLibrary } from "../model"; + +/** Shapes whose models can have data instances. */ +const instanceShapes = [SimpleOlog, SimpleSchema] as const; +type SupportedInstanceShape = (typeof instanceShapes)[number]; +export type ApiInstance = Instance; + +/** An instance document "live" for compatibility with existing container components. + +A facade over the catcolab-documents {@link Instance} API that structurally matches +the other `Live*Doc` types, so that generic UI (toolbar, sidebar, breadcrumbs, +document head, permissions) can consume it uniformly. The instance itself +remains the source of truth: `liveDoc` is derived from its document handle. + */ +export type LiveInstanceDoc = { + /** Tag for use in tagged unions of document types. */ + type: "instance"; + + /** Live document containing the instance data. */ + liveDoc: LiveDoc; + + /** The catcolab-documents instance object, consumed by new components. */ + instance: ApiInstance; + + /** Canonical live schema document used by document chrome. */ + modelLiveDoc: LiveModelDoc["liveDoc"]; +}; + +/** Look up the shape for a theory that supports data instances, if any. */ +export function shapeForTheory(theory?: string) { + return instanceShapes.find((shape) => shape.theory === theory); +} + +function enlivenInstance( + instance: ApiInstance, + liveDoc: LiveDoc, + modelLiveDoc: LiveModelDoc["liveDoc"], +): LiveInstanceDoc { + return { + type: "instance", + liveDoc, + instance, + modelLiveDoc, + }; +} + +export async function getLiveInstance( + refId: string, + api: Api, + models: ModelLibrary, + binder: ApiBinder, +): Promise<{ liveInstance: LiveInstanceDoc; docRef: DocRef }> { + const { liveDoc, docRef } = await api.getLiveDoc(refId, "instance"); + const modelRef = { + id: liveDoc.doc.instanceOf._id, + version: liveDoc.doc.instanceOf._version, + server: liveDoc.doc.instanceOf._server, + }; + if (modelRef.version !== null || modelRef.server !== api.serverHost) { + throw new Error("Data instances require a live model on the current server."); + } + const liveModel = await models.getLiveModel(liveDoc.doc.instanceOf._id); + + const shape = shapeForTheory(liveModel.liveDoc.doc.theory); + if (!shape) { + throw new Error( + `Data instances are not supported for theory "${liveModel.liveDoc.doc.theory}".`, + ); + } + const model = await binder.loadNotebookFromRef(shape, modelRef); + if (model.tag === "Err") { + throw new Error(model.content.map((issue) => issue.message).join("\n")); + } + const instance = await binder.loadInstanceFromRef(model.content, { + id: refId, + version: null, + server: api.serverHost, + }); + if (instance.tag === "Err") { + throw new Error(instance.content.map((issue) => issue.message).join("\n")); + } + + return { + liveInstance: enlivenInstance(instance.content, liveDoc, liveModel.liveDoc), + docRef, + }; +} From 4857827f1e46ad17fea021b837f35f65220016d2 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 25 Aug 2026 15:47:03 +0100 Subject: [PATCH 12/83] FIX: addInstanceRowsToStore automerge compatibility --- packages/documents/src/instance/table-methods.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/documents/src/instance/table-methods.ts b/packages/documents/src/instance/table-methods.ts index 0361d662cf..e38a820b51 100644 --- a/packages/documents/src/instance/table-methods.ts +++ b/packages/documents/src/instance/table-methods.ts @@ -100,13 +100,16 @@ export function addInstanceRowsToStore( for (const rowValues of values) { const fields = encodeFieldsByLabel(schemaTable, rowValues, issues); const id = freshRowId(instanceDocument); - let storedTable = instanceDocument.tables[schemaTable.id]; + const storedTable = instanceDocument.tables[schemaTable.id]; if (storedTable === undefined) { - storedTable = { rows: {}, rowOrder: [] }; - instanceDocument.tables[schemaTable.id] = storedTable; + instanceDocument.tables[schemaTable.id] = { + rows: { [id]: { fields } }, + rowOrder: [id], + }; + } else { + storedTable.rows[id] = { fields }; + storedTable.rowOrder.push(id); } - storedTable.rows[id] = { fields }; - storedTable.rowOrder.push(id); addedRows.push({ schemaTable, id }); } } From 761bf535a9ba52705e48d8a454727f31127504ca Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 25 Aug 2026 16:08:54 +0100 Subject: [PATCH 13/83] ENH: Wire instance through and add stub for instance_editor --- packages/frontend/pnpm-lock.yaml | 3 -- .../src/instance/instance_editor.module.css | 3 ++ .../frontend/src/instance/instance_editor.tsx | 12 ++++++ .../frontend/src/instance/instance_info.tsx | 19 +++++++++ packages/frontend/src/page/document_page.tsx | 39 ++++++++++++++++--- .../src/page/document_page_sidebar.tsx | 4 ++ 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 packages/frontend/src/instance/instance_editor.module.css create mode 100644 packages/frontend/src/instance/instance_editor.tsx create mode 100644 packages/frontend/src/instance/instance_info.tsx diff --git a/packages/frontend/pnpm-lock.yaml b/packages/frontend/pnpm-lock.yaml index 30560a75dd..7e56d75390 100644 --- a/packages/frontend/pnpm-lock.yaml +++ b/packages/frontend/pnpm-lock.yaml @@ -189,9 +189,6 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.10.0 - catcolab-logics: - specifier: link:../logics - version: link:../logics eslint-plugin-solid: specifier: ^0.14.5 version: 0.14.5(eslint@9.39.4)(typescript@6.0.3) diff --git a/packages/frontend/src/instance/instance_editor.module.css b/packages/frontend/src/instance/instance_editor.module.css new file mode 100644 index 0000000000..eeb964c532 --- /dev/null +++ b/packages/frontend/src/instance/instance_editor.module.css @@ -0,0 +1,3 @@ +.editor { + padding: 1rem; +} diff --git a/packages/frontend/src/instance/instance_editor.tsx b/packages/frontend/src/instance/instance_editor.tsx new file mode 100644 index 0000000000..cd1f8d2aa8 --- /dev/null +++ b/packages/frontend/src/instance/instance_editor.tsx @@ -0,0 +1,12 @@ +import type { ApiInstance } from "./live_doc_compatibility"; + +import styles from "./instance_editor.module.css"; + +/** Placeholder editor for the data instance of a model. */ +export function InstanceEditor(_props: { instance: ApiInstance }) { + return ( + + ); +} diff --git a/packages/frontend/src/instance/instance_info.tsx b/packages/frontend/src/instance/instance_info.tsx new file mode 100644 index 0000000000..695c3b4b4a --- /dev/null +++ b/packages/frontend/src/instance/instance_info.tsx @@ -0,0 +1,19 @@ +import { A } from "@solidjs/router"; + +import type { LiveInstanceDoc } from "./live_doc_compatibility"; + +/** Parent model link shown in an instance document head. */ +export function InstanceInfo(props: { liveInstance: LiveInstanceDoc }) { + const modelRefId = () => props.liveInstance.instance.document.instanceOf._id; + + return ( + <> +
Data instance of
+ + + ); +} diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index c78f9d6c51..d6f93cd6a6 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -32,10 +32,21 @@ import { import { getLiveAnalysis, type LiveAnalysisDoc } from "../analysis"; import { AnalysisNotebookEditor } from "../analysis/analysis_editor"; import { AnalysisInfo } from "../analysis/analysis_info"; -import { type Api, type DocRef, type DocumentType, documentTypeLabel, useApi } from "../api"; +import { + type Api, + type ApiBinder, + type DocRef, + type DocumentType, + documentTypeLabel, + useApi, + useBinder, +} from "../api"; import { getLiveDiagram, type LiveDiagramDoc } from "../diagram"; import { DiagramNotebookEditor } from "../diagram/diagram_editor"; import { DiagramInfo } from "../diagram/diagram_info"; +import { InstanceEditor } from "../instance/instance_editor"; +import { InstanceInfo } from "../instance/instance_info"; +import { getLiveInstance, type LiveInstanceDoc } from "../instance/live_doc_compatibility"; import { type LiveModelDoc, type ModelLibrary, ModelLibraryContext } from "../model"; import { ModelNotebookEditor } from "../model/model_editor"; import { ModelDocumentHead } from "../model/model_info"; @@ -51,7 +62,7 @@ import { useSnapshotHistory } from "./use_snapshot_history"; import "./document_page.css"; -type AnyLiveDoc = LiveModelDoc | LiveDiagramDoc | LiveAnalysisDoc; +type AnyLiveDoc = LiveModelDoc | LiveDiagramDoc | LiveAnalysisDoc | LiveInstanceDoc; /** A Live*Document bundled with its backend DocRef. * @@ -68,6 +79,7 @@ const INITIAL_SPLIT_SIZE = 0.5; export default function DocumentPage() { const api = useApi(); + const binder = useBinder(); const models = useContext(ModelLibraryContext); invariant(models, "Must provide model library as context to page"); @@ -85,7 +97,7 @@ export default function DocumentPage() { const [primaryLiveDoc, { refetch: refetchPrimaryDoc }] = createResource( () => params.ref, - (refId) => getLiveDocument(refId, api, models, params.kind as DocumentType), + (refId) => getLiveDocument(refId, api, models, binder, params.kind as DocumentType), ); const [secondaryLiveDoc, { refetch: refetchSecondaryDoc }] = createResource( @@ -108,7 +120,7 @@ export default function DocumentPage() { return undefined; } - return getLiveDocument(source.ref, api, models, source.kind as DocumentType); + return getLiveDocument(source.ref, api, models, binder, source.kind as DocumentType); }, ); @@ -512,6 +524,13 @@ export function DocumentPane(props: { )}
+ + {(liveInstance) => ( + + + + )} +
@@ -538,6 +557,11 @@ export function DocumentPane(props: { /> )} + + {(liveInstance) => ( + + )} + @@ -555,6 +579,7 @@ async function getLiveDocument( refId: string, api: Api, models: ModelLibrary, + binder: ApiBinder, documentType: DocumentType, ): Promise { switch (documentType) { @@ -571,8 +596,10 @@ async function getLiveDocument( const { liveAnalysis, docRef } = await getLiveAnalysis(refId, api, models); return { liveDoc: liveAnalysis, docRef }; } - case "instance": - throw new Error("Instance documents are not supported by the frontend"); + case "instance": { + const { liveInstance, docRef } = await getLiveInstance(refId, api, models, binder); + return { liveDoc: liveInstance, docRef }; + } case "llmconversation": throw new Error("LLM conversation pages are not implemented"); default: diff --git a/packages/frontend/src/page/document_page_sidebar.tsx b/packages/frontend/src/page/document_page_sidebar.tsx index 0cbce9e23f..d702c3b882 100644 --- a/packages/frontend/src/page/document_page_sidebar.tsx +++ b/packages/frontend/src/page/document_page_sidebar.tsx @@ -51,6 +51,9 @@ async function getDocParent(doc: Document, api: Api): Promise relation.relationType === "diagram-in" || + relation.relationType === "instance-of" || relation.relationType === "analysis-of" || relation.relationType === "llmconversation-of", ) From 99b8f2a4ed51813c69aeb2371b4b4a412bedee47 Mon Sep 17 00:00:00 2001 From: tslil-topos Date: Thu, 23 Jul 2026 12:29:54 +0100 Subject: [PATCH 14/83] OBEE-41 scaffolding for LLMConversation pages "all-but-ui" for LLMConversation stuff, following closely the work in #1430. * Route filters in `App.tsx` accept the `llmconversation` kind/subkind. * `document_page.tsx` loads live LLM conversations and shows a stub editor. * `llm_conversation/live_doc_compatibility.ts` loads a conversation, its attachment (model notebook or data instance), and its parent model through the frontend document binder, following `instance/live_doc_compatibility`. The facade also exposes the binder-backed store for use by a future editor. * `document_menu.tsx` creates conversations through the binder. * `model/shapes.ts` centralises the shape lists and theory lookups shared with the instance module now --- packages/frontend/src/App.tsx | 9 +- .../src/instance/live_doc_compatibility.ts | 10 +- .../conversation_editor_stub.module.css | 3 + .../conversation_editor_stub.tsx | 10 ++ .../frontend/src/llm_conversation/index.ts | 3 + .../live_doc_compatibility.ts | 137 ++++++++++++++++++ packages/frontend/src/model/shapes.ts | 29 ++++ packages/frontend/src/page/document_menu.tsx | 38 ++++- packages/frontend/src/page/document_page.tsx | 48 +++++- 9 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 packages/frontend/src/llm_conversation/conversation_editor_stub.module.css create mode 100644 packages/frontend/src/llm_conversation/conversation_editor_stub.tsx create mode 100644 packages/frontend/src/llm_conversation/index.ts create mode 100644 packages/frontend/src/llm_conversation/live_doc_compatibility.ts create mode 100644 packages/frontend/src/model/shapes.ts diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index cc6f506718..65b41a3445 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -131,10 +131,15 @@ const routes: RouteDefinition[] = [ { path: "/:kind/:ref/:subkind?/:subref?", matchFilters: { - kind: ["model", "diagram", "analysis", "instance"], + kind: ["model", "diagram", "analysis", "instance", "llmconversation"], ref: refIsUUIDFilter.ref, subkind: (v?: string) => - !v || v === "analysis" || v === "diagram" || v === "instance" || v === "model", + !v || + v === "analysis" || + v === "diagram" || + v === "instance" || + v === "model" || + v === "llmconversation", subref: (v?: string) => !v || refIsUUIDFilter.ref(v), }, component: lazy(() => import("./page/document_page")), diff --git a/packages/frontend/src/instance/live_doc_compatibility.ts b/packages/frontend/src/instance/live_doc_compatibility.ts index 0d4821b1f1..cc534ceb4b 100644 --- a/packages/frontend/src/instance/live_doc_compatibility.ts +++ b/packages/frontend/src/instance/live_doc_compatibility.ts @@ -1,13 +1,11 @@ -import { SimpleOlog } from "catcolab-logics/simple-olog"; -import { SimpleSchema } from "catcolab-logics/simple-schema"; - import type { Instance, InstanceDocument } from "catcolab-documents"; import type { Api, ApiBinder, ApiDocumentHandle, DocRef, LiveDoc } from "../api"; import type { LiveModelDoc, ModelLibrary } from "../model"; +import { instanceShapes, shapeForTheory as shapeForTheoryIn } from "../model/shapes"; -/** Shapes whose models can have data instances. */ -const instanceShapes = [SimpleOlog, SimpleSchema] as const; type SupportedInstanceShape = (typeof instanceShapes)[number]; + +/** An instance loaded through a frontend document binder. */ export type ApiInstance = Instance; /** An instance document "live" for compatibility with existing container components. @@ -33,7 +31,7 @@ export type LiveInstanceDoc = { /** Look up the shape for a theory that supports data instances, if any. */ export function shapeForTheory(theory?: string) { - return instanceShapes.find((shape) => shape.theory === theory); + return shapeForTheoryIn(instanceShapes, theory); } function enlivenInstance( diff --git a/packages/frontend/src/llm_conversation/conversation_editor_stub.module.css b/packages/frontend/src/llm_conversation/conversation_editor_stub.module.css new file mode 100644 index 0000000000..eeb964c532 --- /dev/null +++ b/packages/frontend/src/llm_conversation/conversation_editor_stub.module.css @@ -0,0 +1,3 @@ +.editor { + padding: 1rem; +} diff --git a/packages/frontend/src/llm_conversation/conversation_editor_stub.tsx b/packages/frontend/src/llm_conversation/conversation_editor_stub.tsx new file mode 100644 index 0000000000..56957b67cc --- /dev/null +++ b/packages/frontend/src/llm_conversation/conversation_editor_stub.tsx @@ -0,0 +1,10 @@ +import styles from "./conversation_editor_stub.module.css"; + +/** Placeholder editor for an LLM conversation. */ +export function LLMConversationEditorStub() { + return ( + + ); +} diff --git a/packages/frontend/src/llm_conversation/index.ts b/packages/frontend/src/llm_conversation/index.ts new file mode 100644 index 0000000000..63443b472e --- /dev/null +++ b/packages/frontend/src/llm_conversation/index.ts @@ -0,0 +1,3 @@ +export * from "./conversation_editor_stub"; +export * from "./document"; +export * from "./live_doc_compatibility"; diff --git a/packages/frontend/src/llm_conversation/live_doc_compatibility.ts b/packages/frontend/src/llm_conversation/live_doc_compatibility.ts new file mode 100644 index 0000000000..9027462dd9 --- /dev/null +++ b/packages/frontend/src/llm_conversation/live_doc_compatibility.ts @@ -0,0 +1,137 @@ +import type { Document } from "catcolab-document-types"; +import type { + DocumentRef, + LLMConversation as LLMConversationAPI, + LLMConversationAttachment, + LLMConversationDocument, + Shape, +} from "catcolab-documents"; +import { llmConversationFromStore } from "catcolab-documents"; +import type { Api, ApiBinder, ApiDocumentHandle, ApiDocumentStore, DocRef, LiveDoc } from "../api"; +import type { LiveModelDoc, ModelLibrary } from "../model"; +import { notebookShapes, shapeForTheory } from "../model/shapes"; + +/** + * Live*Doc compatibility and binder loading for LLM conversations, following + * `instance/live_doc_compatibility.ts`; see that module for the details of the + * pattern. Differences: the attachment may be a model notebook or a data + * instance, and the facade carries the conversation object and its backing + * store, the seam used by the conversation editor. + */ + +export type ApiLLMConversationAttachment = LLMConversationAttachment; + +export type ApiLLMConversation = LLMConversationAPI< + ApiLLMConversationAttachment, + ApiDocumentHandle +>; + +export type LiveLLMConversationDoc = { + type: "llmconversation"; + liveDoc: LiveDoc; + conversation: ApiLLMConversation; + attachment: ApiLLMConversationAttachment; + store: ApiDocumentStore; + modelLiveDoc: LiveModelDoc["liveDoc"]; +}; + +async function getHandle(binder: ApiBinder, ref: DocumentRef) { + const result = await binder.store.getHandle(ref); + if (result.tag === "Err") { + throw new Error(result.content.map((issue) => issue.message).join("\n")); + } + return result.content; +} + +async function loadNotebook(api: Api, binder: ApiBinder, modelRefId: string) { + const ref = { id: modelRefId, version: null, server: api.serverHost }; + const handle = await getHandle(binder, ref); + const document = binder.store.getDocumentView(handle); + if (document.type !== "model") { + throw new Error(`Cannot load document of type "${document.type}" as a model notebook.`); + } + const shape = shapeForTheory(notebookShapes, document.theory); + if (!shape) { + throw new Error(`LLM conversations are not supported for theory "${document.theory}".`); + } + const notebook = await binder.loadNotebookFromRef(shape, ref); + if (notebook.tag === "Err") { + throw new Error(notebook.content.map((issue) => issue.message).join("\n")); + } + return notebook.content; +} + +async function loadAttachment( + api: Api, + binder: ApiBinder, + attachmentRef: DocumentRef, +): Promise<{ attachment: ApiLLMConversationAttachment; modelRefId: string }> { + const handle = await getHandle(binder, attachmentRef); + const document: Document = binder.store.getDocumentView(handle); + + if (document.type === "model") { + const attachment = await loadNotebook(api, binder, attachmentRef.id); + return { attachment, modelRefId: attachmentRef.id }; + } + if (document.type === "instance") { + const modelRefId = document.instanceOf._id; + const schema = await loadNotebook(api, binder, modelRefId); + const instance = await binder.loadInstanceFromRef(schema, attachmentRef); + if (instance.tag === "Err") { + throw new Error(instance.content.map((issue) => issue.message).join("\n")); + } + return { attachment: instance.content, modelRefId }; + } + throw new Error(`Cannot attach an LLM conversation to a "${document.type}" document.`); +} + +export async function getLiveLLMConversation( + refId: string, + api: Api, + models: ModelLibrary, + binder: ApiBinder, +): Promise<{ liveConversation: LiveLLMConversationDoc; docRef: DocRef }> { + const { liveDoc, docRef } = await api.getLiveDoc( + refId, + "llmconversation", + ); + const of = liveDoc.doc.llmConversationOf; + if (of._version !== null || of._server !== api.serverHost) { + throw new Error("LLM conversations require a live attachment on the current server."); + } + const attachmentRef = { id: of._id, version: null, server: api.serverHost }; + + const { attachment, modelRefId } = await loadAttachment(api, binder, attachmentRef); + const liveModel = await models.getLiveModel(modelRefId); + const conversationHandle = await getHandle(binder, { + id: refId, + version: null, + server: api.serverHost, + }); + const conversation = llmConversationFromStore(binder.store, conversationHandle, attachment); + + return { + liveConversation: { + type: "llmconversation", + liveDoc, + conversation, + attachment, + store: binder.store, + modelLiveDoc: liveModel.liveDoc, + }, + docRef, + }; +} + +export async function createLLMConversation( + api: Api, + binder: ApiBinder, + modelRefId: string, + llmModel: string, +): Promise { + const attachment = await loadNotebook(api, binder, modelRefId); + const conversation = await binder.createLLMConversation(attachment, llmModel, { + title: "", + }); + return conversation.handle.ref.id; +} diff --git a/packages/frontend/src/model/shapes.ts b/packages/frontend/src/model/shapes.ts new file mode 100644 index 0000000000..09b8bb63a8 --- /dev/null +++ b/packages/frontend/src/model/shapes.ts @@ -0,0 +1,29 @@ +import { PetriNet } from "catcolab-logics/petri-net"; +import { SimpleOlog } from "catcolab-logics/simple-olog"; +import { SimpleSchema } from "catcolab-logics/simple-schema"; + +/** + * Which theories the frontend can load through a document binder, and the + * shapes it uses to load them. + */ +import type { ModelDocument, Notebook, Shape } from "catcolab-documents"; +import type { ApiDocumentHandle } from "../api"; + +/** Shapes of model notebooks that can be loaded through a document binder. */ +export const notebookShapes = [SimpleOlog, SimpleSchema, PetriNet] as const; + +type NotebookShape = (typeof notebookShapes)[number]; + +/** A model notebook loaded through a frontend document binder. */ +export type ApiNotebook = Notebook; + +/** Shapes whose models can have data instances. */ +export const instanceShapes = [SimpleOlog, SimpleSchema] as const; + +/** Look up the shape for a theory among the given shapes, if any. */ +export function shapeForTheory( + shapes: ReadonlyArray, + theory?: string, +): ShapeType | undefined { + return shapes.find((shape) => shape.theory === theory); +} diff --git a/packages/frontend/src/page/document_menu.tsx b/packages/frontend/src/page/document_menu.tsx index 90b5d6f611..0b1beb9603 100644 --- a/packages/frontend/src/page/document_menu.tsx +++ b/packages/frontend/src/page/document_menu.tsx @@ -6,8 +6,10 @@ import invariant from "tiny-invariant"; import { DocumentTypeIcon, IconButton } from "catcolab-ui-components"; import { createAnalysis } from "../analysis"; -import { type DocRef, type DocumentType, type LiveDoc, useApi } from "../api"; +import { type DocRef, type DocumentType, type LiveDoc, useApi, useBinder } from "../api"; import { createDiagram } from "../diagram"; +import { DEFAULT_LLM_MODEL } from "../inference/chat"; +import { createLLMConversation } from "../llm_conversation"; import { CopyJSONMenuItem, DeleteMenuItem, @@ -19,6 +21,7 @@ import { RestoreMenuItem, } from "../page"; import { TheoryLibraryContext } from "../theory"; +import { isDocumentVisible, useUserSettings } from "../user/user_settings"; export function DocumentMenu(props: { liveDoc: LiveDoc; @@ -27,6 +30,8 @@ export function DocumentMenu(props: { onDocDeleted?: () => void; }) { const api = useApi(); + const binder = useBinder(); + const { settings } = useUserSettings(); const navigate = useNavigate(); const docType = () => props.liveDoc.doc.type; @@ -69,6 +74,21 @@ export function DocumentMenu(props: { handleDocCreated("analysis", newRef); }; + const canCreateLLMConversation = () => + props.liveDoc.doc.type === "model" && + isDocumentVisible({ typeName: "llmconversation" }, settings()); + + const onNewLLMConversation = async () => { + invariant(canCreateLLMConversation(), "LLM Conversation creation should be enabled"); + const newRef = await createLLMConversation( + api, + binder, + props.docRef.refId, + DEFAULT_LLM_MODEL, + ); + handleDocCreated("llmconversation", newRef); + }; + const theories = useContext(TheoryLibraryContext); invariant(theories, "Library of theories should be provided as context"); @@ -79,13 +99,9 @@ export function DocumentMenu(props: { }, ); - const showSeparator = createMemo(() => { - return ( - theory()?.supportsInstances || - docType() === "diagram" || - props.liveDoc.doc.type !== "analysis" - ); - }); + const showSeparator = createMemo( + () => theory()?.supportsInstances || docType() === "model" || docType() === "diagram", + ); const canDelete = () => props.docRef.permissions.user === "Own" && !props.docRef.isDeleted; const canRestore = () => props.docRef.permissions.user === "Own" && props.docRef.isDeleted; @@ -123,6 +139,12 @@ export function DocumentMenu(props: { {`New analysis of this ${docType()}`} + + onNewLLMConversation()}> + + New LLM Conversation + + diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index d6f93cd6a6..5190739d31 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -47,6 +47,11 @@ import { DiagramInfo } from "../diagram/diagram_info"; import { InstanceEditor } from "../instance/instance_editor"; import { InstanceInfo } from "../instance/instance_info"; import { getLiveInstance, type LiveInstanceDoc } from "../instance/live_doc_compatibility"; +import { + getLiveLLMConversation, + LLMConversationEditorStub, + type LiveLLMConversationDoc, +} from "../llm_conversation"; import { type LiveModelDoc, type ModelLibrary, ModelLibraryContext } from "../model"; import { ModelNotebookEditor } from "../model/model_editor"; import { ModelDocumentHead } from "../model/model_info"; @@ -54,7 +59,9 @@ import { DocumentBreadcrumbs, DocumentLoadingScreen } from "../page"; import { DocumentHead } from "../page/document_head"; import { SidebarLayout } from "../page/sidebar_layout"; import { PermissionsButton } from "../user"; +import { isDocumentVisible, useUserSettings } from "../user/user_settings"; import { assertExhaustive } from "../util/assert_exhaustive"; +import NotFoundPage from "./404_page"; import { DocRefIdContext } from "./context"; import { DocumentSidebar } from "./document_page_sidebar"; import { HistorySidebar } from "./history_sidebar"; @@ -62,7 +69,12 @@ import { useSnapshotHistory } from "./use_snapshot_history"; import "./document_page.css"; -type AnyLiveDoc = LiveModelDoc | LiveDiagramDoc | LiveAnalysisDoc | LiveInstanceDoc; +type AnyLiveDoc = + | LiveModelDoc + | LiveDiagramDoc + | LiveAnalysisDoc + | LiveInstanceDoc + | LiveLLMConversationDoc; /** A Live*Document bundled with its backend DocRef. * @@ -85,7 +97,11 @@ export default function DocumentPage() { const params = useParams(); const navigate = useNavigate(); - const isSidePanelOpen = () => !!params.subkind && !!params.subref; + const { settings } = useUserSettings(); + const documentIsVisible = (kind: string | undefined) => + kind === undefined || isDocumentVisible({ typeName: kind as DocumentType }, settings()); + const isSidePanelOpen = () => + !!params.subkind && !!params.subref && documentIsVisible(params.subkind); const paneFocus = useChildFocus<"primary" | "secondary">(rootFocus, { default: "primary" }); // Redirect if primary and secondary refs match @@ -96,7 +112,7 @@ export default function DocumentPage() { }); const [primaryLiveDoc, { refetch: refetchPrimaryDoc }] = createResource( - () => params.ref, + () => (documentIsVisible(params.kind) ? params.ref : undefined), (refId) => getLiveDocument(refId, api, models, binder, params.kind as DocumentType), ); @@ -107,11 +123,12 @@ export default function DocumentPage() { kind: params.subkind || null, ref: params.subref || null, primaryRef: params.ref, + visible: documentIsVisible(params.subkind), }; }, async (source) => { // Return undefined to clear resource when there's no secondary in URL - if (!source.kind || !source.ref) { + if (!source.kind || !source.ref || !source.visible) { return undefined; } @@ -171,7 +188,7 @@ export default function DocumentPage() { }); return ( - <> + }> {documentTitle()} }> {(docWithRef) => ( @@ -230,7 +247,7 @@ export default function DocumentPage() { )} - + ); } @@ -531,6 +548,11 @@ export function DocumentPane(props: { )} + + {(liveConversation) => ( + + )} + @@ -562,6 +584,9 @@ export function DocumentPane(props: { )} + + + @@ -600,8 +625,15 @@ async function getLiveDocument( const { liveInstance, docRef } = await getLiveInstance(refId, api, models, binder); return { liveDoc: liveInstance, docRef }; } - case "llmconversation": - throw new Error("LLM conversation pages are not implemented"); + case "llmconversation": { + const { liveConversation, docRef } = await getLiveLLMConversation( + refId, + api, + models, + binder, + ); + return { liveDoc: liveConversation, docRef }; + } default: assertExhaustive(documentType); } From 0212e2907e7d972dafee392347d925b829ebe8a6 Mon Sep 17 00:00:00 2001 From: tslil-topos Date: Thu, 27 Aug 2026 15:31:41 +0100 Subject: [PATCH 15/83] don't give custom names to cell types in logics --- packages/logics/src/petri-net.ts | 6 +++--- packages/logics/src/simple-olog.ts | 4 ++-- packages/logics/src/simple-schema.ts | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/logics/src/petri-net.ts b/packages/logics/src/petri-net.ts index 6bf1a30ef1..a5199a8d0f 100644 --- a/packages/logics/src/petri-net.ts +++ b/packages/logics/src/petri-net.ts @@ -22,7 +22,7 @@ export const PetriNet = defineShape({ }); export const petriNetCellTypes = { - petri_net_place: Place, - petri_net_transition: Transition, - petri_net_rich_text: RichText, + Place, + Transition, + RichText, }; diff --git a/packages/logics/src/simple-olog.ts b/packages/logics/src/simple-olog.ts index a06369b3fb..4f8d3ee81e 100644 --- a/packages/logics/src/simple-olog.ts +++ b/packages/logics/src/simple-olog.ts @@ -18,6 +18,6 @@ export const SimpleOlog = defineShape({ }); export const simpleOlogCellTypes = { - olog_type: Type, - olog_aspect: Aspect, + Type, + Aspect, }; diff --git a/packages/logics/src/simple-schema.ts b/packages/logics/src/simple-schema.ts index 45653b6863..da38d929bc 100644 --- a/packages/logics/src/simple-schema.ts +++ b/packages/logics/src/simple-schema.ts @@ -24,9 +24,9 @@ export const SimpleSchema = defineShape({ }); export const simpleSchemaCellTypes = { - schema_entity: Entity, - schema_attr_type: AttrType, - schema_mapping: Mapping, - schema_attr: Attr, - schema_rich_text: RichText, + Entity, + AttrType, + Mapping, + Attr, + RichText, }; From 3947823a7f4b737bf9116a36a92bee9178a0a0bd Mon Sep 17 00:00:00 2001 From: Kaspar Date: Fri, 28 Aug 2026 12:34:59 +0100 Subject: [PATCH 16/83] ENH: Reactive view for frontend binder (#1434) --- packages/frontend/package.json | 2 + packages/frontend/pnpm-lock.yaml | 85 +++++++++++- .../frontend/src/api/document_store.test.ts | 122 +++++++++++++++++- packages/frontend/src/api/document_store.ts | 22 ++-- packages/frontend/vite.config.ts | 13 ++ 5 files changed, 228 insertions(+), 16 deletions(-) diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9bcd2df7dd..ff29293dd5 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -29,6 +29,7 @@ "@automerge/automerge": "^3.4.1", "@automerge/automerge-repo": "^2.5.6", "@automerge/automerge-repo-network-websocket": "^2.5.6", + "@automerge/automerge-repo-solid-primitives": "^2.5.6", "@automerge/automerge-repo-storage-indexeddb": "^2.5.6", "@automerge/prosemirror": "^0.2.0", "@benrbray/prosemirror-math": "^1.0.0", @@ -88,6 +89,7 @@ "@types/node": "^24.0.0", "catcolab-logics": "link:../logics", "eslint-plugin-solid": "^0.14.5", + "happy-dom": "^20.0.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-math": "^6.0.0", diff --git a/packages/frontend/pnpm-lock.yaml b/packages/frontend/pnpm-lock.yaml index 7e56d75390..cbf96a638c 100644 --- a/packages/frontend/pnpm-lock.yaml +++ b/packages/frontend/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@automerge/automerge-repo-network-websocket': specifier: ^2.5.6 version: 2.5.6 + '@automerge/automerge-repo-solid-primitives': + specifier: ^2.5.6 + version: 2.5.6(@automerge/automerge@3.4.1)(solid-js@1.9.10) '@automerge/automerge-repo-storage-indexeddb': specifier: ^2.5.6 version: 2.5.6 @@ -192,6 +195,9 @@ importers: eslint-plugin-solid: specifier: ^0.14.5 version: 0.14.5(eslint@9.39.4)(typescript@6.0.3) + happy-dom: + specifier: ^20.0.0 + version: 20.11.2 rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -224,7 +230,7 @@ importers: version: 3.5.0(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.10.0)(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)) + version: 4.1.10(@types/node@24.10.0)(happy-dom@20.11.2)(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)) wasm-pack: specifier: ^0.15.0 version: 0.15.0 @@ -247,6 +253,12 @@ packages: '@automerge/automerge-repo-network-websocket@2.5.6': resolution: {integrity: sha512-xs/xzOCTf5ZuqhDUtBO1VQ3QOnunEobjtgEqGNu7UcoD2uYWET1ANHVfS+ZzSV8IOA1O9jFAPXxzc4FXoSL0hQ==} + '@automerge/automerge-repo-solid-primitives@2.5.6': + resolution: {integrity: sha512-jwxktGP36AhxpPc9d3qi/k9WS7lIRqjfdWhn6i/Mzc6qe47COHe2QBvdDrDo6FTbFBoRz7+H5fyx3e2tfBIXfA==} + peerDependencies: + '@automerge/automerge': ^3.0.0 + solid-js: ^1.9.4 + '@automerge/automerge-repo-storage-indexeddb@2.5.6': resolution: {integrity: sha512-3349orECq2Mj3mo9q6f9ZeXwDkP3lAm6KaivP5+znUpv6Ze+IraE36DdL5GVdzIm+cXej0yWSOCQOalnTMPcQg==} @@ -1241,6 +1253,12 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@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} @@ -1421,6 +1439,10 @@ packages: bs58check@3.0.1: resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1587,6 +1609,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -1806,6 +1832,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + happy-dom@20.11.2: + resolution: {integrity: sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==} + engines: {node: '>=20.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -3092,6 +3122,10 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3126,6 +3160,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xstate@5.20.1: resolution: {integrity: sha512-i9ZpNnm/XhCOMUxae1suT8PjYNTStZWbhmuKt4xeTPaYG5TS0Fz0i+Ka5yxoNPpaHW3VW6JIowrwFgSTZONxig==} @@ -3194,6 +3240,11 @@ snapshots: - supports-color - utf-8-validate + '@automerge/automerge-repo-solid-primitives@2.5.6(@automerge/automerge@3.4.1)(solid-js@1.9.10)': + dependencies: + '@automerge/automerge': 3.4.1 + solid-js: 1.9.10 + '@automerge/automerge-repo-storage-indexeddb@2.5.6': dependencies: '@automerge/automerge-repo': 2.5.6 @@ -4310,6 +4361,12 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.0 + '@typescript-eslint/project-service@8.57.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@6.0.3) @@ -4511,6 +4568,10 @@ snapshots: '@noble/hashes': 1.8.0 bs58: 5.0.0 + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.10.0 + callsites@3.1.0: {} camelcase@6.3.0: {} @@ -4661,6 +4722,8 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + es-module-lexer@2.3.1: {} esast-util-from-estree@2.0.0: @@ -4963,6 +5026,19 @@ snapshots: globals@14.0.0: {} + happy-dom@20.11.2: + dependencies: + '@types/node': 24.10.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -6748,7 +6824,7 @@ snapshots: optionalDependencies: vite: 7.2.2(@types/node@24.10.0)(yaml@2.5.1) - vitest@4.1.10(@types/node@24.10.0)(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)): + vitest@4.1.10(@types/node@24.10.0)(happy-dom@20.11.2)(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)) @@ -6772,6 +6848,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.0 + happy-dom: 20.11.2 transitivePeerDependencies: - msw @@ -6793,6 +6870,8 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -6818,6 +6897,8 @@ snapshots: ws@8.18.3: {} + ws@8.21.3: {} + xstate@5.20.1: {} y18n@5.0.8: {} diff --git a/packages/frontend/src/api/document_store.test.ts b/packages/frontend/src/api/document_store.test.ts index 3cafea49f1..e4b106bfbe 100644 --- a/packages/frontend/src/api/document_store.test.ts +++ b/packages/frontend/src/api/document_store.test.ts @@ -1,8 +1,14 @@ +// @vitest-environment happy-dom +// The happy-dom environment makes solid-js resolve to its reactive client +// build, which the reactive projection tests depend on. +import { getObjectId } from "@automerge/automerge"; import { Repo } from "@automerge/automerge-repo"; import { SimpleOlog, Type } from "catcolab-logics/simple-olog"; +import { createRenderEffect, createRoot } from "solid-js"; +import { unwrap } from "solid-js/store"; import { describe, expect, test } from "vitest"; -import { Instance as LegacyInstance, Model } from "catcolab-document-methods"; +import { Instance as LegacyInstance, Model, type ModelDocument } from "catcolab-document-methods"; import { createBinder, defineShape, type InstanceDocument } from "catcolab-documents"; import { makeLiveDoc } from "./document"; import { createApiDocumentStore } from "./document_store"; @@ -126,6 +132,32 @@ describe("API document store", () => { expect(unsupported).toMatchObject({ tag: "Err" }); }); + test("document views are reactive projections", async () => { + const { schema, store } = await createFixtureWithSchema(); + + const handle = await store.getHandle({ id: schemaRef, version: null }); + expect(handle.tag).toBe("Ok"); + if (handle.tag === "Err") { + throw new Error("expected local ref to resolve"); + } + + const names: string[] = []; + const dispose = createRoot((dispose) => { + createRenderEffect(() => { + names.push(store.getDocumentView(handle.content).name); + }); + return dispose; + }); + + store.changeDocument(handle.content, (doc) => { + doc.name = "Renamed"; + }); + dispose(); + + expect(names).toEqual(["", "Renamed"]); + expect(schema.title).toBe("Renamed"); + }); + test("resolves local refs to canonical cached handles", async () => { const { schemaAutomergeHandle, store } = createFixture(); @@ -137,4 +169,92 @@ describe("API document store", () => { expect(local.content.automergeHandle).toBe(schemaAutomergeHandle); expect(local.content.ref.server).toBe(server); }); + + test("copyValue detaches Solid projection values", async () => { + const { store } = createFixture(); + + const local = await store.getHandle({ id: schemaRef, version: null }); + expect(local.tag).toBe("Ok"); + if (local.tag === "Err") { + throw new Error("expected local ref to resolve"); + } + const handle = local.content; + + const view = store.getDocumentView(handle) as ModelDocument; + const copy = store.copyValue(handle, view); + + // A genuine deep copy: neither the store proxy nor its raw target. + expect(copy).not.toBe(view); + expect(copy).not.toBe(unwrap(view)); + expect(copy).toEqual(unwrap(view)); + expect(copy.notebook).not.toBe(unwrap(view).notebook); + + // Mutating the copy leaves the document untouched. + copy.name = "mutated copy"; + expect(store.getDocumentView(handle).name).toBe(""); + + // Mutating the document leaves the copy stale. + store.changeDocument(handle, (doc) => { + doc.name = "changed"; + }); + expect(copy.name).toBe("mutated copy"); + }); + + test("copyValue detaches Automerge proxy values", async () => { + const { schemaAutomergeHandle, store } = createFixture(); + + const local = await store.getHandle({ id: schemaRef, version: null }); + expect(local.tag).toBe("Ok"); + if (local.tag === "Err") { + throw new Error("expected local ref to resolve"); + } + const handle = local.content; + + const notebook = (schemaAutomergeHandle.doc() as ModelDocument).notebook; + expect(getObjectId(notebook)).not.toBeNull(); + + const copy = store.copyValue(handle, notebook); + expect(copy).not.toBe(notebook); + expect(copy).toEqual(notebook); + + // Mutating the copy succeeds (an Automerge proxy would throw outside a + // change context) and does not touch the document. + copy.cellOrder.push("bogus-cell"); + expect((schemaAutomergeHandle.doc() as ModelDocument).notebook.cellOrder).toHaveLength(0); + }); + + test("copyValue passes primitives through", async () => { + const { store } = createFixture(); + + const local = await store.getHandle({ id: schemaRef, version: null }); + expect(local.tag).toBe("Ok"); + if (local.tag === "Err") { + throw new Error("expected local ref to resolve"); + } + const handle = local.content; + + expect(store.copyValue(handle, 42)).toBe(42); + expect(store.copyValue(handle, "x")).toBe("x"); + expect(store.copyValue(handle, null)).toBeNull(); + }); + + test("notebook dump returns a detached plain document", async () => { + const { schema, store } = await createFixtureWithSchema(); + schema.add(Type, { label: "Person" }); + + const local = await store.getHandle({ id: schemaRef, version: null }); + expect(local.tag).toBe("Ok"); + if (local.tag === "Err") { + throw new Error("expected local ref to resolve"); + } + const handle = local.content; + + const dumped = schema.dump(); + expect(dumped).not.toBe(store.getDocumentView(handle)); + expect(dumped).toEqual(unwrap(store.getDocumentView(handle))); + + // Mutating the dump does not write back to the notebook. + dumped.name = "mutated dump"; + expect(schema.title).toBe(""); + }); }); diff --git a/packages/frontend/src/api/document_store.ts b/packages/frontend/src/api/document_store.ts index 6cc12b6353..07069815a2 100644 --- a/packages/frontend/src/api/document_store.ts +++ b/packages/frontend/src/api/document_store.ts @@ -1,5 +1,6 @@ -import { getBackend, getObjectId } from "@automerge/automerge"; import type { DocHandle } from "@automerge/automerge-repo"; +import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives"; +import { unwrap } from "solid-js/store"; import type { Document } from "catcolab-document-types"; import { @@ -13,6 +14,10 @@ import type { Api } from "./types"; export type ApiDocumentHandle = { automergeHandle: DocHandle; + + /** Fine-grained reactive view of the document, for use in SolidJS contexts. */ + docView: Document; + ref: DocumentRef; }; @@ -41,7 +46,7 @@ export function createApiDocumentStore(api: Api): ApiDocumentStore { existing.ref = ref; return existing; } - const handle = { automergeHandle, ref }; + const handle = { automergeHandle, docView: makeDocumentProjection(automergeHandle), ref }; handles.set(ref.id, handle); return handle; }; @@ -55,22 +60,13 @@ export function createApiDocumentStore(api: Api): ApiDocumentStore { automergeHandle, ); }, - getDocumentView: (handle) => handle.automergeHandle.doc(), + getDocumentView: (handle) => handle.docView, changeDocument: (handle, fn) => handle.automergeHandle.change(fn), subscribe: (handle, callback) => { handle.automergeHandle.on("change", callback); return () => handle.automergeHandle.off("change", callback); }, - copyValue: (handle, value) => { - if (typeof value !== "object" || value === null) { - return value; - } - const objectId = getObjectId(value); - if (!objectId) { - return structuredClone(value); - } - return getBackend(handle.automergeHandle.doc()).materialize(objectId) as typeof value; - }, + copyValue: (_handle, value) => structuredClone(unwrap(value)), getDocumentRef: (handle) => handle.ref, async getHandle(ref: DocumentRef): Promise> { if (ref.version !== null) { diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index dd05702864..e8bf06eee9 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -2,6 +2,7 @@ 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"; +import { defaultServerConditions } from "vite"; import solid from "vite-plugin-solid"; import wasm from "vite-plugin-wasm"; import { defineConfig } from "vitest/config"; @@ -24,10 +25,22 @@ export default defineConfig({ sourcemap: true, target: "es2022", }, + // Vitest runs with node resolve conditions, which select solid-js's + // non-reactive server build. vite-plugin-solid only fixes this when + // vitest runs in mode "test", but our tests run in mode "development", + // so prefer browser builds ourselves when running under vitest. + resolve: process.env.VITEST ? { conditions: ["browser", ...defaultServerConditions] } : {}, test: { // Run test files sequentially to prevent cross-test contamination via // the server's shared user state. fileParallelism: false, + server: { + deps: { + // Process solid through vite so that a single, consistent + // build of the reactive runtime is used everywhere. + inline: [/solid-js/, /automerge-repo-solid-primitives/], + }, + }, }, server: { proxy: { From 83f52ee9c62983bca225d03e077092684ed1306a Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Wed, 26 Aug 2026 17:58:31 +0100 Subject: [PATCH 17/83] ENH: Add ElaboratedModel with shape-typed judgments --- packages/documents/src/index.ts | 6 + .../documents/src/model/elaborated-model.ts | 154 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 packages/documents/src/model/elaborated-model.ts diff --git a/packages/documents/src/index.ts b/packages/documents/src/index.ts index 2d0a9e5f76..c4e0fbfc72 100644 --- a/packages/documents/src/index.ts +++ b/packages/documents/src/index.ts @@ -27,6 +27,12 @@ export type { CellOf as NotebookCell, MorphismCell, ObjectCell } from "./model/c export type { ModelDocument } from "./model/document"; export { modelNotebookFromStore } from "./model/notebook"; export type { Notebook } from "./model/notebook"; +export type { + JudgmentOf, + MorphismJudgment, + ObjectJudgment, + ElaboratedModel, +} from "./model/elaborated-model"; export type { NotebookDocument } from "./notebook-document"; export type { RichTextCell } from "./rich-text"; export { defineMorphism, defineObject, defineShape, RichText } from "./shape"; diff --git a/packages/documents/src/model/elaborated-model.ts b/packages/documents/src/model/elaborated-model.ts new file mode 100644 index 0000000000..744c6945e5 --- /dev/null +++ b/packages/documents/src/model/elaborated-model.ts @@ -0,0 +1,154 @@ +import type { ModelPresentation, MorGenerator, ObGenerator } from "catlog-wasm"; +import { + findMorphismType, + findObjectType, + type AnyCellType, + type MorphismType, + type MorphismTypesOf, + type ObjectType, + type ObjectTypesOf, + type Shape, +} from "../shape"; +import { morphismTypesEqual, objectTypesEqual } from "./equality"; + +/** An object judgment of an elaborated model. Mirrors [`ObjectCell`], minus mutation. */ +export interface ObjectJudgment { + readonly kind: "object"; + readonly id: string; + readonly type: O; + readonly label: string; +} + +/** A morphism judgment of an elaborated model. Mirrors [`MorphismCell`], minus mutation. + +The endpoints are resolved to object judgments; an endpoint that is not a basic +object generator is `null`. */ +export interface MorphismJudgment< + out S extends Shape, + M extends MorphismType = MorphismTypesOf, +> { + readonly kind: "morphism"; + readonly id: string; + readonly type: M; + readonly label: string; + readonly from: ObjectJudgment> | null; + readonly to: ObjectJudgment> | null; +} + +/** A judgment of an elaborated model.*/ +export type JudgmentOf = + | ObjectJudgment> + | MorphismJudgment>; + +/** Read-only, shape-typed access to an elaborated model. + +The API mirrors the notebook's `cells`/`cellsOf`: judgments are returned in +presentation order (objects first, then morphisms). */ +export interface ElaboratedModel { + judgments(): ReadonlyArray>; + judgmentsOf(typeOrShape: AnyCellType | Shape): ReadonlyArray>; +} + +/** Create an elaborated model over a (possibly changing) model presentation. */ +export function elaboratedModelFromPresentation( + shape: S, + getPresentation: () => Readonly | undefined, +): ElaboratedModel { + function judgments(): ReadonlyArray> { + const presentation = getPresentation(); + if (presentation === undefined) { + return []; + } + + const objectsById = new Map>>(); + const objects: ObjectJudgment>[] = []; + for (const generator of presentation.obGenerators) { + const type = findObjectType(shape, generator.obType); + if (type === undefined) { + continue; + } + const judgment: ObjectJudgment> = { + kind: "object", + id: generator.id, + type, + label: displayLabel(generator), + }; + objectsById.set(generator.id, judgment); + objects.push(judgment); + } + + const morphisms: MorphismJudgment>[] = []; + for (const generator of presentation.morGenerators) { + const type = findMorphismType(shape, generator.morType); + if (type === undefined) { + continue; + } + morphisms.push({ + kind: "morphism", + id: generator.id, + type, + label: displayLabel(generator), + from: endpointJudgment(objectsById, generator.dom), + to: endpointJudgment(objectsById, generator.cod), + }); + } + + return [...objects, ...morphisms]; + } + + return { + judgments, + judgmentsOf(typeOrShape: AnyCellType | Shape): ReadonlyArray> { + return judgments().filter((judgment) => judgmentMatchesFilter(judgment, typeOrShape)); + }, + }; +} + +function displayLabel(generator: Pick): string { + return generator.label?.join(".") ?? ""; +} + +function endpointJudgment( + objectsById: ReadonlyMap>>, + endpoint: MorGenerator["dom"], +): ObjectJudgment> | null { + if (endpoint.tag !== "Basic") { + return null; + } + return objectsById.get(endpoint.content) ?? null; +} + +function isCellType(value: AnyCellType | Shape): value is AnyCellType { + return "kind" in value; +} + +function judgmentMatchesFilter( + judgment: JudgmentOf, + filter: AnyCellType | Shape, +): boolean { + if (isCellType(filter)) { + switch (filter.kind) { + case "rich-text": + return false; + case "object": + return ( + judgment.kind === "object" && + objectTypesEqual(judgment.type.obType, filter.obType) + ); + case "morphism": + return ( + judgment.kind === "morphism" && + morphismTypesEqual(judgment.type.morType, filter.morType) + ); + } + } + + if (judgment.kind === "object") { + return (filter.objects ?? []).some((type) => + objectTypesEqual(judgment.type.obType, type.obType), + ); + } + return (filter.morphisms ?? []).some((type) => + morphismTypesEqual(judgment.type.morType, type.morType), + ); +} From 52c1d229a24f5c1e7e783f719d002c7ff54212dd Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Wed, 26 Aug 2026 18:36:22 +0100 Subject: [PATCH 18/83] ENH: Use ElaboratedModel instead of DblMdl in validation result --- packages/documents/src/binder.ts | 31 ++++------ .../src/instance/instance-runtime.ts | 48 ++++++--------- .../documents/src/instance/table-methods.ts | 61 +++++++++---------- packages/documents/src/instance/validation.ts | 13 ++-- .../documents/src/model/elaborated-model.ts | 23 +++---- packages/documents/src/model/notebook.ts | 20 ++++-- packages/documents/src/model/validation.ts | 28 ++++++--- packages/documents/test/instances.test.ts | 14 ++--- packages/documents/test/validation.test.ts | 21 +++---- .../src/llm_conversation/scoped_document.ts | 4 -- 10 files changed, 127 insertions(+), 136 deletions(-) diff --git a/packages/documents/src/binder.ts b/packages/documents/src/binder.ts index f415c3010e..93371aa9a1 100644 --- a/packages/documents/src/binder.ts +++ b/packages/documents/src/binder.ts @@ -141,24 +141,19 @@ function binderFromStore(store: DocumentStore): Binder { return schemaResult; } - const schemaModel = schemaResult.content; - try { - const schemaRef = store.getDocumentRef(schema.handle); - const document = InstanceMethods.newInstanceDocument({ - _id: schemaRef.id, - _version: schemaRef.version, - _server: schemaRef.server ?? "", - }); - document.name = options.title; - - const handle = await store.createHandle(document); - return { - tag: "Ok", - content: instanceFromStore(shape, schema, store, handle), - }; - } finally { - schemaModel.free(); - } + const schemaRef = store.getDocumentRef(schema.handle); + const document = InstanceMethods.newInstanceDocument({ + _id: schemaRef.id, + _version: schemaRef.version, + _server: schemaRef.server ?? "", + }); + document.name = options.title; + + const handle = await store.createHandle(document); + return { + tag: "Ok", + content: instanceFromStore(shape, schema, store, handle), + }; }, async createLLMConversation>( attachment: Attachment, diff --git a/packages/documents/src/instance/instance-runtime.ts b/packages/documents/src/instance/instance-runtime.ts index 2173d8e6ad..c3ebb02387 100644 --- a/packages/documents/src/instance/instance-runtime.ts +++ b/packages/documents/src/instance/instance-runtime.ts @@ -1,7 +1,7 @@ import type { InstanceDocument } from "catcolab-document-methods"; -import type { DblModel } from "catlog-wasm"; import type { DocumentStore } from "../document-store"; import type { ModelDocument } from "../model/document"; +import type { ElaboratedModel } from "../model/elaborated-model"; import type { Notebook } from "../model/notebook"; import type { Issue, Result } from "../result"; import type { InstanceCapableShape, Shape } from "../shape"; @@ -26,7 +26,7 @@ function instanceCapableShape( return shape as InstanceCapableShape; } -/** Validate the schema, run an operation against the resulting model, and free it. +/** Validate the schema and run an operation against the resulting model. `operation` is expected to report its own failures as a `Result`; this does not catch exceptions, so an operation that throws lets that exception propagate. */ @@ -37,19 +37,14 @@ async function withValidatedSchema< E extends ReadonlyArray = ReadonlyArray, >( schema: Notebook, - operation: (schemaModel: DblModel) => Result, + operation: (schemaModel: ElaboratedModel) => Result, ): Promise> { const schemaResult = await schema.validate(); if (schemaResult.tag === "Err") { return schemaResult as Result; } - const schemaModel = schemaResult.content; - try { - return operation(schemaModel); - } finally { - schemaModel.free(); - } + return operation(schemaResult.content); } export function createTablesMethod( @@ -189,29 +184,26 @@ export function createSchemaResultValidator( schema: Notebook, store: DocumentStore, handle: Handle, -): (schemaResult: Result) => Result> { +): ( + schemaResult: Result>, +) => Result> { return (schemaResult) => { if (schemaResult.tag === "Err") { return schemaResult; } - const schemaModel = schemaResult.content; - try { - const tables = instanceTablesFromModel( - instanceCapableShape(schema), - store, - handle, - schemaModel, - ); - const issues: TableFieldIssue[] = validateTableFields( - store.getDocumentView(handle) as Readonly, - tables, - ); - return issues.length === 0 - ? { tag: "Ok", content: undefined } - : { tag: "Err", content: issues }; - } finally { - schemaModel.free(); - } + const tables = instanceTablesFromModel( + instanceCapableShape(schema), + store, + handle, + schemaResult.content, + ); + const issues: TableFieldIssue[] = validateTableFields( + store.getDocumentView(handle) as Readonly, + tables, + ); + return issues.length === 0 + ? { tag: "Ok", content: undefined } + : { tag: "Err", content: issues }; }; } diff --git a/packages/documents/src/instance/table-methods.ts b/packages/documents/src/instance/table-methods.ts index e38a820b51..dffdb7f550 100644 --- a/packages/documents/src/instance/table-methods.ts +++ b/packages/documents/src/instance/table-methods.ts @@ -2,11 +2,11 @@ import { v7 as uuid } from "uuid"; import type { InstanceDocument } from "catcolab-document-methods"; import type * as DocumentTypes from "catcolab-document-types"; -import type { DblModel, ObGenerator } from "catlog-wasm"; +import type { QualifiedLabel } from "catlog-wasm"; import type { DocumentStore } from "../document-store"; -import { objectTypesEqual } from "../model/equality"; +import type { ElaboratedModel, ObjectJudgment } from "../model/elaborated-model"; import type { Issue, Result } from "../result"; -import type { InstanceCapableShape } from "../shape"; +import type { InstanceCapableShape, ObjectType, Shape } from "../shape"; import type { FieldPath } from "./errors"; import type { FieldValue, @@ -25,7 +25,7 @@ export function readInstancePathFromStore( shape: InstanceCapableShape, store: DocumentStore, handle: Handle, - schemaModel: DblModel, + schemaModel: ElaboratedModel, path: InstancePath, ): Result { const schemaTables = instanceTablesFromModel(shape, store, handle, schemaModel); @@ -73,7 +73,7 @@ export function addInstanceRowsToStore( shape: InstanceCapableShape, store: DocumentStore, handle: Handle, - schemaModel: DblModel, + schemaModel: ElaboratedModel, additions: ReadonlyArray<{ table: InstanceTable; values: ReadonlyArray>; @@ -132,7 +132,7 @@ export function updateInstanceFieldsByLabelInStore( shape: InstanceCapableShape, store: DocumentStore, handle: Handle, - schemaModel: DblModel, + schemaModel: ElaboratedModel, updates: ReadonlyArray<{ row: TableRow; values: ReadonlyArray>; @@ -172,7 +172,7 @@ export function updateInstanceFieldByIdInStore( shape: InstanceCapableShape, store: DocumentStore, handle: Handle, - schemaModel: DblModel, + schemaModel: ElaboratedModel, row: TableRow, field: { id: string }, value: LiteralValue | TableRow, @@ -320,48 +320,45 @@ export function instanceTablesFromModel( shape: InstanceCapableShape, store: DocumentStore, handle: Handle, - schemaModel: DblModel, + schemaModel: ElaboratedModel, ): readonly InstanceTable[] { - const presentation = schemaModel.presentation(); - const objectsById = new Map(presentation.obGenerators.map((object) => [object.id, object])); - const tableObjects = presentation.obGenerators.filter((object) => - shape.supportsInstances.tableObjects.some((type) => - objectTypesEqual(type.obType, object.obType), - ), - ); + const judgments = schemaModel.judgments(); + const tableObjects = schemaModel + .judgmentsOf({ objects: shape.supportsInstances.tableObjects }) + // This shouldn't be needed once judgmentsOf returns narrower types. + .filter((judgment): judgment is ObjectJudgment => judgment.kind === "object"); const tableIds = new Set(tableObjects.map((object) => object.id)); return tableObjects.map((tableObject) => { const headers: TableHeader[] = []; - for (const morphism of presentation.morGenerators) { - if (morphism.dom.tag !== "Basic" || morphism.dom.content !== tableObject.id) { - continue; - } - if (morphism.cod.tag !== "Basic") { + for (const morphism of judgments) { + if (morphism.kind !== "morphism" || morphism.from?.id !== tableObject.id) { continue; } - const codomainObject = objectsById.get(morphism.cod.content); - if (codomainObject === undefined) { + const codomain = morphism.to; + if (codomain === null) { continue; } - const label = displayLabel(morphism); - if (tableIds.has(codomainObject.id)) { + if (tableIds.has(codomain.id)) { headers.push({ id: morphism.id, - label, - type: { tag: "RowRef", content: { id: codomainObject.id } }, + label: displayLabel(morphism.label), + type: { tag: "RowRef", content: { id: codomain.id } }, }); continue; } - const attributeType = codomainObject; - const literalType = atomicTypeOfAttributeType(attributeType); - headers.push({ id: morphism.id, label, type: { tag: literalType } }); + const literalType = atomicTypeOfAttributeType(codomain.label); + headers.push({ + id: morphism.id, + label: displayLabel(morphism.label), + type: { tag: literalType }, + }); } const table: InstanceTable = { id: tableObject.id, - label: displayLabel(tableObject), + label: displayLabel(tableObject.label), headers, get rows() { const document = store.getDocumentView(handle) as Readonly; @@ -374,8 +371,8 @@ export function instanceTablesFromModel( }); } -function displayLabel(generator: Pick): string { - return generator.label?.join(".") ?? ""; +function displayLabel(label: QualifiedLabel): string { + return label.join("."); } function fieldValueFromStored(path: FieldPath, value: DocumentTypes.FieldValue): FieldValue { diff --git a/packages/documents/src/instance/validation.ts b/packages/documents/src/instance/validation.ts index 029e0febc9..4dfac6d69b 100644 --- a/packages/documents/src/instance/validation.ts +++ b/packages/documents/src/instance/validation.ts @@ -2,15 +2,16 @@ import type { InstanceDocument } from "catcolab-document-methods"; import type * as DocumentTypes from "catcolab-document-types"; -import type { ObGenerator } from "catlog-wasm"; +import type { QualifiedLabel } from "catlog-wasm"; import type { FieldPath, TableFieldIssue } from "./errors"; import type { InstanceTable, LiteralType, TableHeader } from "./tables"; -/** Decide which concrete atomic type an attribute type denotes. This function - is intended to be temporary and should be replaced once we have an account - of typing with which we are satisfied. */ -export function atomicTypeOfAttributeType(attributeType: Pick): LiteralType { - const name = attributeType.label?.[0]; +/** Decide which concrete atomic type an attribute type's qualified label + denotes. The first label segment decides. This function is intended to be + temporary and should be replaced once we have an account of typing with + which we are satisfied. */ +export function atomicTypeOfAttributeType(label: QualifiedLabel): LiteralType { + const name = label[0]; switch (name) { case "Bool": case "Int": diff --git a/packages/documents/src/model/elaborated-model.ts b/packages/documents/src/model/elaborated-model.ts index 744c6945e5..5937de50a2 100644 --- a/packages/documents/src/model/elaborated-model.ts +++ b/packages/documents/src/model/elaborated-model.ts @@ -1,4 +1,4 @@ -import type { ModelPresentation, MorGenerator, ObGenerator } from "catlog-wasm"; +import type { ModelPresentation, MorGenerator, QualifiedLabel } from "catlog-wasm"; import { findMorphismType, findObjectType, @@ -11,18 +11,17 @@ import { } from "../shape"; import { morphismTypesEqual, objectTypesEqual } from "./equality"; -/** An object judgment of an elaborated model. Mirrors [`ObjectCell`], minus mutation. */ +/** An object judgment of an elaborated model. Mirrors [`ObjectCell`], minus +mutation */ export interface ObjectJudgment { readonly kind: "object"; readonly id: string; readonly type: O; - readonly label: string; + readonly label: QualifiedLabel; } -/** A morphism judgment of an elaborated model. Mirrors [`MorphismCell`], minus mutation. - -The endpoints are resolved to object judgments; an endpoint that is not a basic -object generator is `null`. */ +/** A morphism judgment of an elaborated model. Mirrors [`MorphismCell`], minus +mutation display string. */ export interface MorphismJudgment< out S extends Shape, M extends MorphismType = MorphismTypesOf, @@ -30,7 +29,7 @@ export interface MorphismJudgment< readonly kind: "morphism"; readonly id: string; readonly type: M; - readonly label: string; + readonly label: QualifiedLabel; readonly from: ObjectJudgment> | null; readonly to: ObjectJudgment> | null; } @@ -71,7 +70,7 @@ export function elaboratedModelFromPresentation( kind: "object", id: generator.id, type, - label: displayLabel(generator), + label: generator.label ?? [], }; objectsById.set(generator.id, judgment); objects.push(judgment); @@ -87,7 +86,7 @@ export function elaboratedModelFromPresentation( kind: "morphism", id: generator.id, type, - label: displayLabel(generator), + label: generator.label ?? [], from: endpointJudgment(objectsById, generator.dom), to: endpointJudgment(objectsById, generator.cod), }); @@ -104,10 +103,6 @@ export function elaboratedModelFromPresentation( }; } -function displayLabel(generator: Pick): string { - return generator.label?.join(".") ?? ""; -} - function endpointJudgment( objectsById: ReadonlyMap>>, endpoint: MorGenerator["dom"], diff --git a/packages/documents/src/model/notebook.ts b/packages/documents/src/model/notebook.ts index 4e97669429..454a5497e4 100644 --- a/packages/documents/src/model/notebook.ts +++ b/packages/documents/src/model/notebook.ts @@ -1,6 +1,6 @@ import { Model, Nb } from "catcolab-document-methods"; import type { ModelJudgment } from "catcolab-document-types"; -import type { DblModel, DblTheory } from "catlog-wasm"; +import type { DblTheory, ModelPresentation } from "catlog-wasm"; import type { DocumentStore } from "../document-store"; import type { NotebookDocument } from "../notebook-document"; import type { Result } from "../result"; @@ -27,6 +27,7 @@ import { type ObjectCell, } from "./cell"; import type { ModelDocument } from "./document"; +import { elaboratedModelFromPresentation, type ElaboratedModel } from "./elaborated-model"; import { morphismTypesEqual, objectTypesEqual } from "./equality"; import { validateModelDocument } from "./validation"; @@ -179,8 +180,8 @@ export interface Notebook< update(patch: Partial<{ title: string }>): void; dump(): D; onChange(callback: () => void): () => void; - validate(): Promise>; - onValidate(callback: (result: Result) => void): () => void; + validate(): Promise>>; + onValidate(callback: (result: Result>) => void): () => void; } export function modelNotebookFromStore( @@ -190,8 +191,8 @@ export function modelNotebookFromStore( ): Notebook { let coreTheory: Promise | undefined; - async function validateCurrentDocument(): Promise> { - let result: Result; + async function validateCurrentDocument(): Promise>> { + let result: Result; if (!shape.getCoreTheory) { let shapeName = "unnamed"; if (shape.theory) { @@ -224,7 +225,14 @@ export function modelNotebookFromStore( } } - return result; + if (result.tag === "Err") { + return result; + } + const presentation = result.content; + return { + tag: "Ok", + content: elaboratedModelFromPresentation(shape, () => presentation), + }; } function appendCell( diff --git a/packages/documents/src/model/validation.ts b/packages/documents/src/model/validation.ts index 0439d93a50..ceedd1ae0d 100644 --- a/packages/documents/src/model/validation.ts +++ b/packages/documents/src/model/validation.ts @@ -1,6 +1,6 @@ import type { FormalCell, ModelDocument } from "catcolab-document-methods"; import type { ModelJudgment, Notebook } from "catcolab-document-types"; -import type { DblModel, DblTheory, InvalidDblModel } from "catlog-wasm"; +import type { DblModel, DblTheory, InvalidDblModel, ModelPresentation } from "catlog-wasm"; import type { Issue, Result } from "../result"; function formalCellForGenerator( @@ -88,12 +88,14 @@ function invalidModelIssue(notebook: Notebook, error: InvalidDblM } } -/** Elaborate and validate a model document against its core theory. */ +/** Elaborate and validate a model document against its core theory. + +On success, returns the presentation of the validated model. */ export async function validateModelDocument( document: Readonly, theory: DblTheory, refId: string, -): Promise> { +): Promise> { const { DblModelMap, elaborateModel } = await import("catlog-wasm"); const instantiatedModels = new DblModelMap(); let model: DblModel; @@ -108,12 +110,18 @@ export async function validateModelDocument( instantiatedModels.free(); } - const validation = model.validate(); - if (validation.tag === "Err") { - return { - tag: "Err", - content: validation.content.map((error) => invalidModelIssue(document.notebook, error)), - }; + try { + const validation = model.validate(); + if (validation.tag === "Err") { + return { + tag: "Err", + content: validation.content.map((error) => + invalidModelIssue(document.notebook, error), + ), + }; + } + return { tag: "Ok", content: model.presentation() }; + } finally { + model.free(); } - return { tag: "Ok", content: model }; } diff --git a/packages/documents/test/instances.test.ts b/packages/documents/test/instances.test.ts index 8fc0d2f297..e70c07706f 100644 --- a/packages/documents/test/instances.test.ts +++ b/packages/documents/test/instances.test.ts @@ -497,12 +497,12 @@ describe("tabular instances", () => { describe("atomic instance column types", () => { test("attribute type labels match exact atomic names", () => { - expect(atomicTypeOfAttributeType({ label: ["Bool"] })).toBe("Bool"); - expect(atomicTypeOfAttributeType({ label: ["Int"] })).toBe("Int"); - expect(atomicTypeOfAttributeType({ label: ["Float"] })).toBe("Float"); - expect(atomicTypeOfAttributeType({ label: ["String"] })).toBe("String"); - expect(atomicTypeOfAttributeType({ label: ["namespace", "Bool"] })).toBe("String"); - expect(atomicTypeOfAttributeType({ label: undefined })).toBe("String"); - expect(atomicTypeOfAttributeType({ label: ["string"] })).toBe("String"); + expect(atomicTypeOfAttributeType(["Bool"])).toBe("Bool"); + expect(atomicTypeOfAttributeType(["Int"])).toBe("Int"); + expect(atomicTypeOfAttributeType(["Float"])).toBe("Float"); + expect(atomicTypeOfAttributeType(["String"])).toBe("String"); + expect(atomicTypeOfAttributeType(["namespace", "Bool"])).toBe("String"); + expect(atomicTypeOfAttributeType([])).toBe("String"); + expect(atomicTypeOfAttributeType(["string"])).toBe("String"); }); }); diff --git a/packages/documents/test/validation.test.ts b/packages/documents/test/validation.test.ts index 6197b00704..6473c7bf78 100644 --- a/packages/documents/test/validation.test.ts +++ b/packages/documents/test/validation.test.ts @@ -3,8 +3,7 @@ import { describe, expect, test } from "vitest"; // RFC-0006 "Model validation": the asynchronous `validate` method, subscribing // to validation changes with `onValidate`, and validation failures. -import { createBinder, Instantiation, type Result } from "catcolab-documents"; -import type { DblModel } from "catlog-wasm"; +import { createBinder, type ElaboratedModel, Instantiation, type Result } from "catcolab-documents"; async function wellFormedOlog() { const binder = createBinder(); @@ -20,14 +19,14 @@ async function wellFormedOlog() { // the first time tests run we incur the cost of loading the catlog-wasm bundle, // so these tests have longer timeouts describe("validate", { timeout: 10000 }, () => { - test("a well-formed notebook validates to an Ok carrying the model", async () => { + test("a well-formed notebook validates to an Ok carrying the elaborated model", async () => { const notebook = await wellFormedOlog(); - const result: Result = await notebook.validate(); + const result: Result> = await notebook.validate(); expect(result.tag).toBe("Ok"); }); - test("the validated model is available on the result and can be queried", async () => { + test("the elaborated model is available on the result and can be queried", async () => { const notebook = await wellFormedOlog(); const result = await notebook.validate(); @@ -35,8 +34,8 @@ describe("validate", { timeout: 10000 }, () => { if (result.tag !== "Ok") { return; } - expect(result.content.obGenerators().length).toBe(2); - expect(result.content.morGenerators().length).toBe(1); + expect(result.content.judgmentsOf(Type).length).toBe(2); + expect(result.content.judgmentsOf(Aspect).length).toBe(1); }); }); @@ -44,8 +43,8 @@ describe("onValidate", { timeout: 10000 }, () => { test("always calls the callback at least once with the current validation", async () => { const notebook = await wellFormedOlog(); - const first = await new Promise>((resolve) => { - const unsubscribe = notebook.onValidate((result: Result) => { + const first = await new Promise>>((resolve) => { + const unsubscribe = notebook.onValidate((result) => { resolve(result); unsubscribe(); }); @@ -57,8 +56,8 @@ describe("onValidate", { timeout: 10000 }, () => { test("notifies when changes to the formal content affect the validation result", async () => { const notebook = await wellFormedOlog(); - const results: Result[] = []; - const unsubscribe = notebook.onValidate((result: Result) => { + const results: Result>[] = []; + const unsubscribe = notebook.onValidate((result) => { results.push(result); }); diff --git a/packages/frontend/src/llm_conversation/scoped_document.ts b/packages/frontend/src/llm_conversation/scoped_document.ts index 2a86b5f77d..b1e0d3fe59 100644 --- a/packages/frontend/src/llm_conversation/scoped_document.ts +++ b/packages/frontend/src/llm_conversation/scoped_document.ts @@ -1,6 +1,5 @@ import type { Document } from "catcolab-document-types"; import { type DocumentStore, type Issue, type Result } from "catcolab-documents"; -import type { DblModel } from "catlog-wasm"; import { createDocumentTransaction } from "./document_transaction"; export type DocumentBinding = { @@ -75,9 +74,6 @@ async function validateScopedDocument( if (result.tag === "Err") { return [`${binding}: ${JSON.stringify(result.content)}`]; } - if (document.document.type === "model") { - (result.content as DblModel).free(); - } return []; } From f29f9914c095f7ffc155fa36ae8ba3045e9ca180 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Wed, 26 Aug 2026 19:36:23 +0100 Subject: [PATCH 19/83] REFACTOR: Use void instead of undefined in return types --- packages/documents/src/instance/instance-runtime.ts | 2 +- packages/documents/src/instance/instance.ts | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/documents/src/instance/instance-runtime.ts b/packages/documents/src/instance/instance-runtime.ts index c3ebb02387..bd5f18bde5 100644 --- a/packages/documents/src/instance/instance-runtime.ts +++ b/packages/documents/src/instance/instance-runtime.ts @@ -186,7 +186,7 @@ export function createSchemaResultValidator( handle: Handle, ): ( schemaResult: Result>, -) => Result> { +) => Result> { return (schemaResult) => { if (schemaResult.tag === "Err") { return schemaResult; diff --git a/packages/documents/src/instance/instance.ts b/packages/documents/src/instance/instance.ts index 2569a52556..39364f1cc4 100644 --- a/packages/documents/src/instance/instance.ts +++ b/packages/documents/src/instance/instance.ts @@ -94,7 +94,7 @@ export function instanceFromStore( const validateSchemaResult = createSchemaResultValidator(schema, store, handle); async function validateCurrentDocument(): Promise< - Result> + Result> > { return validateSchemaResult(await schema.validate()); } @@ -148,7 +148,7 @@ export function instanceFromStore( deleteRows(rows: ReadonlyArray<{ tableId: string; rowId: string }>): void { deleteStoredRows(rows); }, - validate(): Promise>> { + validate(): Promise>> { return validateCurrentDocument(); }, onChange(callback: () => void): () => void { @@ -160,13 +160,11 @@ export function instanceFromStore( }; }, onValidate( - callback: (result: Result>) => void, + callback: (result: Result>) => void, ): () => void { let active: boolean = true; - function notify( - result: Result>, - ): void { + function notify(result: Result>): void { if (active) { callback(result); } From e93c7bffa85054645a653f6a215c6e802940ecea Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Wed, 26 Aug 2026 17:58:39 +0100 Subject: [PATCH 20/83] ENH: Add a reactive validation view to notebooks --- .github/workflows/ci.yml | 8 +- .../src/document-store/document-store.ts | 29 +++ .../documents/src/document-store/index.ts | 3 +- packages/documents/src/index.ts | 3 +- .../documents/src/model/elaborated-model.ts | 20 ++ packages/documents/src/model/notebook.ts | 81 +------- packages/documents/src/model/validation.ts | 184 ++++++++++++++++-- .../test/stores/solid-store-fixture.ts | 63 ++++++ .../documents/test/stores/solid-store.test.ts | 58 +----- .../test/stores/solid-validation-view.test.ts | 49 +++++ .../documents/test/validation-view.test.ts | 75 +++++++ packages/documents/tsconfig.test.json | 4 +- packages/frontend/src/api/document_store.ts | 11 +- 13 files changed, 436 insertions(+), 152 deletions(-) create mode 100644 packages/documents/test/stores/solid-store-fixture.ts create mode 100644 packages/documents/test/stores/solid-validation-view.test.ts create mode 100644 packages/documents/test/validation-view.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbf53dc2f5..bc04c2f170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,13 +124,7 @@ jobs: - name: Typescript documents package tests run: | - pnpm --filter catcolab-documents exec vitest run \ - test/creating-and-editing.test.ts \ - test/definitions.test.ts \ - test/binder.test-d.ts \ - test/llm-conversation.test.ts \ - test/instances.test.ts \ - test/serialization.test.ts \ + pnpm --filter catcolab-documents test - name: Typescript logic package tests run: | diff --git a/packages/documents/src/document-store/document-store.ts b/packages/documents/src/document-store/document-store.ts index 2d36b77ac9..d1e6d4f65e 100644 --- a/packages/documents/src/document-store/document-store.ts +++ b/packages/documents/src/document-store/document-store.ts @@ -7,6 +7,12 @@ export interface DocumentRef { server?: string; } +/** A replaceable view of a value that may be reactive in the host application. */ +export interface ReactiveView { + readonly current: Readonly; + replace(next: T): void; +} + export interface DocumentStore { // An async function to create a document handle from initial data. createHandle(initialDoc: Document): Promise; @@ -24,4 +30,27 @@ export interface DocumentStore { getDocumentRef(handle: Handle): DocumentRef; // Get a document view from a handle getDocumentView(handle: Handle): Readonly; + // Create a reactive view for values derived from documents. Stores may + // use this hook to integrate their host application's reactive primitives. + createReactiveView?(initial: T): ReactiveView; +} + +/** Create a store-native reactive view, falling back to a plain replaceable value. */ +export function createReactiveView( + store: DocumentStore, + initial: T, +): ReactiveView { + if (store.createReactiveView) { + return store.createReactiveView(initial); + } + + let current: T = initial; + return { + get current(): Readonly { + return current; + }, + replace(next: T): void { + current = next; + }, + }; } diff --git a/packages/documents/src/document-store/index.ts b/packages/documents/src/document-store/index.ts index 5776e009b4..3da284605b 100644 --- a/packages/documents/src/document-store/index.ts +++ b/packages/documents/src/document-store/index.ts @@ -1,2 +1,3 @@ -export type { DocumentRef, DocumentStore } from "./document-store"; +export type { DocumentRef, DocumentStore, ReactiveView } from "./document-store"; +export { createReactiveView } from "./document-store"; export { createInMemoryStore } from "./in-memory"; diff --git a/packages/documents/src/index.ts b/packages/documents/src/index.ts index c4e0fbfc72..27fa6e967b 100644 --- a/packages/documents/src/index.ts +++ b/packages/documents/src/index.ts @@ -1,7 +1,7 @@ export { createBinder } from "./binder"; export { CellKind } from "./model/cell"; export type { Binder } from "./binder"; -export type { DocumentStore, DocumentRef } from "./document-store"; +export type { DocumentStore, DocumentRef, ReactiveView } from "./document-store"; export type { Issue, PathSegment, Result } from "./result"; export { createInMemoryStore } from "./document-store"; export { atomicTypeOfAttributeType } from "./instance/validation"; @@ -32,6 +32,7 @@ export type { MorphismJudgment, ObjectJudgment, ElaboratedModel, + ValidationView, } from "./model/elaborated-model"; export type { NotebookDocument } from "./notebook-document"; export type { RichTextCell } from "./rich-text"; diff --git a/packages/documents/src/model/elaborated-model.ts b/packages/documents/src/model/elaborated-model.ts index 5937de50a2..a478c96590 100644 --- a/packages/documents/src/model/elaborated-model.ts +++ b/packages/documents/src/model/elaborated-model.ts @@ -1,4 +1,5 @@ import type { ModelPresentation, MorGenerator, QualifiedLabel } from "catlog-wasm"; +import type { Issue } from "../result"; import { findMorphismType, findObjectType, @@ -48,6 +49,25 @@ export interface ElaboratedModel { judgmentsOf(typeOrShape: AnyCellType | Shape): ReadonlyArray>; } +/** A live view of a notebook's validation state. + +While the view is active, the notebook revalidates whenever its document +changes; `model` and `issues` reflect the latest outcome. The caller must +dispose the view when it is no longer needed. + +Elaboration and validation are separate steps, so `model` is always available: +it reflects the latest elaboration even when validation fails. It is empty +only before the first elaboration completes or when elaboration itself fails. + +In the case of an empty model the `model.judgments` and `model.judgementsOf` methods +return empty arrays. */ +export interface ValidationView { + readonly model: ElaboratedModel; + /** Validation issues; empty when the notebook is valid. */ + readonly issues: ReadonlyArray; + dispose(): void; +} + /** Create an elaborated model over a (possibly changing) model presentation. */ export function elaboratedModelFromPresentation( shape: S, diff --git a/packages/documents/src/model/notebook.ts b/packages/documents/src/model/notebook.ts index 454a5497e4..20124ede40 100644 --- a/packages/documents/src/model/notebook.ts +++ b/packages/documents/src/model/notebook.ts @@ -1,6 +1,5 @@ import { Model, Nb } from "catcolab-document-methods"; import type { ModelJudgment } from "catcolab-document-types"; -import type { DblTheory, ModelPresentation } from "catlog-wasm"; import type { DocumentStore } from "../document-store"; import type { NotebookDocument } from "../notebook-document"; import type { Result } from "../result"; @@ -27,9 +26,9 @@ import { type ObjectCell, } from "./cell"; import type { ModelDocument } from "./document"; -import { elaboratedModelFromPresentation, type ElaboratedModel } from "./elaborated-model"; +import type { ElaboratedModel, ValidationView } from "./elaborated-model"; import { morphismTypesEqual, objectTypesEqual } from "./equality"; -import { validateModelDocument } from "./validation"; +import { createNotebookValidator } from "./validation"; /** * The value given to [`Notebook.add`]. @@ -182,6 +181,9 @@ export interface Notebook< onChange(callback: () => void): () => void; validate(): Promise>>; onValidate(callback: (result: Result>) => void): () => void; + /** Create a live, reactive view of the notebook's validation state. The + * caller must dispose the view when it is no longer needed. */ + createValidationView(): ValidationView; } export function modelNotebookFromStore( @@ -189,51 +191,7 @@ export function modelNotebookFromStore( store: DocumentStore, handle: Handle, ): Notebook { - let coreTheory: Promise | undefined; - - async function validateCurrentDocument(): Promise>> { - let result: Result; - if (!shape.getCoreTheory) { - let shapeName = "unnamed"; - if (shape.theory) { - shapeName = shape.theory; - } - result = { - tag: "Err", - content: [{ message: `Shape \`${shapeName}\` has no core theory` }], - }; - } else { - try { - if (!coreTheory) { - coreTheory = shape.getCoreTheory(); - } - const theory = await coreTheory; - const document = store.copyValue( - handle, - store.getDocumentView(handle), - ) as ModelDocument; - result = await validateModelDocument( - document, - theory, - store.getDocumentRef(handle).id, - ); - } catch (error) { - result = { - tag: "Err", - content: [{ message: `Failed to load core theory: ${String(error)}` }], - }; - } - } - - if (result.tag === "Err") { - return result; - } - const presentation = result.content; - return { - tag: "Ok", - content: elaboratedModelFromPresentation(shape, () => presentation), - }; - } + const validator = createNotebookValidator(shape, store, handle); function appendCell( cell: ReturnType | Nb.FormalCell, @@ -319,29 +277,8 @@ export function modelNotebookFromStore( onChange(callback) { return store.subscribe(handle, callback); }, - validate() { - return validateCurrentDocument(); - }, - onValidate(callback) { - let active = true; - - async function validateAndNotify(): Promise { - const result = await validateCurrentDocument(); - if (!active) { - return; - } - callback(result); - } - - const unsubscribe = store.subscribe(handle, () => { - void validateAndNotify(); - }); - void validateAndNotify(); - - return () => { - active = false; - unsubscribe(); - }; - }, + validate: validator.validate, + onValidate: validator.onValidate, + createValidationView: validator.createValidationView, }; } diff --git a/packages/documents/src/model/validation.ts b/packages/documents/src/model/validation.ts index ceedd1ae0d..c0a6538986 100644 --- a/packages/documents/src/model/validation.ts +++ b/packages/documents/src/model/validation.ts @@ -1,7 +1,14 @@ import type { FormalCell, ModelDocument } from "catcolab-document-methods"; import type { ModelJudgment, Notebook } from "catcolab-document-types"; import type { DblModel, DblTheory, InvalidDblModel, ModelPresentation } from "catlog-wasm"; +import { createReactiveView, type DocumentStore } from "../document-store"; import type { Issue, Result } from "../result"; +import type { Shape } from "../shape"; +import { + elaboratedModelFromPresentation, + type ElaboratedModel, + type ValidationView, +} from "./elaborated-model"; function formalCellForGenerator( notebook: Notebook, @@ -88,14 +95,24 @@ function invalidModelIssue(notebook: Notebook, error: InvalidDblM } } -/** Elaborate and validate a model document against its core theory. +/** The outcome of elaborating and then validating a model document. -On success, returns the presentation of the validated model. */ +Elaboration and validation are separate steps: even an invalid model has a +presentation, so `presentation` is absent only when elaboration itself fails. */ +export interface ModelValidation { + /** Presentation of the elaborated model; absent if elaboration failed. */ + readonly presentation?: ModelPresentation; + /** Validation issues; empty when the document is valid. */ + readonly issues: ReadonlyArray; +} + +/** Elaborate and validate a model document against its core theory. Total: +elaboration and validation failures are values. */ export async function validateModelDocument( document: Readonly, theory: DblTheory, refId: string, -): Promise> { +): Promise { const { DblModelMap, elaborateModel } = await import("catlog-wasm"); const instantiatedModels = new DblModelMap(); let model: DblModel; @@ -103,25 +120,166 @@ export async function validateModelDocument( model = elaborateModel(document.notebook, instantiatedModels, theory, refId); } catch (error) { return { - tag: "Err", - content: [{ message: `Failed to elaborate model: ${String(error)}` }], + issues: [{ message: `Failed to elaborate model: ${String(error)}` }], }; } finally { instantiatedModels.free(); } try { + const presentation = model.presentation(); const validation = model.validate(); - if (validation.tag === "Err") { + const issues = + validation.tag === "Err" + ? validation.content.map((error) => invalidModelIssue(document.notebook, error)) + : []; + return { presentation, issues }; + } finally { + model.free(); + } +} + +/** The validation surface of a notebook: one-shot validation, validation +callbacks, and live validation views. */ +export interface NotebookValidator { + validate(): Promise>>; + onValidate(callback: (result: Result>) => void): () => void; + createValidationView(): ValidationView; +} + +/** The state shared by all validation consumers of a notebook. */ +type ValidationState = ModelValidation; + +/** Create the validation machinery for a notebook over a document store. + +All consumers share one code path and one source of truth: the document is +revalidated at most once per change and the store is only subscribed while at +least one listener is active. */ +export function createNotebookValidator( + shape: S, + store: DocumentStore, + handle: Handle, +): NotebookValidator { + let coreTheory: Promise | undefined; + + /** Elaborate and validate the current document. */ + async function elaborateAndValidate(): Promise { + if (!shape.getCoreTheory) { + let shapeName = "unnamed"; + if (shape.theory) { + shapeName = shape.theory; + } return { - tag: "Err", - content: validation.content.map((error) => - invalidModelIssue(document.notebook, error), - ), + issues: [{ message: `Shape \`${shapeName}\` has no core theory` }], + }; + } + try { + if (!coreTheory) { + coreTheory = shape.getCoreTheory(); + } + const theory = await coreTheory; + const document = store.copyValue( + handle, + store.getDocumentView(handle), + ) as ModelDocument; + return await validateModelDocument(document, theory, store.getDocumentRef(handle).id); + } catch (error) { + return { + issues: [{ message: `Failed to load core theory: ${String(error)}` }], }; } - return { tag: "Ok", content: model.presentation() }; - } finally { - model.free(); } + + /** Convert a validation state into the `Result` exposed by `validate` and + `onValidate`. Ok only when elaboration succeeded and there are no issues. */ + function resultFromValidation({ + presentation, + issues, + }: ModelValidation): Result> { + if (issues.length > 0 || presentation === undefined) { + return { tag: "Err", content: issues }; + } + return { + tag: "Ok", + content: elaboratedModelFromPresentation(shape, () => presentation), + }; + } + + let latestValidationState: ValidationState = { + issues: [{ message: "The notebook has not been validated yet." }], + }; + const validationStateListeners = new Set<(state: ValidationState) => void>(); + let unsubscribeValidationSource: (() => void) | undefined; + let revalidationCounter = 0; + + function publishValidationState(state: ValidationState): void { + latestValidationState = state; + for (const listener of validationStateListeners) { + listener(state); + } + } + + /** Revalidate the document and publish the outcome to listeners. When + revalidations overlap, only the latest-started one publishes, so listeners + never observe stale state; every caller still receives its own outcome. */ + async function revalidate(): Promise { + const ticket = ++revalidationCounter; + const state = await elaborateAndValidate(); + if (ticket === revalidationCounter) { + publishValidationState(state); + } + return state; + } + + /** Subscribe to validation state, revalidating on every document change. + The listener receives an initial publish once revalidation completes. */ + function subscribeToValidationState(listener: (state: ValidationState) => void): () => void { + validationStateListeners.add(listener); + if (unsubscribeValidationSource === undefined) { + unsubscribeValidationSource = store.subscribe(handle, () => { + void revalidate(); + }); + } + void revalidate(); + + return () => { + if (!validationStateListeners.delete(listener)) { + return; + } + if (validationStateListeners.size === 0) { + unsubscribeValidationSource?.(); + unsubscribeValidationSource = undefined; + } + }; + } + + return { + async validate() { + return resultFromValidation(await revalidate()); + }, + onValidate(callback) { + return subscribeToValidationState((state) => { + callback(resultFromValidation(state)); + }); + }, + createValidationView(): ValidationView { + const reactiveView = createReactiveView(store, latestValidationState); + const dispose = subscribeToValidationState((state) => { + reactiveView.replace(state); + }); + + const model = elaboratedModelFromPresentation( + shape, + () => reactiveView.current.presentation, + ); + + return { + model, + get issues(): ReadonlyArray { + return reactiveView.current.issues; + }, + dispose, + }; + }, + }; } diff --git a/packages/documents/test/stores/solid-store-fixture.ts b/packages/documents/test/stores/solid-store-fixture.ts new file mode 100644 index 0000000000..2550163a4f --- /dev/null +++ b/packages/documents/test/stores/solid-store-fixture.ts @@ -0,0 +1,63 @@ +import { createStore, reconcile, type SetStoreFunction, unwrap } from "solid-js/store"; + +import type { Document } from "catcolab-document-types"; +// A SolidJS document store: keeps a draft document plus a Solid store view +// reconciled on every change. +import type { DocumentStore } from "catcolab-documents"; + +export type SolidStoreHandle = { + draftDoc: Document; + docView: Document; + setDocView: SetStoreFunction; + listeners: Set<() => void>; +}; + +// Every store mints a stable reference for its handles; this one assigns an id +// on demand and keeps it in a WeakMap. +const solidStoreIds = new WeakMap(); +const solidStoreIdFor = (handle: SolidStoreHandle): string => { + let id = solidStoreIds.get(handle); + if (!id) { + id = crypto.randomUUID(); + solidStoreIds.set(handle, id); + } + return id; +}; + +export const solidStore: DocumentStore = { + async createHandle(initialDoc) { + const draftDoc = structuredClone(initialDoc as Document); + const [docView, setDocView] = createStore(initialDoc as Document); + return { draftDoc, docView, setDocView, listeners: new Set() }; + }, + changeDocument: (handle, fn) => { + fn(handle.draftDoc); + handle.setDocView(reconcile(structuredClone(handle.draftDoc), { key: "id" })); + for (const listener of Array.from(handle.listeners)) { + listener(); + } + }, + subscribe: (handle, callback) => { + handle.listeners.add(callback); + return () => { + handle.listeners.delete(callback); + }; + }, + copyValue: (_handle, value) => structuredClone(unwrap(value)), + createReactiveView(initial) { + const [current, setCurrent] = createStore(initial); + return { + current, + replace(next) { + setCurrent(reconcile(next)); + }, + }; + }, + getDocumentView: (handle) => handle.docView, + getDocumentRef: (handle) => ({ id: solidStoreIdFor(handle), version: null, server: "" }), + // Link resolution omitted for brevity. + getHandle: async () => ({ + tag: "Err", + content: [{ message: "This store cannot resolve references." }], + }), +}; diff --git a/packages/documents/test/stores/solid-store.test.ts b/packages/documents/test/stores/solid-store.test.ts index aeca5de15e..f713b3f13b 100644 --- a/packages/documents/test/stores/solid-store.test.ts +++ b/packages/documents/test/stores/solid-store.test.ts @@ -1,63 +1,9 @@ import { SimpleOlog, Type } from "catcolab-logics/simple-olog"; -import { createStore, reconcile, type SetStoreFunction, unwrap } from "solid-js/store"; import { describe, expect, test } from "vitest"; -import type { Document } from "catcolab-document-types"; // RFC-0006 "Use with SolidJS and Automerge" — the SolidJS binder. -// -// The `Binder` abstraction keeps the API consistent while plugging in custom -// backends through `createBinder` and the `DocumentStore` type. A simple -// SolidJS store keeps a draft document plus a Solid store view reconciled on -// every change. -import { createBinder, type DocumentStore } from "catcolab-documents"; - -type SolidStoreHandle = { - draftDoc: Document; - docView: Document; - setDocView: SetStoreFunction; - listeners: Set<() => void>; -}; - -// Every store mints a stable reference for its handles; this one assigns an id -// on demand and keeps it in a WeakMap. -const solidStoreIds = new WeakMap(); -const solidStoreIdFor = (handle: SolidStoreHandle): string => { - let id = solidStoreIds.get(handle); - if (!id) { - id = crypto.randomUUID(); - solidStoreIds.set(handle, id); - } - return id; -}; - -const solidStore: DocumentStore = { - async createHandle(initialDoc) { - const draftDoc = structuredClone(initialDoc as Document); - const [docView, setDocView] = createStore(initialDoc as Document); - return { draftDoc, docView, setDocView, listeners: new Set() }; - }, - changeDocument: (handle, fn) => { - fn(handle.draftDoc); - handle.setDocView(reconcile(structuredClone(handle.draftDoc), { key: "id" })); - for (const listener of Array.from(handle.listeners)) { - listener(); - } - }, - subscribe: (handle, callback) => { - handle.listeners.add(callback); - return () => { - handle.listeners.delete(callback); - }; - }, - copyValue: (_handle, value) => structuredClone(unwrap(value)), - getDocumentView: (handle) => handle.docView, - getDocumentRef: (handle) => ({ id: solidStoreIdFor(handle), version: null, server: "" }), - // Link resolution omitted for brevity. - getHandle: async () => ({ - tag: "Err", - content: [{ message: "This store cannot resolve references." }], - }), -}; +import { createBinder } from "catcolab-documents"; +import { solidStore } from "./solid-store-fixture"; describe.skip("SolidJS binder", () => { test("a binder over a Solid store is used just as the default binder", async () => { diff --git a/packages/documents/test/stores/solid-validation-view.test.ts b/packages/documents/test/stores/solid-validation-view.test.ts new file mode 100644 index 0000000000..d0bea0a4c8 --- /dev/null +++ b/packages/documents/test/stores/solid-validation-view.test.ts @@ -0,0 +1,49 @@ +import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog"; +import { createEffect, createRoot } from "solid-js"; +import { describe, expect, test } from "vitest"; + +// RFC-0006 "Use with SolidJS and Automerge" — reactive validation views over a +// SolidJS document store. +import { createBinder } from "catcolab-documents"; +import { solidStore } from "./solid-store-fixture"; + +describe("reactive validation view", { timeout: 20000 }, () => { + test("tracks validation state and judgments in a Solid effect", async () => { + const solidBinder = createBinder(solidStore); + const notebook = await solidBinder.createNotebook(SimpleOlog, { title: "An Olog" }); + const source = notebook.add(Type, { label: "A" }); + const target = notebook.add(Type, { label: "B" }); + notebook.add(Aspect, { label: "has", from: source, to: target }); + + const view = notebook.createValidationView(); + + let issueCount = -1; + let labels: string[] = []; + const dispose = createRoot((dispose) => { + createEffect(() => { + issueCount = view.issues.length; + labels = view.model.judgments().map((judgment) => judgment.label.join(".")); + }); + return dispose; + }); + + await expect.poll(() => issueCount, { timeout: 10000 }).toBe(0); + expect(labels).toEqual(["A", "B", "has"]); + + // Adding an object updates the judgments through the reactive view. + notebook.add(Type, { label: "C" }); + await expect.poll(() => labels).toEqual(["A", "B", "C", "has"]); + + // An ill-formed change (an aspect without endpoints) surfaces issues, + // while the elaborated model stays available. + notebook.add(Aspect, { label: "dangling", from: null, to: null }); + await expect.poll(() => issueCount).toBeGreaterThan(0); + expect(labels).toEqual(["A", "B", "C", "has"]); + + // Disposing the last view releases the store subscription. + expect(notebook.handle.listeners.size).toBeGreaterThan(0); + view.dispose(); + expect(notebook.handle.listeners.size).toBe(0); + dispose(); + }); +}); diff --git a/packages/documents/test/validation-view.test.ts b/packages/documents/test/validation-view.test.ts new file mode 100644 index 0000000000..4bcd52adf1 --- /dev/null +++ b/packages/documents/test/validation-view.test.ts @@ -0,0 +1,75 @@ +import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog"; +import { describe, expect, test } from "vitest"; + +// RFC-0006 "Model validation": live validation views created with +// `createValidationView`. +import { createBinder } from "catcolab-documents"; + +async function wellFormedOlog() { + const binder = createBinder(); + const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" }); + + const source = notebook.add(Type, { label: "A" }); + const target = notebook.add(Type, { label: "B" }); + notebook.add(Aspect, { label: "has", from: source, to: target }); + + return notebook; +} + +// the first time tests run we incur the cost of loading the catlog-wasm bundle, +// so these tests have longer timeouts +describe("createValidationView", { timeout: 20000 }, () => { + test("exposes validated judgments that mirror the notebook cells", async () => { + const notebook = await wellFormedOlog(); + const view = notebook.createValidationView(); + + // Nothing has been validated yet when the view is created, but the + // model is still available (and empty). + expect(view.issues.length).toBeGreaterThan(0); + expect(view.model.judgments()).toHaveLength(0); + + await expect.poll(() => view.issues, { timeout: 10000 }).toEqual([]); + const model = view.model; + + expect(model.judgments().map((judgment) => [judgment.kind, judgment.label])).toEqual([ + ["object", ["A"]], + ["object", ["B"]], + ["morphism", ["has"]], + ]); + expect(model.judgmentsOf(Type).map((judgment) => judgment.label)).toEqual([["A"], ["B"]]); + + const [has] = model.judgmentsOf(Aspect); + if (has?.kind !== "morphism") { + throw new Error("Expected a morphism judgment"); + } + expect(has.from?.label).toEqual(["A"]); + expect(has.to?.label).toEqual(["B"]); + + view.dispose(); + }); + + test("revalidates when the notebook changes and reports failures", async () => { + const notebook = await wellFormedOlog(); + const view = notebook.createValidationView(); + + await expect.poll(() => view.issues, { timeout: 10000 }).toEqual([]); + const model = view.model; + + notebook.add(Type, { label: "C" }); + await expect.poll(() => model.judgmentsOf(Type).length).toBe(3); + + // An aspect without endpoints makes the notebook ill-formed. + notebook.add(Aspect, { label: "dangling", from: null, to: null }); + await expect.poll(() => view.issues.length).toBeGreaterThan(0); + expect(view.issues.map((issue) => issue.message)).toEqual([ + "Morphism `dangling` has a mistyped domain", + ]); + + // The elaborated model remains available while the notebook is + // invalid; the dangling morphism is simply not part of it. + expect(model.judgmentsOf(Type).length).toBe(3); + expect(model.judgmentsOf(Aspect).map((judgment) => judgment.label)).toEqual([["has"]]); + + view.dispose(); + }); +}); diff --git a/packages/documents/tsconfig.test.json b/packages/documents/tsconfig.test.json index 7ff992b6ef..73a29ed5e1 100644 --- a/packages/documents/tsconfig.test.json +++ b/packages/documents/tsconfig.test.json @@ -15,6 +15,8 @@ "test/definitions.test.ts", "test/instances.test.ts", "test/llm-conversation.test.ts", - "test/binder.test-d.ts" + "test/binder.test-d.ts", + "test/validation-view.test.ts", + "test/stores/solid-validation-view.test.ts" ] } diff --git a/packages/frontend/src/api/document_store.ts b/packages/frontend/src/api/document_store.ts index 07069815a2..3d706e9d5b 100644 --- a/packages/frontend/src/api/document_store.ts +++ b/packages/frontend/src/api/document_store.ts @@ -1,6 +1,6 @@ import type { DocHandle } from "@automerge/automerge-repo"; import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives"; -import { unwrap } from "solid-js/store"; +import { createStore, reconcile, unwrap } from "solid-js/store"; import type { Document } from "catcolab-document-types"; import { @@ -67,6 +67,15 @@ export function createApiDocumentStore(api: Api): ApiDocumentStore { return () => handle.automergeHandle.off("change", callback); }, copyValue: (_handle, value) => structuredClone(unwrap(value)), + createReactiveView(initial) { + const [current, setCurrent] = createStore(initial); + return { + current, + replace(next) { + setCurrent(reconcile(next)); + }, + }; + }, getDocumentRef: (handle) => handle.ref, async getHandle(ref: DocumentRef): Promise> { if (ref.version !== null) { From 8c09878133edb7bf272b366fbf533bf51c9f85a9 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Thu, 27 Aug 2026 18:10:55 +0100 Subject: [PATCH 21/83] TEST: Add solid-completions-view test --- .../stores/solid-completions-view.test.tsx | 98 +++++++++++++++++++ packages/documents/tsconfig.test.json | 3 +- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 packages/documents/test/stores/solid-completions-view.test.tsx diff --git a/packages/documents/test/stores/solid-completions-view.test.tsx b/packages/documents/test/stores/solid-completions-view.test.tsx new file mode 100644 index 0000000000..5bf94acbcb --- /dev/null +++ b/packages/documents/test/stores/solid-completions-view.test.tsx @@ -0,0 +1,98 @@ +import { Attr, AttrType, Entity, SimpleSchema } from "catcolab-logics/simple-schema"; +import { type Accessor, createSignal, For, onCleanup } from "solid-js"; +import { render } from "solid-js/web"; +import { describe, expect, test } from "vitest"; + +// An extension to RFC-0006 "SolidJS example with validation & completions". +// This functionality was not described in the RFC. +// view (`createValidationView`) feeds completions to a Solid component. The +// view's elaborated model is fine-grained reactive through the Solid store's +// `createReactiveView`, so no signals need to be wired by hand. +import { createBinder, type MorphismCell, type Notebook } from "catcolab-documents"; +import { solidStore } from "./solid-store-fixture"; + +/** Shows an attribute's codomain and offers completions for replacing it, +drawn from the validated model's attribute types. */ +function CodomainPicker(props: { + attrCell: MorphismCell; + notebook: Notebook; + text: Accessor; + onSelect: (label: string) => void; +}) { + const view = props.notebook.createValidationView(); + onCleanup(() => view.dispose()); + + const completions = () => + view.model + .judgmentsOf(AttrType) + .map((judgment) => judgment.label.join(".")) + .filter((label) => label.toLowerCase().includes(props.text().toLowerCase())); + + return ( + + {props.attrCell.to?.label ?? "?"} +
    + + {(label) =>
  • props.onSelect(label)}>{label}
  • } +
    +
+
+ ); +} + +describe("SolidJS completions from a validation view", { timeout: 20000 }, () => { + test("the validated model feeds completions and codomain selection", async () => { + const binder = createBinder(solidStore); + const notebook = await binder.createNotebook(SimpleSchema, { title: "Company schema" }); + + const person = notebook.add(Entity, { label: "Person" }); + const string = notebook.add(AttrType, { label: "String" }); + const integer = notebook.add(AttrType, { label: "Integer" }); + const boolean = notebook.add(AttrType, { label: "Boolean" }); + const name = notebook.add(Attr, { label: "name", from: person, to: string }); + const attrTypes = [string, integer, boolean]; + + const [text, setText] = createSignal(""); + const container = document.createElement("div"); + document.body.appendChild(container); + const dispose = render( + () => ( + { + const cell = attrTypes.find((attrType) => attrType.label === label); + if (cell) { + name.update({ to: cell }); + } + }} + /> + ), + container, + ); + + const completionLabels = () => + [...container.querySelectorAll(".completion-list li")].map((li) => li.textContent); + const selectedLabel = () => container.querySelector(".selected")?.textContent; + + // Completions appear once the view's first validation completes. + await expect + .poll(completionLabels, { timeout: 10000 }) + .toEqual(["String", "Integer", "Boolean"]); + expect(selectedLabel()).toBe("String"); + + // Filtering is synchronous: it only reads the already-validated model. + setText("in"); + expect(completionLabels()).toEqual(["String", "Integer"]); + + // Selecting a completion updates the document; the codomain re-renders + // and the view revalidates without issues. + const items = container.querySelectorAll(".completion-list li"); + items[1]?.click(); + await expect.poll(selectedLabel).toBe("Integer"); + + dispose(); + container.remove(); + }); +}); diff --git a/packages/documents/tsconfig.test.json b/packages/documents/tsconfig.test.json index 73a29ed5e1..bc60a21ab1 100644 --- a/packages/documents/tsconfig.test.json +++ b/packages/documents/tsconfig.test.json @@ -17,6 +17,7 @@ "test/llm-conversation.test.ts", "test/binder.test-d.ts", "test/validation-view.test.ts", - "test/stores/solid-validation-view.test.ts" + "test/stores/solid-validation-view.test.ts", + "test/stores/solid-completions-view.test.tsx" ] } From c8fee79cdfdf041ee00e03a0cfb253d5de5c1553 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Fri, 28 Aug 2026 17:53:24 +0100 Subject: [PATCH 22/83] ENH: Make elaborated model always available from validation --- packages/documents/src/binder.ts | 6 +-- packages/documents/src/index.ts | 3 +- .../src/instance/instance-runtime.ts | 22 +++++---- .../documents/src/model/elaborated-model.ts | 27 ++++++----- packages/documents/src/model/notebook.ts | 9 ++-- packages/documents/src/model/validation.ts | 42 ++++++++--------- .../test/corpus/model-editing.db-dump-test.ts | 10 ++--- packages/documents/test/instantiation.test.ts | 2 +- packages/documents/test/migration.test.ts | 2 +- .../documents/test/path-equations.test.ts | 2 +- .../documents/test/solid-completions.test.tsx | 13 ++---- .../test/stores/backend-store.test.ts | 2 +- packages/documents/test/validation.test.ts | 45 +++++++++++-------- .../src/llm_conversation/scoped_document.ts | 7 +-- packages/logics/test/petri-net.test.ts | 9 ++-- .../logics/test/simple-olog-advanced.test.ts | 11 ++--- packages/logics/test/simple-schema.test.ts | 13 +++--- 17 files changed, 110 insertions(+), 115 deletions(-) diff --git a/packages/documents/src/binder.ts b/packages/documents/src/binder.ts index 93371aa9a1..470719ee4e 100644 --- a/packages/documents/src/binder.ts +++ b/packages/documents/src/binder.ts @@ -136,9 +136,9 @@ function binderFromStore(store: DocumentStore): Binder { }; } - const schemaResult = await schema.validate(); - if (schemaResult.tag === "Err") { - return schemaResult; + const schemaValidation = await schema.validate(); + if (schemaValidation.issues.length > 0) { + return { tag: "Err", content: schemaValidation.issues }; } const schemaRef = store.getDocumentRef(schema.handle); diff --git a/packages/documents/src/index.ts b/packages/documents/src/index.ts index 27fa6e967b..c20fea8bda 100644 --- a/packages/documents/src/index.ts +++ b/packages/documents/src/index.ts @@ -32,7 +32,8 @@ export type { MorphismJudgment, ObjectJudgment, ElaboratedModel, - ValidationView, + ModelValidation, + ModelValidationView, } from "./model/elaborated-model"; export type { NotebookDocument } from "./notebook-document"; export type { RichTextCell } from "./rich-text"; diff --git a/packages/documents/src/instance/instance-runtime.ts b/packages/documents/src/instance/instance-runtime.ts index bd5f18bde5..b9432e36f1 100644 --- a/packages/documents/src/instance/instance-runtime.ts +++ b/packages/documents/src/instance/instance-runtime.ts @@ -1,7 +1,7 @@ import type { InstanceDocument } from "catcolab-document-methods"; import type { DocumentStore } from "../document-store"; import type { ModelDocument } from "../model/document"; -import type { ElaboratedModel } from "../model/elaborated-model"; +import type { ElaboratedModel, ModelValidation } from "../model/elaborated-model"; import type { Notebook } from "../model/notebook"; import type { Issue, Result } from "../result"; import type { InstanceCapableShape, Shape } from "../shape"; @@ -39,12 +39,12 @@ async function withValidatedSchema< schema: Notebook, operation: (schemaModel: ElaboratedModel) => Result, ): Promise> { - const schemaResult = await schema.validate(); - if (schemaResult.tag === "Err") { - return schemaResult as Result; + const schemaValidation = await schema.validate(); + if (schemaValidation.issues.length > 0) { + return { tag: "Err", content: schemaValidation.issues as E }; } - return operation(schemaResult.content); + return operation(schemaValidation.model); } export function createTablesMethod( @@ -184,19 +184,17 @@ export function createSchemaResultValidator( schema: Notebook, store: DocumentStore, handle: Handle, -): ( - schemaResult: Result>, -) => Result> { - return (schemaResult) => { - if (schemaResult.tag === "Err") { - return schemaResult; +): (schemaValidation: ModelValidation) => Result> { + return (schemaValidation) => { + if (schemaValidation.issues.length > 0) { + return { tag: "Err", content: schemaValidation.issues }; } const tables = instanceTablesFromModel( instanceCapableShape(schema), store, handle, - schemaResult.content, + schemaValidation.model, ); const issues: TableFieldIssue[] = validateTableFields( store.getDocumentView(handle) as Readonly, diff --git a/packages/documents/src/model/elaborated-model.ts b/packages/documents/src/model/elaborated-model.ts index a478c96590..dffd76f16f 100644 --- a/packages/documents/src/model/elaborated-model.ts +++ b/packages/documents/src/model/elaborated-model.ts @@ -49,22 +49,27 @@ export interface ElaboratedModel { judgmentsOf(typeOrShape: AnyCellType | Shape): ReadonlyArray>; } -/** A live view of a notebook's validation state. - -While the view is active, the notebook revalidates whenever its document -changes; `model` and `issues` reflect the latest outcome. The caller must -dispose the view when it is no longer needed. +/** The result of elaborating and validating a notebook. Elaboration and validation are separate steps, so `model` is always available: -it reflects the latest elaboration even when validation fails. It is empty -only before the first elaboration completes or when elaboration itself fails. - -In the case of an empty model the `model.judgments` and `model.judgementsOf` methods -return empty arrays. */ -export interface ValidationView { +it contains the elaborated judgments even when validation fails. The model is +empty only when elaboration itself fails. */ +export interface ModelValidation { readonly model: ElaboratedModel; /** Validation issues; empty when the notebook is valid. */ readonly issues: ReadonlyArray; +} + +/** A live view of a notebook's validation state. + +While the view is active, the notebook revalidates whenever its document +changes; `model` and `issues` reflect the latest outcome. Before the first +elaboration completes, `model` is empty and `issues` reports that validation is +pending. The caller must dispose the view when it is no longer needed. + +In the case of an empty model the `model.judgments` and `model.judgmentsOf` +methods return empty arrays. */ +export interface ModelValidationView extends ModelValidation { dispose(): void; } diff --git a/packages/documents/src/model/notebook.ts b/packages/documents/src/model/notebook.ts index 20124ede40..ef2fb79d0e 100644 --- a/packages/documents/src/model/notebook.ts +++ b/packages/documents/src/model/notebook.ts @@ -2,7 +2,6 @@ import { Model, Nb } from "catcolab-document-methods"; import type { ModelJudgment } from "catcolab-document-types"; import type { DocumentStore } from "../document-store"; import type { NotebookDocument } from "../notebook-document"; -import type { Result } from "../result"; import { getRichTextCell, type RichTextCell } from "../rich-text"; import type { AnyCellType, @@ -26,7 +25,7 @@ import { type ObjectCell, } from "./cell"; import type { ModelDocument } from "./document"; -import type { ElaboratedModel, ValidationView } from "./elaborated-model"; +import type { ModelValidation, ModelValidationView } from "./elaborated-model"; import { morphismTypesEqual, objectTypesEqual } from "./equality"; import { createNotebookValidator } from "./validation"; @@ -179,11 +178,11 @@ export interface Notebook< update(patch: Partial<{ title: string }>): void; dump(): D; onChange(callback: () => void): () => void; - validate(): Promise>>; - onValidate(callback: (result: Result>) => void): () => void; + validate(): Promise>; + onValidate(callback: (result: ModelValidation) => void): () => void; /** Create a live, reactive view of the notebook's validation state. The * caller must dispose the view when it is no longer needed. */ - createValidationView(): ValidationView; + createValidationView(): ModelValidationView; } export function modelNotebookFromStore( diff --git a/packages/documents/src/model/validation.ts b/packages/documents/src/model/validation.ts index c0a6538986..76e3d084bc 100644 --- a/packages/documents/src/model/validation.ts +++ b/packages/documents/src/model/validation.ts @@ -2,12 +2,12 @@ import type { FormalCell, ModelDocument } from "catcolab-document-methods"; import type { ModelJudgment, Notebook } from "catcolab-document-types"; import type { DblModel, DblTheory, InvalidDblModel, ModelPresentation } from "catlog-wasm"; import { createReactiveView, type DocumentStore } from "../document-store"; -import type { Issue, Result } from "../result"; +import type { Issue } from "../result"; import type { Shape } from "../shape"; import { elaboratedModelFromPresentation, - type ElaboratedModel, - type ValidationView, + type ModelValidation, + type ModelValidationView, } from "./elaborated-model"; function formalCellForGenerator( @@ -99,7 +99,7 @@ function invalidModelIssue(notebook: Notebook, error: InvalidDblM Elaboration and validation are separate steps: even an invalid model has a presentation, so `presentation` is absent only when elaboration itself fails. */ -export interface ModelValidation { +interface ModelValidationState { /** Presentation of the elaborated model; absent if elaboration failed. */ readonly presentation?: ModelPresentation; /** Validation issues; empty when the document is valid. */ @@ -112,7 +112,7 @@ export async function validateModelDocument( document: Readonly, theory: DblTheory, refId: string, -): Promise { +): Promise { const { DblModelMap, elaborateModel } = await import("catlog-wasm"); const instantiatedModels = new DblModelMap(); let model: DblModel; @@ -142,13 +142,13 @@ export async function validateModelDocument( /** The validation surface of a notebook: one-shot validation, validation callbacks, and live validation views. */ export interface NotebookValidator { - validate(): Promise>>; - onValidate(callback: (result: Result>) => void): () => void; - createValidationView(): ValidationView; + validate(): Promise>; + onValidate(callback: (result: ModelValidation) => void): () => void; + createValidationView(): ModelValidationView; } /** The state shared by all validation consumers of a notebook. */ -type ValidationState = ModelValidation; +type ValidationState = ModelValidationState; /** Create the validation machinery for a notebook over a document store. @@ -163,7 +163,7 @@ export function createNotebookValidator( let coreTheory: Promise | undefined; /** Elaborate and validate the current document. */ - async function elaborateAndValidate(): Promise { + async function elaborateAndValidate(): Promise { if (!shape.getCoreTheory) { let shapeName = "unnamed"; if (shape.theory) { @@ -190,18 +190,14 @@ export function createNotebookValidator( } } - /** Convert a validation state into the `Result` exposed by `validate` and - `onValidate`. Ok only when elaboration succeeded and there are no issues. */ - function resultFromValidation({ + /** Convert internal validation state into the public snapshot. */ + function modelValidationFromState({ presentation, issues, - }: ModelValidation): Result> { - if (issues.length > 0 || presentation === undefined) { - return { tag: "Err", content: issues }; - } + }: ModelValidationState): ModelValidation { return { - tag: "Ok", - content: elaboratedModelFromPresentation(shape, () => presentation), + model: elaboratedModelFromPresentation(shape, () => presentation), + issues, }; } @@ -222,7 +218,7 @@ export function createNotebookValidator( /** Revalidate the document and publish the outcome to listeners. When revalidations overlap, only the latest-started one publishes, so listeners never observe stale state; every caller still receives its own outcome. */ - async function revalidate(): Promise { + async function revalidate(): Promise { const ticket = ++revalidationCounter; const state = await elaborateAndValidate(); if (ticket === revalidationCounter) { @@ -255,14 +251,14 @@ export function createNotebookValidator( return { async validate() { - return resultFromValidation(await revalidate()); + return modelValidationFromState(await revalidate()); }, onValidate(callback) { return subscribeToValidationState((state) => { - callback(resultFromValidation(state)); + callback(modelValidationFromState(state)); }); }, - createValidationView(): ValidationView { + createValidationView(): ModelValidationView { const reactiveView = createReactiveView(store, latestValidationState); const dispose = subscribeToValidationState((state) => { reactiveView.replace(state); diff --git a/packages/documents/test/corpus/model-editing.db-dump-test.ts b/packages/documents/test/corpus/model-editing.db-dump-test.ts index b9bad19893..296ccbbc82 100644 --- a/packages/documents/test/corpus/model-editing.db-dump-test.ts +++ b/packages/documents/test/corpus/model-editing.db-dump-test.ts @@ -129,7 +129,7 @@ describe("editing valid models from a Next database dump", () => { continue; } - if ((await opened.notebook.validate()).tag !== "Ok") { + if ((await opened.notebook.validate()).issues.length > 0) { semanticallyInvalid += 1; continue; } @@ -174,7 +174,7 @@ async function checkFixture( for (const edit of edits) { applySemanticPreservingEdit(opened.notebook, edit); assertNotebookStructureIsConsistent(opened.notebook); - expect((await opened.notebook.validate()).tag).toBe("Ok"); + expect((await opened.notebook.validate()).issues).toEqual([]); } }), { numRuns, seed: seedFromRef(fixture.refId) }, @@ -189,7 +189,7 @@ async function checkFixture( const { notebook, storeHarness } = opened; assertNotebookStructureIsConsistent(notebook); - expect((await notebook.validate()).tag).toBe("Ok"); + expect((await notebook.validate()).issues).toEqual([]); const baseline = structuredClone(notebook.dump()); const dependencies = storeHarness.snapshotLoadedExcept(fixture.refId); @@ -202,12 +202,12 @@ async function checkFixture( const commit = transaction.commit(); assertNotebookStructureIsConsistent(notebook); - expect(["Err", "Ok"]).toContain((await notebook.validate()).tag); + expect((await notebook.validate()).model).toBeDefined(); notebook.revertCommit(commit); assertNotebookStructureIsConsistent(notebook); expect(notebook.dump()).toEqual(baseline); - expect((await notebook.validate()).tag).toBe("Ok"); + expect((await notebook.validate()).issues).toEqual([]); expect(storeHarness.snapshotLoadedExcept(fixture.refId)).toEqual(dependencies); }), { numRuns, seed: seedFromRef(fixture.refId) }, diff --git a/packages/documents/test/instantiation.test.ts b/packages/documents/test/instantiation.test.ts index fc1c67669d..e9ce75d581 100644 --- a/packages/documents/test/instantiation.test.ts +++ b/packages/documents/test/instantiation.test.ts @@ -30,6 +30,6 @@ describe.skip("instantiation", () => { // Instantiating a valid notebook keeps this notebook valid. const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); + expect(result.issues).toEqual([]); }); }); diff --git a/packages/documents/test/migration.test.ts b/packages/documents/test/migration.test.ts index 2ae41324e6..6ae62bdcdc 100644 --- a/packages/documents/test/migration.test.ts +++ b/packages/documents/test/migration.test.ts @@ -37,6 +37,6 @@ describe.skip("migrating between logics", () => { .map((cell) => cell.label) .join(", "), ).toBe("has"); - expect((await schema.validate()).tag).toBe("Ok"); + expect((await schema.validate()).issues).toEqual([]); }); }); diff --git a/packages/documents/test/path-equations.test.ts b/packages/documents/test/path-equations.test.ts index c8a0a995d6..ff75056d9b 100644 --- a/packages/documents/test/path-equations.test.ts +++ b/packages/documents/test/path-equations.test.ts @@ -32,6 +32,6 @@ describe.skip("path equations", () => { expect(notebook.cellsOf(PathEquation).length).toBe(1); const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); + expect(result.issues).toEqual([]); }); }); diff --git a/packages/documents/test/solid-completions.test.tsx b/packages/documents/test/solid-completions.test.tsx index 378b753464..8c9bfa279e 100644 --- a/packages/documents/test/solid-completions.test.tsx +++ b/packages/documents/test/solid-completions.test.tsx @@ -24,11 +24,10 @@ import { defineObject, defineShape, type DocumentStore, - type ModelValidationResult, + type ModelValidation, type Notebook, type NotebookCell, RichText, - type ValidatableNotebook, } from "catcolab-documents"; import type { DblModel, ObType, QualifiedName } from "catlog-wasm"; import { selfResolving } from "./helpers/self_resolving"; @@ -186,20 +185,16 @@ function MyCellEditor(props: { cell: NotebookCell; text: Accesso // Globals for testing let testCurrentModel!: () => DblModel | undefined; -function MyNotebookEditor(props: { - notebook: MyNotebook & ValidatableNotebook; - text: Accessor; -}) { +function MyNotebookEditor(props: { notebook: MyNotebook; text: Accessor }) { // `onValidate` delivers an initial result and then re-validates whenever // anything the validation depends on changes, notifying only when the // result actually changed. - const [validation, setValidation] = createSignal(); + const [validation, setValidation] = createSignal>(); const unsubscribe = props.notebook.onValidate(setValidation); onCleanup(unsubscribe); const model = () => { - const result = validation(); - return result?.tag === "Ok" ? result.content : undefined; + return validation()?.model; }; testCurrentModel = model; diff --git a/packages/documents/test/stores/backend-store.test.ts b/packages/documents/test/stores/backend-store.test.ts index 55a7014df7..cf2ba90041 100644 --- a/packages/documents/test/stores/backend-store.test.ts +++ b/packages/documents/test/stores/backend-store.test.ts @@ -106,6 +106,6 @@ describe.skip("backend binder", () => { notebook.add(Instantiation, { label: "ImportedOlog", model: imported }); const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); + expect(result.issues).toEqual([]); }); }); diff --git a/packages/documents/test/validation.test.ts b/packages/documents/test/validation.test.ts index 6473c7bf78..f487171698 100644 --- a/packages/documents/test/validation.test.ts +++ b/packages/documents/test/validation.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; // RFC-0006 "Model validation": the asynchronous `validate` method, subscribing // to validation changes with `onValidate`, and validation failures. -import { createBinder, type ElaboratedModel, Instantiation, type Result } from "catcolab-documents"; +import { createBinder, Instantiation, type ModelValidation } from "catcolab-documents"; async function wellFormedOlog() { const binder = createBinder(); @@ -19,23 +19,32 @@ async function wellFormedOlog() { // the first time tests run we incur the cost of loading the catlog-wasm bundle, // so these tests have longer timeouts describe("validate", { timeout: 10000 }, () => { - test("a well-formed notebook validates to an Ok carrying the elaborated model", async () => { + test("a well-formed notebook validates without issues", async () => { const notebook = await wellFormedOlog(); - const result: Result> = await notebook.validate(); - expect(result.tag).toBe("Ok"); + const result: ModelValidation = await notebook.validate(); + expect(result.issues).toEqual([]); }); test("the elaborated model is available on the result and can be queried", async () => { const notebook = await wellFormedOlog(); const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); - if (result.tag !== "Ok") { - return; - } - expect(result.content.judgmentsOf(Type).length).toBe(2); - expect(result.content.judgmentsOf(Aspect).length).toBe(1); + expect(result.model.judgmentsOf(Type).length).toBe(2); + expect(result.model.judgmentsOf(Aspect).length).toBe(1); + }); + + test("an invalid notebook still returns its elaborated model", async () => { + const notebook = await wellFormedOlog(); + notebook.add(Aspect, { label: "dangling", from: null, to: null }); + + const result = await notebook.validate(); + + expect(result.issues.length).toBeGreaterThan(0); + expect(result.model.judgmentsOf(Type).length).toBe(2); + expect(result.model.judgmentsOf(Aspect).map((judgment) => judgment.label)).toEqual([ + ["has"], + ]); }); }); @@ -43,20 +52,20 @@ describe("onValidate", { timeout: 10000 }, () => { test("always calls the callback at least once with the current validation", async () => { const notebook = await wellFormedOlog(); - const first = await new Promise>>((resolve) => { + const first = await new Promise>((resolve) => { const unsubscribe = notebook.onValidate((result) => { resolve(result); unsubscribe(); }); }); - expect(first.tag).toBe("Ok"); + expect(first.issues).toEqual([]); }); test("notifies when changes to the formal content affect the validation result", async () => { const notebook = await wellFormedOlog(); - const results: Result>[] = []; + const results: ModelValidation[] = []; const unsubscribe = notebook.onValidate((result) => { results.push(result); }); @@ -78,7 +87,9 @@ describe("onValidate", { timeout: 10000 }, () => { } unsubscribe(); - expect(results[results.length - 1]?.tag).toBe("Err"); + const latest = results[results.length - 1]; + expect(latest?.issues.length).toBeGreaterThan(0); + expect(latest?.model.judgmentsOf(Type).map((judgment) => judgment.label)).toEqual([["B"]]); }); }); @@ -97,11 +108,7 @@ describe.skip("validation result", () => { second.add(Instantiation, { label: "ImportedFirst", model: first }); const result = await first.validate(); - expect(result.tag).toBe("Err"); - if (result.tag !== "Err") { - return; - } - expect(result.content.map((issue) => issue.message).join("; ")).toBe( + expect(result.issues.map((issue) => issue.message).join("; ")).toBe( 'Instantiation cycle detected: "First" → "Second" → "First". ' + "A notebook cannot instantiate itself, directly or indirectly. " + "To fix, remove one of the instantiations in this chain.", diff --git a/packages/frontend/src/llm_conversation/scoped_document.ts b/packages/frontend/src/llm_conversation/scoped_document.ts index b1e0d3fe59..db0f108435 100644 --- a/packages/frontend/src/llm_conversation/scoped_document.ts +++ b/packages/frontend/src/llm_conversation/scoped_document.ts @@ -6,7 +6,7 @@ export type DocumentBinding = { readonly document: Readonly; readonly handle: Handle; readonly title: string; - validate(): Promise>>; + validate(): Promise> | { issues: ReadonlyArray }>; }; export type ScopedDocumentLink = { @@ -71,8 +71,9 @@ async function validateScopedDocument( binding: string, ): Promise> { const result = await document.validate(); - if (result.tag === "Err") { - return [`${binding}: ${JSON.stringify(result.content)}`]; + const issues = "issues" in result ? result.issues : result.tag === "Err" ? result.content : []; + if (issues.length > 0) { + return [`${binding}: ${JSON.stringify(issues)}`]; } return []; } diff --git a/packages/logics/test/petri-net.test.ts b/packages/logics/test/petri-net.test.ts index 3603220d08..91ad0cdc15 100644 --- a/packages/logics/test/petri-net.test.ts +++ b/packages/logics/test/petri-net.test.ts @@ -74,11 +74,8 @@ describe("The petri-net logic", () => { }); const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); - if (result.tag !== "Ok") { - return; - } - expect(result.content.obGenerators().length).toBe(3); - expect(result.content.morGenerators().length).toBe(1); + expect(result.issues).toEqual([]); + expect(result.model.judgmentsOf(Place).length).toBe(3); + expect(result.model.judgmentsOf(Transition).length).toBe(1); }); }); diff --git a/packages/logics/test/simple-olog-advanced.test.ts b/packages/logics/test/simple-olog-advanced.test.ts index 0b456c4e4c..a890a8654e 100644 --- a/packages/logics/test/simple-olog-advanced.test.ts +++ b/packages/logics/test/simple-olog-advanced.test.ts @@ -16,12 +16,9 @@ describe.skip("the simple-olog logic (advanced)", () => { notebook.add(Aspect, { label: "has", from: a, to: b }); const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); - if (result.tag !== "Ok") { - return; - } - expect(result.content.obGenerators().length).toBe(2); - expect(result.content.morGenerators().length).toBe(1); + expect(result.issues).toEqual([]); + expect(result.model.judgmentsOf(Type).length).toBe(2); + expect(result.model.judgmentsOf(Aspect).length).toBe(1); }); test("the shape supports rich text and path equations", async () => { @@ -79,6 +76,6 @@ describe.skip("the simple-olog logic (advanced)", () => { return; } expect(migration.content.document.theory).toBe("simple-schema"); - expect((await migration.content.validate()).tag).toBe("Ok"); + expect((await migration.content.validate()).issues).toEqual([]); }); }); diff --git a/packages/logics/test/simple-schema.test.ts b/packages/logics/test/simple-schema.test.ts index c8f62f2d8a..d0fd19b327 100644 --- a/packages/logics/test/simple-schema.test.ts +++ b/packages/logics/test/simple-schema.test.ts @@ -57,12 +57,11 @@ describe("the simple-schema logic", () => { notebook.add(Attr, { label: "name", from: person, to: str }); const result = await notebook.validate(); - expect(result.tag).toBe("Ok"); - if (result.tag !== "Ok") { - return; - } - expect(result.content.obGenerators().length).toBe(3); - expect(result.content.morGenerators().length).toBe(2); + expect(result.issues).toEqual([]); + expect(result.model.judgmentsOf(Entity).length).toBe(2); + expect(result.model.judgmentsOf(AttrType).length).toBe(1); + expect(result.model.judgmentsOf(Mapping).length).toBe(1); + expect(result.model.judgmentsOf(Attr).length).toBe(1); }); test("the shape supports rich text", async () => { @@ -102,6 +101,6 @@ describe("the simple-schema logic", () => { return; } expect(migration.content.document.theory).toBe("simple-olog"); - expect((await migration.content.validate()).tag).toBe("Ok"); + expect((await migration.content.validate()).issues).toEqual([]); }); }); From 6b7f8b4c21eae21eb89fe1a1d13e19e07229e6bf Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Fri, 28 Aug 2026 15:19:04 +0100 Subject: [PATCH 23/83] ENH: Make instance validation return tables --- packages/documents/src/index.ts | 4 +- packages/documents/src/instance/errors.ts | 18 +- .../src/instance/instance-runtime.ts | 97 +++------ packages/documents/src/instance/instance.ts | 75 ++++--- .../documents/src/instance/table-methods.ts | 126 ++++++++--- packages/documents/src/instance/tables.ts | 25 ++- packages/documents/src/instance/validation.ts | 28 ++- .../documents/src/model/elaborated-model.ts | 2 +- packages/documents/src/model/validation.ts | 13 +- packages/documents/test/instances.test.ts | 195 ++++++++++++++---- packages/documents/test/validation.test.ts | 20 +- .../frontend/src/api/document_store.test.ts | 9 +- .../src/llm_conversation/document.test.ts | 49 ++++- .../src/llm_conversation/execution_scope.ts | 2 +- .../src/llm_conversation/scoped_document.ts | 33 ++- 15 files changed, 466 insertions(+), 230 deletions(-) diff --git a/packages/documents/src/index.ts b/packages/documents/src/index.ts index c20fea8bda..1c405c147f 100644 --- a/packages/documents/src/index.ts +++ b/packages/documents/src/index.ts @@ -5,9 +5,9 @@ export type { DocumentStore, DocumentRef, ReactiveView } from "./document-store" export type { Issue, PathSegment, Result } from "./result"; export { createInMemoryStore } from "./document-store"; export { atomicTypeOfAttributeType } from "./instance/validation"; -export type { FieldPath, TableFieldIssue } from "./instance/errors"; +export type { FieldPath, OrphanedTableIssue, TableFieldIssue, TableIssue } from "./instance/errors"; export { instanceFromStore } from "./instance/instance"; -export type { Instance, InstanceDocument } from "./instance/instance"; +export type { Instance, InstanceDocument, InstanceValidation } from "./instance/instance"; export { llmConversationFromStore } from "./llm-conversation"; export type { LLMConversation, diff --git a/packages/documents/src/instance/errors.ts b/packages/documents/src/instance/errors.ts index e1615ec0bf..91b0190bd0 100644 --- a/packages/documents/src/instance/errors.ts +++ b/packages/documents/src/instance/errors.ts @@ -12,5 +12,21 @@ export type FieldPath = [ /** A problem with one field in an instance table. */ export interface TableFieldIssue extends Issue { readonly path: FieldPath; - readonly issueType: "MissingValue" | "DanglingRowRef" | "MistypedRowRef" | "MistypedLiteral"; + readonly issueType: + | "MissingValue" + | "DanglingRowRef" + | "MistypedRowRef" + | "MistypedLiteral" + | "OrphanedField"; } + +/** A problem with a stored table that has no entity in the schema. */ +export interface OrphanedTableIssue extends Issue { + readonly path: [ + string, // Table id. + ]; + readonly issueType: "OrphanedTable"; +} + +/** A problem found while validating an instance's tables. */ +export type TableIssue = TableFieldIssue | OrphanedTableIssue; diff --git a/packages/documents/src/instance/instance-runtime.ts b/packages/documents/src/instance/instance-runtime.ts index b9432e36f1..3c699859e8 100644 --- a/packages/documents/src/instance/instance-runtime.ts +++ b/packages/documents/src/instance/instance-runtime.ts @@ -3,18 +3,19 @@ import type { DocumentStore } from "../document-store"; import type { ModelDocument } from "../model/document"; import type { ElaboratedModel, ModelValidation } from "../model/elaborated-model"; import type { Notebook } from "../model/notebook"; -import type { Issue, Result } from "../result"; +import type { Result } from "../result"; import type { InstanceCapableShape, Shape } from "../shape"; -import type { TableFieldIssue } from "./errors"; +import type { InstanceValidation } from "./instance"; import { addInstanceRowsToStore, instanceTablesFromModel, - readInstancePathFromStore, + readInstancePath, + tablesWithOrphanedData, updateInstanceFieldByIdInStore, updateInstanceFieldsByLabelInStore, } from "./table-methods"; -import type { FieldValue, InstancePath, InstanceTable, LiteralValue, TableRow } from "./tables"; -import { validateTableFields } from "./validation"; +import type { InstanceTable, LiteralValue, TableRow } from "./tables"; +import { validateInstanceTables } from "./validation"; function instanceCapableShape( schema: Notebook, @@ -30,60 +31,18 @@ function instanceCapableShape( `operation` is expected to report its own failures as a `Result`; this does not catch exceptions, so an operation that throws lets that exception propagate. */ -async function withValidatedSchema< - Handle, - S extends Shape, - T, - E extends ReadonlyArray = ReadonlyArray, ->( +async function withValidatedSchema( schema: Notebook, - operation: (schemaModel: ElaboratedModel) => Result, -): Promise> { + operation: (schemaModel: ElaboratedModel) => Result, +): Promise> { const schemaValidation = await schema.validate(); if (schemaValidation.issues.length > 0) { - return { tag: "Err", content: schemaValidation.issues as E }; + return { tag: "Err", content: schemaValidation.issues }; } return operation(schemaValidation.model); } -export function createTablesMethod( - schema: Notebook, - store: DocumentStore, - handle: Handle, -): () => Promise>> { - return () => - withValidatedSchema( - schema, - (schemaModel): Result> => ({ - tag: "Ok", - content: instanceTablesFromModel( - instanceCapableShape(schema), - store, - handle, - schemaModel, - ), - }), - ); -} - -export function createGetMethod( - schema: Notebook, - store: DocumentStore, - handle: Handle, -): (path: InstancePath) => Promise> { - return (path) => - withValidatedSchema(schema, (schemaModel) => - readInstancePathFromStore( - instanceCapableShape(schema), - store, - handle, - schemaModel, - path, - ), - ); -} - export function createAddRowsMethod( schema: Notebook, store: DocumentStore, @@ -133,7 +92,7 @@ export function createUpdateRowsMethod( row: TableRow; values: ReadonlyArray>; }>, -) => Promise>> { +) => Promise> { return (updates) => withValidatedSchema(schema, (schemaModel) => updateInstanceFieldsByLabelInStore( @@ -148,10 +107,7 @@ export function createUpdateRowsMethod( export function createUpdateRowMethod( updateRows: ReturnType, -): ( - row: TableRow, - values: Record, -) => Promise>> { +): (row: TableRow, values: Record) => Promise> { return (row, values) => updateRows([{ row, values: [values] }]); } @@ -163,7 +119,7 @@ export function createSetMethod( row: TableRow, morphism: { id: string }, value: LiteralValue | TableRow, -) => Promise>> { +) => Promise> { return (row, morphism, value) => withValidatedSchema(schema, (schemaModel) => updateInstanceFieldByIdInStore( @@ -178,30 +134,29 @@ export function createSetMethod( ); } -/** Build a validator that combines schema validation with instance-content -validation. */ -export function createSchemaResultValidator( +/** Build a validator that combines schema and instance validation. */ +export function createInstanceValidator( schema: Notebook, store: DocumentStore, handle: Handle, -): (schemaValidation: ModelValidation) => Result> { +): (schemaValidation: ModelValidation) => InstanceValidation { return (schemaValidation) => { - if (schemaValidation.issues.length > 0) { - return { tag: "Err", content: schemaValidation.issues }; - } - - const tables = instanceTablesFromModel( + const schemaTables = instanceTablesFromModel( instanceCapableShape(schema), store, handle, schemaValidation.model, ); - const issues: TableFieldIssue[] = validateTableFields( + const issues = validateInstanceTables( store.getDocumentView(handle) as Readonly, - tables, + schemaTables, ); - return issues.length === 0 - ? { tag: "Ok", content: undefined } - : { tag: "Err", content: issues }; + const tables = tablesWithOrphanedData(store, handle, schemaTables); + return { + modelValidation: schemaValidation, + tables, + issues, + get: (path) => readInstancePath(store, handle, tables, path), + }; }; } diff --git a/packages/documents/src/instance/instance.ts b/packages/documents/src/instance/instance.ts index 39364f1cc4..2e83491fe5 100644 --- a/packages/documents/src/instance/instance.ts +++ b/packages/documents/src/instance/instance.ts @@ -2,17 +2,16 @@ import type { InstanceDocument } from "catcolab-document-methods"; import type { Document } from "catcolab-document-types"; import type { DocumentStore } from "../document-store"; import type { ModelDocument } from "../model/document"; +import type { ModelValidation } from "../model/elaborated-model"; import type { Notebook } from "../model/notebook"; -import type { Issue, Result } from "../result"; +import type { Result } from "../result"; import type { Shape } from "../shape"; -import type { TableFieldIssue } from "./errors"; +import type { TableIssue } from "./errors"; import { createAddRowsMethod, createAddRowMethod, - createGetMethod, - createSchemaResultValidator, + createInstanceValidator, createSetMethod, - createTablesMethod, createUpdateRowsMethod, createUpdateRowMethod, } from "./instance-runtime"; @@ -27,9 +26,6 @@ export interface Instance { readonly document: Readonly; readonly title: string; - tables(): Promise>>; - get(path: InstancePath): Promise>; - update(patch: Partial<{ title: string }>): void; dump(): InstanceDocument; @@ -46,31 +42,42 @@ export interface Instance { updateRow( row: TableRow, values: Record, - ): Promise>>; + ): Promise>; updateRows( updates: ReadonlyArray<{ row: TableRow; values: ReadonlyArray>; }>, - ): Promise>>; + ): Promise>; set( row: TableRow, morphism: { id: string }, value: LiteralValue | TableRow, - ): Promise>>; + ): Promise>; /** Delete stored rows without requiring a valid schema. */ deleteRow(tableId: string, rowId: string): void; deleteRows(rows: ReadonlyArray<{ tableId: string; rowId: string }>): void; - /* Validate both the schema and then the instance */ - validate(): Promise>>; + /** Validate the schema and instance data. Schema issues are reported by + `modelValidation`; instance-data issues are reported by `issues`. */ + validate(): Promise>; /** Subscribe to changes to either the instance document or its schema. */ onChange(callback: () => void): () => void; /** Revalidate initially and whenever either the instance or its schema changes. */ - onValidate( - callback: (result: Result>) => void, - ): () => void; + onValidate(callback: (validation: InstanceValidation) => void): () => void; +} + +/** The result of validating an instance and its schema. */ +export interface InstanceValidation { + /** The result of elaborating and validating the instance's schema. */ + readonly modelValidation: ModelValidation; + /** The instance's tables, including any orphaned stored data. */ + readonly tables: ReadonlyArray; + /** Problems with the instance data; empty when the data is valid. */ + readonly issues: ReadonlyArray; + /** Read one table, row, or field from the validated tables. */ + get(path: InstancePath): Result; } /** Create a store-backed instance. Schema-derived operations validate the schema on demand. */ @@ -84,19 +91,15 @@ export function instanceFromStore( return store.getDocumentView(handle) as Readonly; } - const tables = createTablesMethod(schema, store, handle); - const get = createGetMethod(schema, store, handle); const addRows = createAddRowsMethod(schema, store, handle); const addRow = createAddRowMethod(addRows); const updateRows = createUpdateRowsMethod(schema, store, handle); const updateRow = createUpdateRowMethod(updateRows); const set = createSetMethod(schema, store, handle); - const validateSchemaResult = createSchemaResultValidator(schema, store, handle); + const validateInstance = createInstanceValidator(schema, store, handle); - async function validateCurrentDocument(): Promise< - Result> - > { - return validateSchemaResult(await schema.validate()); + async function validateCurrentDocument(): Promise> { + return validateInstance(await schema.validate()); } function deleteStoredRows(rows: ReadonlyArray<{ tableId: string; rowId: string }>): void { @@ -125,8 +128,6 @@ export function instanceFromStore( get title(): string { return currentDocument().name; }, - tables, - get, update(patch: Partial<{ title: string }>): void { if (patch.title !== undefined) { store.changeDocument(handle, (document: Document): void => { @@ -148,7 +149,7 @@ export function instanceFromStore( deleteRows(rows: ReadonlyArray<{ tableId: string; rowId: string }>): void { deleteStoredRows(rows); }, - validate(): Promise>> { + validate(): Promise> { return validateCurrentDocument(); }, onChange(callback: () => void): () => void { @@ -159,26 +160,24 @@ export function instanceFromStore( unsubscribeSchema(); }; }, - onValidate( - callback: (result: Result>) => void, - ): () => void { + onValidate(callback: (validation: InstanceValidation) => void): () => void { let active: boolean = true; + let latestModelValidation: ModelValidation | undefined; - function notify(result: Result>): void { + function notify(validation: InstanceValidation): void { if (active) { - callback(result); + callback(validation); } } - async function validateAndNotify(): Promise { - notify(await validateCurrentDocument()); - } - const unsubscribeInstance: () => void = store.subscribe(handle, (): void => { - void validateAndNotify(); + if (latestModelValidation !== undefined) { + notify(validateInstance(latestModelValidation)); + } }); - const unsubscribeSchema: () => void = schema.onValidate((result): void => { - notify(validateSchemaResult(result)); + const unsubscribeSchema: () => void = schema.onValidate((modelValidation): void => { + latestModelValidation = modelValidation; + notify(validateInstance(modelValidation)); }); return (): void => { diff --git a/packages/documents/src/instance/table-methods.ts b/packages/documents/src/instance/table-methods.ts index dffdb7f550..56dd27fb2c 100644 --- a/packages/documents/src/instance/table-methods.ts +++ b/packages/documents/src/instance/table-methods.ts @@ -18,28 +18,24 @@ import type { } from "./tables"; import { atomicTypeOfAttributeType } from "./validation"; -/** Read one table, row, or field using a validated schema model. +/** Read one table, row, or field from prepared tables. Addressing failures are reported as issues. */ -export function readInstancePathFromStore( - shape: InstanceCapableShape, +export function readInstancePath( store: DocumentStore, handle: Handle, - schemaModel: ElaboratedModel, + tables: ReadonlyArray, path: InstancePath, ): Result { - const schemaTables = instanceTablesFromModel(shape, store, handle, schemaModel); - const schemaTableById = new Map( - schemaTables.map((schemaTable) => [schemaTable.id, schemaTable]), - ); + const tableById = new Map(tables.map((table) => [table.id, table])); const document = store.getDocumentView(handle) as Readonly; const [tableId, rowsSegment, rowId, fieldsSegment, fieldId, ...rest] = path; - const schemaTable = schemaTableById.get(tableId); - if (schemaTable === undefined) { + const table = tableById.get(tableId); + if (table === undefined) { return pathError(`Table \`${tableId}\` does not exist`); } if (path.length === 1) { - return { tag: "Ok", content: schemaTable }; + return { tag: "Ok", content: table }; } if (rowsSegment !== "rows" || rowId === undefined) { return pathError("An instance path after a table must address a row"); @@ -48,14 +44,14 @@ export function readInstancePathFromStore( if (storedRow === undefined) { return pathError(`Row \`${rowId}\` does not exist in table \`${tableId}\``); } - const row = makeRow(store, handle, schemaTable, rowId); + const row = makeRow(store, handle, table, rowId); if (path.length === 3) { return { tag: "Ok", content: row }; } if (fieldsSegment !== "fields" || fieldId === undefined || rest.length > 0) { return pathError("An instance path after a row must address a field"); } - if (!schemaTable.headers.some((header) => header.id === fieldId)) { + if (!table.headers.some((header) => header.id === fieldId)) { return pathError(`Field \`${fieldId}\` does not exist in table \`${tableId}\``); } const stored = storedRow.fields[fieldId] ?? "Null"; @@ -137,7 +133,7 @@ export function updateInstanceFieldsByLabelInStore( row: TableRow; values: ReadonlyArray>; }>, -): Result { +): Result { const schemaTables = instanceTablesFromModel(shape, store, handle, schemaModel); const issues: Issue[] = []; @@ -176,7 +172,7 @@ export function updateInstanceFieldByIdInStore( row: TableRow, field: { id: string }, value: LiteralValue | TableRow, -): Result { +): Result { const schemaTables = instanceTablesFromModel(shape, store, handle, schemaModel); const issues: Issue[] = []; @@ -315,7 +311,28 @@ function makeRow( }; } -/** Read the tables using a validated schema model. */ +function makeTable( + store: DocumentStore, + handle: Handle, + id: string, + label: string | null, + headers: ReadonlyArray, +): InstanceTable { + const table: InstanceTable = { + id, + label, + headers, + get rows() { + const document = store.getDocumentView(handle) as Readonly; + return orderedRowIds(document.tables[id]).map((rowId) => + makeRow(store, handle, table, rowId), + ); + }, + }; + return table; +} + +/** Read the tables using an elaborated schema model. */ export function instanceTablesFromModel( shape: InstanceCapableShape, store: DocumentStore, @@ -356,19 +373,72 @@ export function instanceTablesFromModel( type: { tag: literalType }, }); } - const table: InstanceTable = { - id: tableObject.id, - label: displayLabel(tableObject.label), - headers, - get rows() { - const document = store.getDocumentView(handle) as Readonly; - return orderedRowIds(document.tables[tableObject.id]).map((rowId) => - makeRow(store, handle, table, rowId), - ); - }, - }; - return table; + return makeTable(store, handle, tableObject.id, displayLabel(tableObject.label), headers); + }); +} + +/** Extend schema-derived tables with any orphaned stored data. + +Orphaned stored fields are appended to their table's headers as `Unknown`-typed +headers, and stored tables without a schema entity become tables with `null` +labels whose headers are derived from the stored field ids. */ +export function tablesWithOrphanedData( + store: DocumentStore, + handle: Handle, + schemaTables: ReadonlyArray, +): ReadonlyArray { + const document = store.getDocumentView(handle) as Readonly; + const schemaTableIds = new Set(schemaTables.map((table) => table.id)); + + const tables = schemaTables.map((table) => { + const orphanedFieldIds = storedFieldIds( + document.tables[table.id], + new Set(table.headers.map((header) => header.id)), + ); + if (orphanedFieldIds.length === 0) { + return table; + } + return makeTable(store, handle, table.id, table.label, [ + ...table.headers, + ...orphanedFieldIds.map(unknownHeader), + ]); }); + + const orphanedTables = Object.keys(document.tables) + .filter((tableId) => !schemaTableIds.has(tableId)) + .map((tableId) => + makeTable( + store, + handle, + tableId, + null, + storedFieldIds(document.tables[tableId], new Set()).map(unknownHeader), + ), + ); + + return [...tables, ...orphanedTables]; +} + +/** Collect stored field ids not in `knownIds`, in first-seen row order. */ +function storedFieldIds( + table: Readonly | undefined, + knownIds: ReadonlySet, +): string[] { + const fieldIds: string[] = []; + const seen = new Set(knownIds); + for (const rowId of orderedRowIds(table)) { + for (const fieldId of Object.keys(table?.rows[rowId]?.fields ?? {})) { + if (!seen.has(fieldId)) { + seen.add(fieldId); + fieldIds.push(fieldId); + } + } + } + return fieldIds; +} + +function unknownHeader(fieldId: string): TableHeader { + return { id: fieldId, label: null, type: { tag: "Unknown" } }; } function displayLabel(label: QualifiedLabel): string { diff --git a/packages/documents/src/instance/tables.ts b/packages/documents/src/instance/tables.ts index 2e12d63c59..aff3dd8fa5 100644 --- a/packages/documents/src/instance/tables.ts +++ b/packages/documents/src/instance/tables.ts @@ -1,30 +1,35 @@ import type { FieldPath } from "./errors"; /** Path relative to the document's `tables` map. */ -export type InstancePath = [string, ...string[]]; +export type InstancePath = [string] | [string, "rows", string] | FieldPath; export type LiteralValue = boolean | number | string | null; export type LiteralType = "Bool" | "Int" | "Float" | "String"; export interface InstanceTable { - /** The table's id: the same as the schema entity's id. */ + /** The stored table id; when the schema entity exists (non-orphaned) then + * this is that entity's id. */ readonly id: string; - /** The schema entity's display label; `""` when unlabeled. */ - readonly label: string; + /** The schema entity's display label; `""` when unlabeled, `null` when the + table has no entity in the schema. */ + readonly label: string | null; /** The table's rows, in stored order. */ readonly rows: ReadonlyArray; - /** The table's headers: the schema morphisms out of its entity. */ + /** The table's headers: the schema morphisms out of its entity, followed + by `Unknown`-typed headers for any orphaned stored fields. */ readonly headers: ReadonlyArray; } export interface TableHeader { - /** The schema morphism's id. */ + /** The schema morphism id, or the stored field id when the header is unknown. */ readonly id: string; - /** Dot-joined qualified label; empty when unlabeled. */ - readonly label: string; + /** Dot-joined qualified label; `""` when unlabeled, `null` when the header + has no morphism in the schema. */ + readonly label: string | null; readonly type: | { readonly tag: LiteralType } - | { readonly tag: "RowRef"; readonly content: { readonly id: string } }; + | { readonly tag: "RowRef"; readonly content: { readonly id: string } } + | { readonly tag: "Unknown" }; } export interface TableRow { @@ -32,7 +37,7 @@ export interface TableRow { readonly id: string; /** The row's zero-based position in the table. */ readonly index: number; - /** The row's schema-interpreted fields. */ + /** The row's decoded fields, in header order. */ readonly fields: ReadonlyArray; } diff --git a/packages/documents/src/instance/validation.ts b/packages/documents/src/instance/validation.ts index 4dfac6d69b..3f6971307b 100644 --- a/packages/documents/src/instance/validation.ts +++ b/packages/documents/src/instance/validation.ts @@ -3,7 +3,7 @@ import type { InstanceDocument } from "catcolab-document-methods"; import type * as DocumentTypes from "catcolab-document-types"; import type { QualifiedLabel } from "catlog-wasm"; -import type { FieldPath, TableFieldIssue } from "./errors"; +import type { FieldPath, TableFieldIssue, TableIssue } from "./errors"; import type { InstanceTable, LiteralType, TableHeader } from "./tables"; /** Decide which concrete atomic type an attribute type's qualified label @@ -23,11 +23,14 @@ export function atomicTypeOfAttributeType(label: QualifiedLabel): LiteralType { } } -/** Compare stored fields with tables derived from a validated schema model. */ -export function validateTableFields( +/** Compare stored data with tables derived from an elaborated schema model. + +Stored tables without a schema entity report a single `OrphanedTable` issue; +their rows are not validated further. */ +export function validateInstanceTables( document: Readonly, tables: ReadonlyArray, -): TableFieldIssue[] { +): TableIssue[] { const tableById = new Map(tables.map((table) => [table.id, table])); const rowTables = new Map>(); @@ -39,15 +42,23 @@ export function validateTableFields( } } - const issues: TableFieldIssue[] = []; + const issues: TableIssue[] = []; for (const [tableId, storedTable] of Object.entries(document.tables)) { const table = tableById.get(tableId); if (table === undefined) { + issues.push({ + message: `Table \`${tableId}\` does not exist in the schema`, + path: [tableId], + issueType: "OrphanedTable", + }); continue; } const headerById = new Map(table.headers.map((header) => [header.id, header])); for (const [rowId, row] of Object.entries(storedTable.rows)) { for (const header of table.headers) { + if (header.type.tag === "Unknown") { + continue; + } const fieldId = header.id; const path = fieldPath(tableId, rowId, fieldId); const value = row.fields[fieldId] ?? "Null"; @@ -92,13 +103,14 @@ export function validateTableFields( } } for (const fieldId of Object.keys(row.fields)) { - if (headerById.has(fieldId)) { + const header = headerById.get(fieldId); + if (header !== undefined && header.type.tag !== "Unknown") { continue; } issues.push({ - message: `Field \`${fieldId}\` is not a typed column of table \`${table.label}\``, + message: `Field \`${fieldId}\` in table \`${table.label}\` does not exist in the schema`, path: fieldPath(tableId, rowId, fieldId), - issueType: "MistypedLiteral", + issueType: "OrphanedField", }); } } diff --git a/packages/documents/src/model/elaborated-model.ts b/packages/documents/src/model/elaborated-model.ts index dffd76f16f..fd01fc7719 100644 --- a/packages/documents/src/model/elaborated-model.ts +++ b/packages/documents/src/model/elaborated-model.ts @@ -53,7 +53,7 @@ export interface ElaboratedModel { Elaboration and validation are separate steps, so `model` is always available: it contains the elaborated judgments even when validation fails. The model is -empty only when elaboration itself fails. */ +empty when elaboration fails or the core theory cannot be loaded. */ export interface ModelValidation { readonly model: ElaboratedModel; /** Validation issues; empty when the notebook is valid. */ diff --git a/packages/documents/src/model/validation.ts b/packages/documents/src/model/validation.ts index 76e3d084bc..c4ebdd889c 100644 --- a/packages/documents/src/model/validation.ts +++ b/packages/documents/src/model/validation.ts @@ -147,9 +147,6 @@ export interface NotebookValidator { createValidationView(): ModelValidationView; } -/** The state shared by all validation consumers of a notebook. */ -type ValidationState = ModelValidationState; - /** Create the validation machinery for a notebook over a document store. All consumers share one code path and one source of truth: the document is @@ -201,14 +198,14 @@ export function createNotebookValidator( }; } - let latestValidationState: ValidationState = { + let latestValidationState: ModelValidationState = { issues: [{ message: "The notebook has not been validated yet." }], }; - const validationStateListeners = new Set<(state: ValidationState) => void>(); + const validationStateListeners = new Set<(state: ModelValidationState) => void>(); let unsubscribeValidationSource: (() => void) | undefined; let revalidationCounter = 0; - function publishValidationState(state: ValidationState): void { + function publishValidationState(state: ModelValidationState): void { latestValidationState = state; for (const listener of validationStateListeners) { listener(state); @@ -229,7 +226,9 @@ export function createNotebookValidator( /** Subscribe to validation state, revalidating on every document change. The listener receives an initial publish once revalidation completes. */ - function subscribeToValidationState(listener: (state: ValidationState) => void): () => void { + function subscribeToValidationState( + listener: (state: ModelValidationState) => void, + ): () => void { validationStateListeners.add(listener); if (unsubscribeValidationSource === undefined) { unsubscribeValidationSource = store.subscribe(handle, () => { diff --git a/packages/documents/test/instances.test.ts b/packages/documents/test/instances.test.ts index e70c07706f..de88d4b99b 100644 --- a/packages/documents/test/instances.test.ts +++ b/packages/documents/test/instances.test.ts @@ -8,9 +8,7 @@ import { defineShape, type DocumentRef, type DocumentStore, - type Issue, type Result, - type TableFieldIssue, } from "catcolab-documents"; function refOf(store: DocumentStore, handle: Handle): DocumentRef { @@ -166,9 +164,11 @@ describe("instance schema validation", () => { await binder.createInstance(schema, { title: "Company instance" }), ); - const tables = expectOk(await instance.tables()); - expect(tables.map((table) => table.label)).toEqual(["Person", "Company"]); - expect(await instance.validate()).toEqual({ tag: "Ok", content: undefined }); + const validation = await instance.validate(); + expect(validation.tables.map((table) => table.label)).toEqual(["Person", "Company"]); + expect(validation.issues).toEqual([]); + expect(validation.modelValidation.issues).toEqual([]); + expect(validation.modelValidation.model.judgmentsOf(Entity)).toHaveLength(2); }, ); @@ -195,23 +195,28 @@ describe("instance schema validation", () => { const unsubscribeChanges = instance.onChange(() => { changes += 1; }); - const validations: Result>[] = []; - const unsubscribeValidation = instance.onValidate((result) => { - validations.push(result); + const validations: Awaited>[] = []; + const unsubscribeValidation = instance.onValidate((validation) => { + validations.push(validation); }); await expect.poll(() => validations.length).toBe(1); - expectOk(validations[0]!); - expect(expectOk(await instance.tables()).map((table) => table.label)).toEqual(["Person"]); + const validation = validations[0]!; + expect(validation.tables.map((table) => table.label)).toEqual(["Person"]); schema.add(Mapping, { label: "invalid", from: null, to: null }); await expect.poll(() => changes).toBe(1); - await expect.poll(() => validations.at(-1)?.tag).toBe("Err"); + await expect.poll(() => validations.length).toBe(2); + await expect + .poll(() => validations.at(-1)?.modelValidation.issues.length) + .toBeGreaterThan(0); + expect(validations.at(-1)?.issues).toEqual([]); + expect(validations.at(-1)?.tables.map((table) => table.label)).toEqual(["Person"]); instance.update({ title: "Renamed instance" }); await expect.poll(() => changes).toBe(2); - await expect.poll(() => validations.length).toBeGreaterThanOrEqual(3); - expect(validations.at(-1)?.tag).toBe("Err"); + await expect.poll(() => validations.length).toBe(3); + expect(validations.at(-1)?.modelValidation.issues.length).toBeGreaterThan(0); unsubscribeChanges(); unsubscribeValidation(); @@ -238,8 +243,9 @@ describe("tabular instances", () => { await binder.createInstance(schema, { title: "Company instance" }), ); - expect((await instance.validate()).tag).toBe("Ok"); - const tables = expectOk(await instance.tables()); + const validation = await instance.validate(); + expect(validation.issues).toEqual([]); + const tables = validation.tables; const personTable = tables.find((table) => table.label === "Person"); const companyTable = tables.find((table) => table.label === "Company"); expect(personTable).toBeDefined(); @@ -270,11 +276,11 @@ describe("tabular instances", () => { }); expect(nameHeader.type).toEqual({ tag: "String" }); - expect(await instance.get([personTable.id])).toMatchObject({ + expect(validation.get([personTable.id])).toMatchObject({ tag: "Ok", content: { id: personTable.id }, }); - const missingIssues = expectErr(await instance.get(["missing-table"])); + const missingIssues = expectErr(validation.get(["missing-table"])); expect(missingIssues[0]?.message).toMatch(/does not exist/); const acme = expectOk(await instance.addRow(companyTable, { companyName: "Acme" })); @@ -287,7 +293,7 @@ describe("tabular instances", () => { expect(alice.index).toBe(0); expect(alice.fields.map((field) => field.tag)).toEqual(["RowRef", "String"]); - const nameResult = await instance.get([ + const nameResult = validation.get([ personTable.id, "rows", alice.id, @@ -368,10 +374,10 @@ describe("tabular instances", () => { const instance = expectOk( await binder.createInstance(schema, { title: "Company instance" }), ); - expect((await instance.validate()).tag).toBe("Ok"); - const tables = expectOk(await instance.tables()); - const personTable = tables.find((table) => table.label === "Person"); - const companyTable = tables.find((table) => table.label === "Company"); + const validation = await instance.validate(); + expect(validation.issues).toEqual([]); + const personTable = validation.tables.find((table) => table.label === "Person"); + const companyTable = validation.tables.find((table) => table.label === "Company"); if (personTable === undefined || companyTable === undefined) { return; } @@ -403,12 +409,8 @@ describe("tabular instances", () => { row.fields[nameHeader.id] = { Int: 42 }; }); - const result = await instance.validate(); - expect(result.tag).toBe("Err"); - if (result.tag !== "Err") { - return; - } - expect(result.content).toEqual( + const revalidation = await instance.validate(); + expect(revalidation.issues).toEqual( expect.arrayContaining([ expect.objectContaining({ path: [personTable.id, "rows", alice.id, "fields", employerHeader.id], @@ -430,7 +432,7 @@ describe("tabular instances", () => { const instance = expectOk( await binder.createInstance(schema, { title: "Company instance" }), ); - const personTable = expectOk(await instance.tables()).find( + const personTable = (await instance.validate()).tables.find( (table) => table.label === "Person", ); if (personTable === undefined) { @@ -439,7 +441,10 @@ describe("tabular instances", () => { const row = expectOk(await instance.addRow(personTable)); schema.add(Mapping, { label: "invalid", from: null, to: null }); - expect((await instance.validate()).tag).toBe("Err"); + const validation = await instance.validate(); + expect(validation.modelValidation.issues.length).toBeGreaterThan(0); + expect(validation.issues).toEqual([]); + expect(validation.tables.map((table) => table.label)).toEqual(["Person"]); instance.deleteRow(personTable.id, row.id); expect(instance.document.tables[personTable.id]?.rows[row.id]).toBeUndefined(); @@ -463,10 +468,10 @@ describe("tabular instances", () => { const instance = expectOk( await binder.createInstance(schema, { title: "Company instance" }), ); - expect((await instance.validate()).tag).toBe("Ok"); - const tables = expectOk(await instance.tables()); - const personTable = tables.find((table) => table.label === "Person"); - const companyTable = tables.find((table) => table.label === "Company"); + const validation = await instance.validate(); + expect(validation.issues).toEqual([]); + const personTable = validation.tables.find((table) => table.label === "Person"); + const companyTable = validation.tables.find((table) => table.label === "Company"); if (personTable === undefined || companyTable === undefined) { return; } @@ -481,18 +486,128 @@ describe("tabular instances", () => { const alice = expectOk(await instance.addRow(personTable, { employer: acme })); instance.deleteRow(companyTable.id, acme.id); - const result = await instance.validate(); - expect(result.tag).toBe("Err"); - if (result.tag !== "Err") { - return; - } - expect(result.content).toContainEqual({ + const revalidation = await instance.validate(); + expect(revalidation.issues).toContainEqual({ message: "`employer` refers to a row that no longer exists", path: [personTable.id, "rows", alice.id, "fields", employerHeader.id], issueType: "DanglingRowRef", }); }, ); + + test( + "schema and instance issues are reported with usable partial tables", + { timeout: 20_000 }, + async () => { + const binder = createBinder(); + const schema = await binder.createNotebook(SimpleSchema, { + title: "Company schema", + }); + const person = schema.add(Entity, { label: "Person" }); + const string = schema.add(AttrType, { label: "String" }); + schema.add(Attr, { label: "name", from: person, to: string }); + const instance = expectOk( + await binder.createInstance(schema, { title: "Company instance" }), + ); + const validation = await instance.validate(); + const personTable = validation.tables.find((table) => table.label === "Person"); + if (personTable === undefined) { + throw new Error("Person table was not derived"); + } + const alice = expectOk(await instance.addRow(personTable, { name: "Alice" })); + + binder.store.changeDocument(instance.document as Document, (document) => { + const stored = document as unknown as StoredInstanceForTest; + const row = stored.tables[personTable.id]?.rows[alice.id]; + if (row === undefined) { + throw new Error("Alice row is missing from the stored instance"); + } + row.fields["unexpected"] = { String: "stray" }; + }); + schema.add(Mapping, { label: "invalid", from: null, to: null }); + + const revalidation = await instance.validate(); + expect(revalidation.modelValidation.issues.length).toBeGreaterThan(0); + expect(revalidation.modelValidation.model.judgmentsOf(Entity)).toHaveLength(1); + expect(revalidation.issues).toEqual([ + { + message: "Field `unexpected` in table `Person` does not exist in the schema", + path: [personTable.id, "rows", alice.id, "fields", "unexpected"], + issueType: "OrphanedField", + }, + ]); + + const augmentedTable = revalidation.tables.find((table) => table.id === personTable.id); + expect(augmentedTable?.headers.at(-1)).toEqual({ + id: "unexpected", + label: null, + type: { tag: "Unknown" }, + }); + expect( + revalidation.get([personTable.id, "rows", alice.id, "fields", "unexpected"]), + ).toEqual({ + tag: "Ok", + content: { + tag: "String", + content: { + path: [personTable.id, "rows", alice.id, "fields", "unexpected"], + value: "stray", + }, + }, + }); + }, + ); + + test( + "an orphaned stored table is reported and derived from its stored fields", + { timeout: 20_000 }, + async () => { + const binder = createBinder(); + const schema = await binder.createNotebook(SimpleSchema, { + title: "Company schema", + }); + schema.add(Entity, { label: "Person" }); + const instance = expectOk( + await binder.createInstance(schema, { title: "Company instance" }), + ); + + binder.store.changeDocument(instance.document as Document, (document) => { + const stored = document as unknown as StoredInstanceForTest; + stored.tables["ghost-table"] = { + rows: { "ghost-row": { fields: { mystery: { Int: 3 } } } }, + rowOrder: ["ghost-row"], + }; + }); + + const validation = await instance.validate(); + expect(validation.issues).toEqual([ + { + message: "Table `ghost-table` does not exist in the schema", + path: ["ghost-table"], + issueType: "OrphanedTable", + }, + ]); + + const ghostTable = validation.tables.find((table) => table.id === "ghost-table"); + expect(ghostTable).toMatchObject({ + label: null, + headers: [{ id: "mystery", label: null, type: { tag: "Unknown" } }], + }); + expect(ghostTable?.rows.map((row) => row.id)).toEqual(["ghost-row"]); + expect( + validation.get(["ghost-table", "rows", "ghost-row", "fields", "mystery"]), + ).toEqual({ + tag: "Ok", + content: { + tag: "Int", + content: { + path: ["ghost-table", "rows", "ghost-row", "fields", "mystery"], + value: 3, + }, + }, + }); + }, + ); }); describe("atomic instance column types", () => { diff --git a/packages/documents/test/validation.test.ts b/packages/documents/test/validation.test.ts index f487171698..9d32a32bca 100644 --- a/packages/documents/test/validation.test.ts +++ b/packages/documents/test/validation.test.ts @@ -2,8 +2,8 @@ import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog"; import { describe, expect, test } from "vitest"; // RFC-0006 "Model validation": the asynchronous `validate` method, subscribing -// to validation changes with `onValidate`, and validation failures. -import { createBinder, Instantiation, type ModelValidation } from "catcolab-documents"; +// to validation changes with `onValidate`, and validation issues. +import { createBinder, defineShape, Instantiation, type ModelValidation } from "catcolab-documents"; async function wellFormedOlog() { const binder = createBinder(); @@ -46,6 +46,18 @@ describe("validate", { timeout: 10000 }, () => { ["has"], ]); }); + + test("a missing core theory reports issues with an empty model", async () => { + const binder = createBinder(); + const notebook = await binder.createNotebook(defineShape({ theory: "bare" }), { + title: "Bare notebook", + }); + + const result = await notebook.validate(); + + expect(result.issues).toEqual([{ message: "Shape `bare` has no core theory" }]); + expect(result.model.judgments()).toEqual([]); + }); }); describe("onValidate", { timeout: 10000 }, () => { @@ -93,8 +105,8 @@ describe("onValidate", { timeout: 10000 }, () => { }); }); -describe.skip("validation result", () => { - test("an ill-formed notebook results in an Err carrying issues", async () => { +describe.skip("validation issues", () => { + test("an ill-formed notebook reports issues", async () => { const binder = createBinder(); const first = await binder.createNotebook(SimpleOlog, { title: "First" }); diff --git a/packages/frontend/src/api/document_store.test.ts b/packages/frontend/src/api/document_store.test.ts index e4b106bfbe..f2002855ae 100644 --- a/packages/frontend/src/api/document_store.test.ts +++ b/packages/frontend/src/api/document_store.test.ts @@ -81,12 +81,9 @@ describe("API document store", () => { throw new Error("expected instance to load"); } - const tables = await instance.content.tables(); - expect(tables.tag).toBe("Ok"); - if (tables.tag !== "Ok") { - throw new Error("expected tables to load"); - } - const table = tables.content.find((table) => table.label === "Person"); + const validation = await instance.content.validate(); + expect(validation.issues).toEqual([]); + const table = validation.tables.find((table) => table.label === "Person"); expect(table).toBeDefined(); if (!table) { throw new Error("expected entity table"); diff --git a/packages/frontend/src/llm_conversation/document.test.ts b/packages/frontend/src/llm_conversation/document.test.ts index d6a9198698..f5bdd7c850 100644 --- a/packages/frontend/src/llm_conversation/document.test.ts +++ b/packages/frontend/src/llm_conversation/document.test.ts @@ -1,4 +1,4 @@ -import { Entity, SimpleSchema } from "catcolab-logics/simple-schema"; +import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema"; import { assert, beforeEach, describe, test, vi } from "vitest"; import type { Document } from "catcolab-document-types"; @@ -42,7 +42,7 @@ async function makeInvalidInstance(fixture: Fixture) { const instance = fixture.instance; assert(instance); fixture.schema.add(Entity, { label: "Person" }); - const table = expectOk(await instance.tables())[0]; + const table = (await instance.validate()).tables[0]; assert(table); const row = expectOk(await instance.addRow(table)); fixture.binder.store.changeDocument(instance.handle, (document) => { @@ -114,6 +114,47 @@ describe("LLM conversation turns", { timeout: 30_000 }, () => { ); }); + test("blocks notebook commits until schema issues are repaired", async () => { + const fixture = await makeFixture(); + inference.runChatTurn.mockImplementation( + async ( + _client, + _transcript, + scope, + _onContent, + _model, + _systemPromptSuffix, + onSuccessHook, + ) => { + const schema = schemaBinding(scope); + const invalid = schema.add(Mapping, { + label: "invalid", + from: null, + to: null, + }); + assert(onSuccessHook); + + let validationError: unknown; + try { + await onSuccessHook(); + } catch (error) { + validationError = error; + } + assert(validationError instanceof Error); + assert.match(validationError.message, /document_Company_schema: .*"message"/s); + + invalid.delete(); + await onSuccessHook(); + return response("Repaired."); + }, + ); + + assert.deepStrictEqual(await runTurn(fixture), { + tag: "Completed", + content: "Repaired.", + }); + }); + test("persists complete validation feedback from linked document tools", async () => { const fixture = await makeFixture(true); const { table, row } = await makeInvalidInstance(fixture); @@ -143,7 +184,7 @@ describe("LLM conversation turns", { timeout: 30_000 }, () => { } assert(validationError instanceof Error); const feedback = validationError.message; - assert.match(feedback, /"issueType":"MistypedLiteral"/); + assert.match(feedback, /"issueType":"OrphanedField"/); assert.ok( feedback.includes( JSON.stringify([table.id, "rows", row.id, "fields", "unexpected"]), @@ -195,7 +236,7 @@ describe("LLM conversation turns", { timeout: 30_000 }, () => { assert(execution?.tag === "llm-code-execution"); assert.strictEqual(execution.result.tag, "Err"); if (execution.result.tag === "Err") { - assert.match(execution.result.error, /"issueType":"MistypedLiteral"/); + assert.match(execution.result.error, /"issueType":"OrphanedField"/); } }); diff --git a/packages/frontend/src/llm_conversation/execution_scope.ts b/packages/frontend/src/llm_conversation/execution_scope.ts index f2b0647283..c087ff938f 100644 --- a/packages/frontend/src/llm_conversation/execution_scope.ts +++ b/packages/frontend/src/llm_conversation/execution_scope.ts @@ -18,7 +18,7 @@ import { type ScopedDocumentRole, } from "./scoped_document"; -const API_PROMPT = `The document bindings expose the CatColab document API. A notebook binding has \`title\`, \`cells()\`, \`cellsOf(type)\`, \`add(type, values)\`, \`update(patch)\`, and \`validate()\`. The \`type\` argument of a notebook's \`add\` method must be one of the cell-type bindings in scope; the \`values\` argument is an object: \`{ label }\` to add an object cell, \`{ label, from, to }\` to add a morphism cell (\`from\`/\`to\` are existing object cells), or \`{ content }\` to add an informal text cell. A tabular document binding has \`title\`, \`tables()\`, \`get(path)\`, row editing methods, \`update(patch)\`, and \`validate()\`. These APIs mutate in-memory working copies; changes are applied to the user's documents only after every document validates.`; +const API_PROMPT = `The document bindings expose the CatColab document API. A notebook binding has \`title\`, \`cells()\`, \`cellsOf(type)\`, \`add(type, values)\`, \`update(patch)\`, and \`validate()\`; \`validate()\` returns the elaborated \`model\` and any \`issues\`. The \`type\` argument of a notebook's \`add\` method must be one of the cell-type bindings in scope; the \`values\` argument is an object: \`{ label }\` to add an object cell, \`{ label, from, to }\` to add a morphism cell (\`from\`/\`to\` are existing object cells), or \`{ content }\` to add an informal text cell. A tabular document binding has \`title\`, row editing methods, \`update(patch)\`, and \`validate()\`; \`validate()\` returns the \`tables\`, instance-data \`issues\`, the schema's \`modelValidation\`, and \`get(path)\`. These APIs mutate in-memory working copies; changes are applied to the user's documents only after every document validates without issues.`; type SourceDocuments = | { diff --git a/packages/frontend/src/llm_conversation/scoped_document.ts b/packages/frontend/src/llm_conversation/scoped_document.ts index db0f108435..df98532486 100644 --- a/packages/frontend/src/llm_conversation/scoped_document.ts +++ b/packages/frontend/src/llm_conversation/scoped_document.ts @@ -1,12 +1,20 @@ import type { Document } from "catcolab-document-types"; -import { type DocumentStore, type Issue, type Result } from "catcolab-documents"; +import type { + DocumentStore, + InstanceValidation, + Issue, + ModelValidation, + Shape, +} from "catcolab-documents"; import { createDocumentTransaction } from "./document_transaction"; -export type DocumentBinding = { +type DocumentValidation = ModelValidation | InstanceValidation; + +type DocumentBinding = { readonly document: Readonly; readonly handle: Handle; readonly title: string; - validate(): Promise> | { issues: ReadonlyArray }>; + validate(): Promise; }; export type ScopedDocumentLink = { @@ -32,8 +40,8 @@ export type ScopedDocument = { }; /** Create and stage the execution-scope representation of one document. */ -export function createScopedDocument(options: { - binding: DocumentBinding; +export function createScopedDocument(options: { + binding: DocumentBinding; bindingStore: DocumentStore; sourceHandle: Handle; sourceStore: DocumentStore; @@ -66,18 +74,25 @@ export function createScopedDocument(options: { }; } -async function validateScopedDocument( - document: DocumentBinding, +async function validateScopedDocument( + document: DocumentBinding, binding: string, ): Promise> { - const result = await document.validate(); - const issues = "issues" in result ? result.issues : result.tag === "Err" ? result.content : []; + const validation = await document.validate(); + const issues = validationIssues(validation); if (issues.length > 0) { return [`${binding}: ${JSON.stringify(issues)}`]; } return []; } +function validationIssues(validation: DocumentValidation): ReadonlyArray { + if ("modelValidation" in validation) { + return [...validation.modelValidation.issues, ...validation.issues]; + } + return validation.issues; +} + function describeScopedDocument(description: ScopedDocumentDescription): string { const kind = description.role === "attachment" ? "attached document" : "document"; const links = (description.links ?? []) From 3991b861b0e20ed864a8a95457fa3f3f6e26ad50 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 1 Sep 2026 14:26:25 +0100 Subject: [PATCH 24/83] ENH: Reactive view for instance validation --- packages/documents/src/index.ts | 7 +- packages/documents/src/instance/instance.ts | 44 ++++++++++- packages/documents/test/instances.test.ts | 35 +++++++++ .../test/stores/solid-validation-view.test.ts | 76 +++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/packages/documents/src/index.ts b/packages/documents/src/index.ts index 1c405c147f..b05928e44c 100644 --- a/packages/documents/src/index.ts +++ b/packages/documents/src/index.ts @@ -7,7 +7,12 @@ export { createInMemoryStore } from "./document-store"; export { atomicTypeOfAttributeType } from "./instance/validation"; export type { FieldPath, OrphanedTableIssue, TableFieldIssue, TableIssue } from "./instance/errors"; export { instanceFromStore } from "./instance/instance"; -export type { Instance, InstanceDocument, InstanceValidation } from "./instance/instance"; +export type { + Instance, + InstanceDocument, + InstanceValidation, + InstanceValidationView, +} from "./instance/instance"; export { llmConversationFromStore } from "./llm-conversation"; export type { LLMConversation, diff --git a/packages/documents/src/instance/instance.ts b/packages/documents/src/instance/instance.ts index 2e83491fe5..e8bd176a34 100644 --- a/packages/documents/src/instance/instance.ts +++ b/packages/documents/src/instance/instance.ts @@ -1,8 +1,8 @@ import type { InstanceDocument } from "catcolab-document-methods"; import type { Document } from "catcolab-document-types"; -import type { DocumentStore } from "../document-store"; +import { createReactiveView, type DocumentStore } from "../document-store"; import type { ModelDocument } from "../model/document"; -import type { ModelValidation } from "../model/elaborated-model"; +import type { ModelValidation, ModelValidationView } from "../model/elaborated-model"; import type { Notebook } from "../model/notebook"; import type { Result } from "../result"; import type { Shape } from "../shape"; @@ -66,6 +66,9 @@ export interface Instance { onChange(callback: () => void): () => void; /** Revalidate initially and whenever either the instance or its schema changes. */ onValidate(callback: (validation: InstanceValidation) => void): () => void; + /** Create a live, reactive view of the instance's validation state. The + * caller must dispose the view when it is no longer needed. */ + createValidationView(): InstanceValidationView; } /** The result of validating an instance and its schema. */ @@ -80,6 +83,13 @@ export interface InstanceValidation { get(path: InstancePath): Result; } +/** A live view of an instance's validation state. */ +export interface InstanceValidationView extends InstanceValidation { + /** The live validation view of the instance's schema. */ + readonly modelValidation: ModelValidationView; + dispose(): void; +} + /** Create a store-backed instance. Schema-derived operations validate the schema on demand. */ export function instanceFromStore( shape: S, @@ -186,6 +196,36 @@ export function instanceFromStore( unsubscribeSchema(); }; }, + createValidationView(): InstanceValidationView { + const modelValidation = schema.createValidationView(); + let revision = 0; + const reactiveRevision = createReactiveView(store, { revision }); + const unsubscribeInstance = store.subscribe(handle, (): void => { + reactiveRevision.replace({ revision: ++revision }); + }); + + function currentValidation(): InstanceValidation { + void reactiveRevision.current.revision; + return validateInstance(modelValidation); + } + + return { + modelValidation, + get tables(): ReadonlyArray { + return currentValidation().tables; + }, + get issues(): ReadonlyArray { + return currentValidation().issues; + }, + get(path: InstancePath): Result { + return currentValidation().get(path); + }, + dispose(): void { + unsubscribeInstance(); + modelValidation.dispose(); + }, + }; + }, }; return instance; diff --git a/packages/documents/test/instances.test.ts b/packages/documents/test/instances.test.ts index de88d4b99b..5c7e9de0fb 100644 --- a/packages/documents/test/instances.test.ts +++ b/packages/documents/test/instances.test.ts @@ -221,6 +221,41 @@ describe("instance schema validation", () => { unsubscribeChanges(); unsubscribeValidation(); }); + + test("a validation view tracks schema and instance changes", async () => { + const binder = createBinder(); + const schema = await binder.createNotebook(SimpleSchema, { title: "Company schema" }); + schema.add(Entity, { label: "Person" }); + const instance = expectOk( + await binder.createInstance(schema, { title: "Company instance" }), + ); + const view = instance.createValidationView(); + + await expect.poll(() => view.modelValidation.issues).toEqual([]); + expect(view.tables.map((table) => table.label)).toEqual(["Person"]); + + schema.add(Entity, { label: "Company" }); + await expect + .poll(() => view.tables.map((table) => table.label)) + .toEqual(["Person", "Company"]); + + binder.store.changeDocument(instance.handle, (document) => { + const stored = document as unknown as StoredInstanceForTest; + stored.tables["ghost-table"] = { + rows: { "ghost-row": { fields: { mystery: { Int: 3 } } } }, + rowOrder: ["ghost-row"], + }; + }); + await expect + .poll(() => view.issues.map((issue) => issue.issueType)) + .toEqual(["OrphanedTable"]); + expect(view.get(["ghost-table", "rows", "ghost-row", "fields", "mystery"])).toMatchObject({ + tag: "Ok", + content: { tag: "Int", content: { value: 3 } }, + }); + + view.dispose(); + }); }); describe("tabular instances", () => { diff --git a/packages/documents/test/stores/solid-validation-view.test.ts b/packages/documents/test/stores/solid-validation-view.test.ts index d0bea0a4c8..29849f720e 100644 --- a/packages/documents/test/stores/solid-validation-view.test.ts +++ b/packages/documents/test/stores/solid-validation-view.test.ts @@ -1,4 +1,5 @@ import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog"; +import { Attr, AttrType, Entity, SimpleSchema } from "catcolab-logics/simple-schema"; import { createEffect, createRoot } from "solid-js"; import { describe, expect, test } from "vitest"; @@ -46,4 +47,79 @@ describe("reactive validation view", { timeout: 20000 }, () => { expect(notebook.handle.listeners.size).toBe(0); dispose(); }); + + test("threads model and instance reactivity through an instance view", async () => { + const solidBinder = createBinder(solidStore); + const schema = await solidBinder.createNotebook(SimpleSchema, { + title: "Company schema", + }); + const person = schema.add(Entity, { label: "Person" }); + const string = schema.add(AttrType, { label: "String" }); + schema.add(Attr, { label: "name", from: person, to: string }); + const instanceResult = await solidBinder.createInstance(schema, { + title: "Company instance", + }); + if (instanceResult.tag === "Err") { + throw new Error("Expected the instance to be created"); + } + const instance = instanceResult.content; + const view = instance.createValidationView(); + + let tableLabels: Array = []; + let headerLabels: Array = []; + let rowCount = -1; + let issueTypes: string[] = []; + let schemaIssueCount = -1; + const dispose = createRoot((dispose) => { + // All observed state comes from one view; no signals are updated manually. + createEffect(() => { + schemaIssueCount = view.modelValidation.issues.length; + const tables = view.tables; + tableLabels = tables.map((table) => table.label); + headerLabels = tables[0]?.headers.map((header) => header.label) ?? []; + rowCount = tables[0]?.rows.length ?? -1; + issueTypes = view.issues.map((issue) => issue.issueType); + }); + return dispose; + }); + + await expect.poll(() => schemaIssueCount, { timeout: 10000 }).toBe(0); + expect(tableLabels).toEqual(["Person"]); + expect(headerLabels).toEqual(["name"]); + expect(rowCount).toBe(0); + + const personTable = view.tables[0]; + if (personTable === undefined) { + throw new Error("Expected the Person table"); + } + // An instance edit updates the row count without refreshing the view. + const rowResult = await instance.addRow(personTable, { name: "Alice" }); + if (rowResult.tag === "Err") { + throw new Error("Expected the row to be added"); + } + await expect.poll(() => rowCount).toBe(1); + + // A schema edit updates both the derived headers and instance issues. + schema.add(Attr, { label: "role", from: person, to: string }); + await expect.poll(() => headerLabels).toEqual(["name", "role"]); + await expect.poll(() => issueTypes).toEqual(["MissingValue"]); + + const roleHeader = view.tables[0]?.headers.find((header) => header.label === "role"); + if (roleHeader === undefined) { + throw new Error("Expected the role header"); + } + const setResult = await instance.set(rowResult.content, roleHeader, "Engineer"); + if (setResult.tag === "Err") { + throw new Error("Expected the role to be set"); + } + // Repairing the instance clears the issue through the same view. + await expect.poll(() => issueTypes).toEqual([]); + + expect(instance.handle.listeners.size).toBeGreaterThan(0); + expect(schema.handle.listeners.size).toBeGreaterThan(0); + view.dispose(); + expect(instance.handle.listeners.size).toBe(0); + expect(schema.handle.listeners.size).toBe(0); + dispose(); + }); }); From 3f0030d00ad37a247f0dddc9c3182a15dca20fb9 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 1 Sep 2026 16:35:07 +0100 Subject: [PATCH 25/83] ENH: Allow instances to be created from partial schemas --- packages/documents/src/binder.ts | 5 ----- packages/documents/test/instances.test.ts | 11 ++++++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/documents/src/binder.ts b/packages/documents/src/binder.ts index 470719ee4e..63bad27566 100644 --- a/packages/documents/src/binder.ts +++ b/packages/documents/src/binder.ts @@ -136,11 +136,6 @@ function binderFromStore(store: DocumentStore): Binder { }; } - const schemaValidation = await schema.validate(); - if (schemaValidation.issues.length > 0) { - return { tag: "Err", content: schemaValidation.issues }; - } - const schemaRef = store.getDocumentRef(schema.handle); const document = InstanceMethods.newInstanceDocument({ _id: schemaRef.id, diff --git a/packages/documents/test/instances.test.ts b/packages/documents/test/instances.test.ts index 5c7e9de0fb..178a5d5327 100644 --- a/packages/documents/test/instances.test.ts +++ b/packages/documents/test/instances.test.ts @@ -172,15 +172,20 @@ describe("instance schema validation", () => { }, ); - test("an invalid schema does not create an instance", async () => { + test("an instance can be created from a partially valid schema", async () => { const binder = createBinder(); const schema = await binder.createNotebook(SimpleSchema, { title: "Invalid schema" }); + schema.add(Entity, { label: "Person" }); schema.add(Mapping, { label: "invalid", from: null, to: null }); - const issues = expectErr( + const instance = expectOk( await binder.createInstance(schema, { title: "Company instance" }), ); - expect(issues.length).toBeGreaterThan(0); + const validation = await instance.validate(); + + expect(validation.modelValidation.issues.length).toBeGreaterThan(0); + expect(validation.tables.map((table) => table.label)).toEqual(["Person"]); + expect(validation.issues).toEqual([]); }); test("change and validation subscriptions combine the schema and instance", async () => { From 2566b1343eb2d1408f5919000dcbba7a2b65aa68 Mon Sep 17 00:00:00 2001 From: Evan Patterson Date: Thu, 3 Sep 2026 02:42:29 -0700 Subject: [PATCH 26/83] UI for LLM conversation (#1436) * ENH: Minimal working UI for LLM conversation. Many features are missing, as is any styling. * BUILD: Update MDX dependencies and remove stale/unused ones. * ENH: Render Markdown in LLM messages as Solid JSX. * BUILD: Defer loading of Markdown-rendering pipeline until needed. * ENH: Basic UI for code executions and feedback requests in LLM convo. * ENH: Basic styling of user and LLM messages and user input form. * FIX: Ensure that `IconButton` predictably renders as `button`. Previously the behavior was subtly different depending on whether a tooltip was set. * ENH: Automatically scroll the LLM conversation pane as content grows. * FIX: Reduce re-rendering (flickering) over course of LLM turn. * ENH: Add `Shift + Enter` shortcut to send message to LLM. * CLEANUP: Simpler and more robust implementation of autoscroll. * CLEANUP: In LLM convo component, get store from binder (`useBinder`). * ENH: Show document that LLM convo depends on in top-right corner. * FIX: Don't create extra vertical space above overlong document title. An old issue that has been annoying me for a long time. * ENH: Display idle/running status of LLM. * ENH: Display notices from LLM turn, excluding request to retry. * BUILD: Upgrade to latest/final pre-1.0 version of `lucide-solid`. * ENH: Re-style LLM messages & code executions + make them collapsible. * ENH: Style tables in LLM message display in `booktabs` style. --- packages/documents/src/llm-conversation.ts | 23 +- packages/frontend/package.json | 17 +- packages/frontend/pnpm-lock.yaml | 1116 +++++------------ .../conversation_controller.ts | 154 +++ .../conversation_editor.module.css | 146 +++ .../llm_conversation/conversation_editor.tsx | 303 +++++ .../conversation_editor_stub.module.css | 3 - .../conversation_editor_stub.tsx | 10 - .../llm_conversation/conversation_info.tsx | 20 + .../frontend/src/llm_conversation/document.ts | 9 +- .../frontend/src/llm_conversation/index.ts | 3 +- .../live_doc_compatibility.ts | 8 +- .../markdown_message.module.css | 46 + .../src/llm_conversation/markdown_message.tsx | 24 + packages/frontend/src/page/document_head.tsx | 2 +- packages/frontend/src/page/document_menu.tsx | 2 +- packages/frontend/src/page/document_page.css | 7 + packages/frontend/src/page/document_page.tsx | 122 +- packages/frontend/vite.config.ts | 9 + packages/ui-components/package.json | 2 +- packages/ui-components/pnpm-lock.yaml | 12 +- packages/ui-components/src/code_view.tsx | 12 +- packages/ui-components/src/icon_button.tsx | 19 +- packages/ui-components/src/inline_input.css | 3 + 24 files changed, 1143 insertions(+), 929 deletions(-) create mode 100644 packages/frontend/src/llm_conversation/conversation_controller.ts create mode 100644 packages/frontend/src/llm_conversation/conversation_editor.module.css create mode 100644 packages/frontend/src/llm_conversation/conversation_editor.tsx delete mode 100644 packages/frontend/src/llm_conversation/conversation_editor_stub.module.css delete mode 100644 packages/frontend/src/llm_conversation/conversation_editor_stub.tsx create mode 100644 packages/frontend/src/llm_conversation/conversation_info.tsx create mode 100644 packages/frontend/src/llm_conversation/markdown_message.module.css create mode 100644 packages/frontend/src/llm_conversation/markdown_message.tsx diff --git a/packages/documents/src/llm-conversation.ts b/packages/documents/src/llm-conversation.ts index 5646a826be..7201b33a95 100644 --- a/packages/documents/src/llm-conversation.ts +++ b/packages/documents/src/llm-conversation.ts @@ -23,6 +23,7 @@ export interface LLMConversation { interactions(): readonly LLMInteraction[]; appendInteraction(interaction: LLMInteraction): void; + appendInteractions(interactions: readonly LLMInteraction[]): void; rejectPendingFeedbackRequests(): void; resolveFeedbackRequest( requestId: Uuid, @@ -46,6 +47,20 @@ export function llmConversationFromStore< return store.getDocumentView(handle) as Readonly; } + function appendInteractions(interactions: readonly LLMInteraction[]): void { + if (interactions.length === 0) { + return; + } + store.changeDocument(handle, (document) => { + for (const interaction of interactions) { + LLMConversationMethods.appendLLMInteraction( + document as LLMConversationDocument, + interaction, + ); + } + }); + } + return { handle, attachment, @@ -59,13 +74,9 @@ export function llmConversationFromStore< return currentDocument().interactions; }, appendInteraction(interaction: LLMInteraction): void { - store.changeDocument(handle, (document) => { - LLMConversationMethods.appendLLMInteraction( - document as LLMConversationDocument, - interaction, - ); - }); + appendInteractions([interaction]); }, + appendInteractions, rejectPendingFeedbackRequests(): void { store.changeDocument(handle, (document) => { LLMConversationMethods.rejectPendingFeedbackRequests( diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ff29293dd5..3adf3de09f 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -37,13 +37,14 @@ "@corvu/popover": "^0.2.0", "@corvu/resizable": "^0.2.5", "@corvu/tooltip": "^0.2.2", - "@mdx-js/rollup": "^3.1.0", - "@modular-forms/solid": "^0.24.1", + "@modular-forms/solid": "^0.25.1", "@qubit-rs/client": "^0.4.5", "@solid-primitives/context": "^0.3.1", "@solid-primitives/destructure": "^0.2.1", "@solid-primitives/event-listener": "^2.4.3", + "@solid-primitives/intersection-observer": "^2.2.5", "@solid-primitives/map": "^0.7.2", + "@solid-primitives/resize-observer": "^2.2.0", "@solid-primitives/timer": "^1.4.1", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", @@ -64,7 +65,7 @@ "html-escaper": "^3.0.3", "js-file-download": "^0.4.12", "katex": "^0.16.22", - "lucide-solid": "^0.471.0", + "lucide-solid": "^0.577.0", "openai": "^6.45.0", "prosemirror-commands": "^1.7.1", "prosemirror-inputrules": "^1.5.0", @@ -74,8 +75,12 @@ "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.10.4", "prosemirror-view": "^1.40.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "solid-firebase": "^0.3.0", "solid-js": "^1.9.10", + "solid-markdown": "^2.1.1", "tiny-invariant": "^1.3.3", "ts-pattern": "^5.2.0", "uuid": "^13.0.0" @@ -83,17 +88,13 @@ "devDependencies": { "@catcolab-dev-tools/oxlint-plugin-catcolab": "link:../../tools/oxlint-plugin-catcolab", "@catcolab-dev-tools/vite-plugin-monorepo-dedupe": "link:../../tools/vite-plugin-monorepo-dedupe", - "@mdx-js/mdx": "^2.3.0", + "@mdx-js/rollup": "^3.1.0", "@types/html-escaper": "^3.0.4", "@types/mdx": "^2.0.13", "@types/node": "^24.0.0", "catcolab-logics": "link:../logics", "eslint-plugin-solid": "^0.14.5", "happy-dom": "^20.0.0", - "rehype-katex": "^7.0.1", - "rehype-raw": "^7.0.0", - "remark-math": "^6.0.0", - "solid-mdx": "^0.0.7", "typed-css-modules": "^0.9.1", "typedoc": "^0.26.8", "typescript": "^6.0.3", diff --git a/packages/frontend/pnpm-lock.yaml b/packages/frontend/pnpm-lock.yaml index cbf96a638c..b4daceb9d7 100644 --- a/packages/frontend/pnpm-lock.yaml +++ b/packages/frontend/pnpm-lock.yaml @@ -47,12 +47,9 @@ importers: '@corvu/tooltip': specifier: ^0.2.2 version: 0.2.2(solid-js@1.9.10) - '@mdx-js/rollup': - specifier: ^3.1.0 - version: 3.1.0(acorn@8.16.0)(rollup@4.53.2) '@modular-forms/solid': - specifier: ^0.24.1 - version: 0.24.1(solid-js@1.9.10)(typescript@6.0.3) + specifier: ^0.25.1 + version: 0.25.1(solid-js@1.9.10)(typescript@6.0.3) '@qubit-rs/client': specifier: ^0.4.5 version: 0.4.5 @@ -65,9 +62,15 @@ importers: '@solid-primitives/event-listener': specifier: ^2.4.3 version: 2.4.3(solid-js@1.9.10) + '@solid-primitives/intersection-observer': + specifier: ^2.2.5 + version: 2.2.5(solid-js@1.9.10) '@solid-primitives/map': specifier: ^0.7.2 version: 0.7.2(solid-js@1.9.10) + '@solid-primitives/resize-observer': + specifier: ^2.2.0 + version: 2.2.0(solid-js@1.9.10) '@solid-primitives/timer': specifier: ^1.4.1 version: 1.4.2(solid-js@1.9.10) @@ -129,8 +132,8 @@ importers: specifier: ^0.16.22 version: 0.16.22 lucide-solid: - specifier: ^0.471.0 - version: 0.471.0(solid-js@1.9.10) + specifier: ^0.577.0 + version: 0.577.0(solid-js@1.9.10) openai: specifier: ^6.45.0 version: 6.45.0(ws@8.18.3) @@ -158,12 +161,24 @@ importers: prosemirror-view: specifier: ^1.40.1 version: 1.40.1 + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 solid-firebase: specifier: ^0.3.0 version: 0.3.0(firebase@10.14.0)(solid-js@1.9.10) solid-js: specifier: ^1.9.10 version: 1.9.10 + solid-markdown: + specifier: ^2.1.1 + version: 2.1.1(solid-js@1.9.10) tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -180,9 +195,9 @@ importers: '@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 + '@mdx-js/rollup': + specifier: ^3.1.0 + version: 3.1.0(acorn@8.16.0)(rollup@4.53.2) '@types/html-escaper': specifier: ^3.0.4 version: 3.0.4 @@ -198,18 +213,6 @@ importers: happy-dom: specifier: ^20.0.0 version: 20.11.2 - rehype-katex: - specifier: ^7.0.1 - version: 7.0.1 - rehype-raw: - specifier: ^7.0.0 - version: 7.0.0 - remark-math: - specifier: ^6.0.0 - version: 6.0.0 - solid-mdx: - specifier: ^0.0.7 - version: 0.0.7(solid-js@1.9.10)(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)) typed-css-modules: specifier: ^0.9.1 version: 0.9.1 @@ -589,6 +592,12 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -611,8 +620,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.4': @@ -898,9 +907,6 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@mdx-js/mdx@2.3.0': - resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} - '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} @@ -909,8 +915,8 @@ packages: peerDependencies: rollup: '>=2' - '@modular-forms/solid@0.24.1': - resolution: {integrity: sha512-xQKUoiRZKDjcJ6A+8aZcnRWePL0Ss8RWvlU0mxOggRtqswj8sqnZtXoKpKRxSWr/HODoWCjjze76FAH5jI0uwQ==} + '@modular-forms/solid@0.25.1': + resolution: {integrity: sha512-issNZ3xl4tj+1K7KT4dNTQaRq5SmVUXgUPeGTMjtrAzCeTnwM/u6vUxSuTY2bcMw4GzTxreFXLx1xMMhrFkt0A==} peerDependencies: solid-js: ^1.3.1 @@ -1118,8 +1124,13 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/event-listener@2.4.5': - resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==} + '@solid-primitives/event-listener@2.4.6': + resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/intersection-observer@2.2.5': + resolution: {integrity: sha512-mhlQETWaS4jjVBBmfFUsUSih5eSqVdYD7bDAjwmhxEAy1bydKTiaCn8g3ARQB1jM/92qyxTcOvTCpPoMIOkaPA==} peerDependencies: solid-js: ^1.6.12 @@ -1133,18 +1144,18 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/resize-observer@2.1.5': - resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==} + '@solid-primitives/resize-observer@2.2.0': + resolution: {integrity: sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/rootless@1.5.3': - resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==} + '@solid-primitives/rootless@1.5.4': + resolution: {integrity: sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/static-store@0.1.3': - resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==} + '@solid-primitives/static-store@0.1.4': + resolution: {integrity: sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==} peerDependencies: solid-js: ^1.6.12 @@ -1163,8 +1174,8 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/utils@6.4.0': - resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} + '@solid-primitives/utils@6.4.1': + resolution: {integrity: sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==} peerDependencies: solid-js: ^1.6.12 @@ -1181,9 +1192,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@types/acorn@4.0.6': - resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1217,9 +1225,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/hast@2.3.10': - resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1232,9 +1237,6 @@ packages: '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} - '@types/mdast@3.0.15': - resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1414,8 +1416,8 @@ packages: bind-event-listener@3.0.0: resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} @@ -1575,10 +1577,6 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@5.2.0: - resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} - engines: {node: '>=0.3.1'} - eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1639,6 +1637,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-plugin-solid@0.14.5: resolution: {integrity: sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ==} engines: {node: '>=18.0.0'} @@ -1688,36 +1690,21 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-util-attach-comments@2.1.1: - resolution: {integrity: sha512-+5Ba/xGGS6mnwFbXIuQiDPTbuTxuMCooq3arVv7gPZtYpjp+VXH/NkHAP35OOefPhNG/UGqU3vt/LTABwcHX0w==} - estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} - estree-util-build-jsx@2.2.2: - resolution: {integrity: sha512-m56vOXcOBuaF+Igpb9OPAy7f9w9OIkb5yhjsZuaPm7HoGi4oTOQi0h2+yZ+AtKklYFZ+rPC4n0wYCJCEU1ONqg==} - estree-util-build-jsx@3.0.1: resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} - estree-util-is-identifier-name@2.1.0: - resolution: {integrity: sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ==} - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} estree-util-scope@1.0.0: resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} - estree-util-to-js@1.2.0: - resolution: {integrity: sha512-IzU74r1PK5IMMGZXUVZbmiu4A1uhiPgW5hm1GjcOfr4ZzHaMPpLNJjR7HjXiIOzi25nZDrgFTobHTkV5Q6ITjA==} - estree-util-to-js@2.0.0: resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} - estree-util-visit@1.2.1: - resolution: {integrity: sha512-xbgqcrkIVbIG+lI/gzbvd9SGTJL4zqJKBFttUl5pP27KhAjtMKbX/mQXJ7qgyXpMgVy/zvpm0xoQQaGL8OloOw==} - estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} @@ -1792,8 +1779,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} @@ -1862,12 +1849,6 @@ packages: hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} - hast-util-raw@9.0.4: - resolution: {integrity: sha512-LHE65TD2YiNsHD3YuXcKPHXPLuYh/gjp12mOfU8jxSrm1f/yJpsb0F/KKljS6U9LJoP0Ux+tCe8iJ2AsPzTdgA==} - - hast-util-to-estree@2.3.3: - resolution: {integrity: sha512-ihhPIUPxN0v0w6M5+IiAZZrn0LH2uZomeWwhn7uP7avZC6TE7lIiEh2yBMPr5+zi1aUCXq6VoYRgs2Bw9xmycQ==} - hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -1877,15 +1858,9 @@ packages: hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} - hast-util-to-parse5@8.0.0: - resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} - hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} - hast-util-whitespace@2.0.1: - resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} - hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -1951,10 +1926,6 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-buffer@2.0.5: - resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} - engines: {node: '>=4'} - is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -1985,9 +1956,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-reference@3.0.2: - resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} - is-there@4.5.1: resolution: {integrity: sha512-vIZ7HTXAoRoIwYSsTnxb0sg9L6rth+JOulNcavsbskQkCIWoSM2cjFOWZs4wGziGZER+Xgs/HXiCQZgiL8ppxQ==} @@ -2020,8 +1988,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsesc@3.0.2: @@ -2053,10 +2021,6 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - known-css-properties@0.30.0: resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==} @@ -2089,8 +2053,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-solid@0.471.0: - resolution: {integrity: sha512-QkoM5j49z6O6Ek7k6XtMfQtIwCFbBJeyUw98bF1F3fWC9+oIVKfGZRNdweBnW6IaLPKvSE2cyTU4gkGaKZ+iNA==} + lucide-solid@0.577.0: + resolution: {integrity: sha512-r/rsauBlyNjFlUhXCkD544tOH1GgcFFupw9oP2zZT4BiFkHoO3MTr12QfKBrS5zCRIhktc/qY2tRr925hFlNuQ==} peerDependencies: solid-js: ^1.4.7 @@ -2100,10 +2064,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - markdown-extensions@1.1.1: - resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} - engines: {node: '>=0.10.0'} - markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -2112,63 +2072,57 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true - mdast-util-definitions@5.1.2: - resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - mdast-util-from-markdown@1.3.1: - resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} - mdast-util-mdx-expression@1.3.2: - resolution: {integrity: sha512-xIPmR5ReJDu/DHH1OoIT1HkuybIfRGYRywC+gJtI7qHjCJp/M9jrmBEJW22O8lskDWm562BX2W8TiAwRTb0rKA==} - mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - mdast-util-mdx-jsx@2.1.4: - resolution: {integrity: sha512-DtMn9CmVhVzZx3f+optVDF8yFgQVt7FghCRNdlIaS3X5Bnym3hZwPbg/XW86vdpKjlc1PVj26SpnLGeJBXD3JA==} - mdast-util-mdx-jsx@3.2.0: resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} - mdast-util-mdx@2.0.1: - resolution: {integrity: sha512-38w5y+r8nyKlGvNjSEqWrhG0w5PmnRA+wnBvm+ulYCct7nsGYhFVb0lljS9bQav4psDAS1eGkP2LMVcZBi/aqw==} - mdast-util-mdx@3.0.0: resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} - mdast-util-mdxjs-esm@1.3.1: - resolution: {integrity: sha512-SXqglS0HrEvSdUEfoXFtcg7DRl7S2cwOXc7jkuusG472Mmjag34DUDeOJUZtl+BVnyeO1frIgVpHlNRWc2gk/w==} - mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - mdast-util-phrasing@3.0.1: - resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} - mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@12.3.0: - resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==} - mdast-util-to-hast@13.2.0: resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} - mdast-util-to-markdown@1.5.0: - resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} - mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - mdast-util-to-string@3.2.0: - resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==} - mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} @@ -2179,174 +2133,111 @@ packages: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} - micromark-core-commonmark@1.1.0: - resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==} - micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} - micromark-extension-mdx-expression@1.0.8: - resolution: {integrity: sha512-zZpeQtc5wfWKdzDsHRBY003H2Smg+PUi2REhqgIhdzAa5xonhP03FcXxqFSerFiNUr5AWmHpaNPQTBVOS4lrXw==} - micromark-extension-mdx-expression@3.0.1: resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} - micromark-extension-mdx-jsx@1.0.5: - resolution: {integrity: sha512-gPH+9ZdmDflbu19Xkb8+gheqEDqkSpdCEubQyxuz/Hn8DOXiXvrXeikOoBA71+e8Pfi0/UYmU3wW3H58kr7akA==} - micromark-extension-mdx-jsx@3.0.2: resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} - micromark-extension-mdx-md@1.0.1: - resolution: {integrity: sha512-7MSuj2S7xjOQXAjjkbjBsHkMtb+mDGVW6uI2dBL9snOBCbZmoNgDAeZ0nSn9j3T42UE/g2xVNMn18PJxZvkBEA==} - micromark-extension-mdx-md@2.0.0: resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} - micromark-extension-mdxjs-esm@1.0.5: - resolution: {integrity: sha512-xNRBw4aoURcyz/S69B19WnZAkWJMxHMT5hE36GtDAyhoyn/8TuAeqjFJQlwk+MKQsUD7b3l7kFX+vlfVWgcX1w==} - micromark-extension-mdxjs-esm@3.0.0: resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} - micromark-extension-mdxjs@1.0.1: - resolution: {integrity: sha512-7YA7hF6i5eKOfFUzZ+0z6avRG52GpWR8DL+kN47y3f2KhxbBZMhmxe7auOeaTBrW2DenbbZTf1ea9tA2hDpC2Q==} - micromark-extension-mdxjs@3.0.0: resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} - micromark-factory-destination@1.1.0: - resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==} - micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - micromark-factory-label@1.1.0: - resolution: {integrity: sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==} - micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - micromark-factory-mdx-expression@1.0.9: - resolution: {integrity: sha512-jGIWzSmNfdnkJq05c7b0+Wv0Kfz3NJ3N4cBjnbO4zjXIlxJr+f8lk+5ZmwFvqdAbUy2q6B5rCY//g0QAAaXDWA==} - micromark-factory-mdx-expression@2.0.3: resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} - micromark-factory-space@1.1.0: - resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} - micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - micromark-factory-title@1.1.0: - resolution: {integrity: sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==} - micromark-factory-title@2.0.1: resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - micromark-factory-whitespace@1.1.0: - resolution: {integrity: sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==} - micromark-factory-whitespace@2.0.1: resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - micromark-util-character@1.2.0: - resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} - micromark-util-character@2.1.0: resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} - micromark-util-chunked@1.1.0: - resolution: {integrity: sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==} - micromark-util-chunked@2.0.1: resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - micromark-util-classify-character@1.1.0: - resolution: {integrity: sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==} - micromark-util-classify-character@2.0.1: resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - micromark-util-combine-extensions@1.1.0: - resolution: {integrity: sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==} - micromark-util-combine-extensions@2.0.1: resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - micromark-util-decode-numeric-character-reference@1.1.0: - resolution: {integrity: sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==} - micromark-util-decode-numeric-character-reference@2.0.2: resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - micromark-util-decode-string@1.1.0: - resolution: {integrity: sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==} - micromark-util-decode-string@2.0.1: resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - micromark-util-encode@1.1.0: - resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==} - micromark-util-encode@2.0.0: resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} - micromark-util-events-to-acorn@1.2.3: - resolution: {integrity: sha512-ij4X7Wuc4fED6UoLWkmo0xJQhsktfNh1J0m8g4PbIMPlx+ek/4YdW5mvbye8z/aZvAPUoxgXHrwVlXAPKMRp1w==} - micromark-util-events-to-acorn@2.0.3: resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} - micromark-util-html-tag-name@1.2.0: - resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==} - micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - micromark-util-normalize-identifier@1.1.0: - resolution: {integrity: sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==} - micromark-util-normalize-identifier@2.0.1: resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - micromark-util-resolve-all@1.1.0: - resolution: {integrity: sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==} - micromark-util-resolve-all@2.0.1: resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - micromark-util-sanitize-uri@1.2.0: - resolution: {integrity: sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==} - micromark-util-sanitize-uri@2.0.0: resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} - micromark-util-subtokenize@1.1.0: - resolution: {integrity: sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==} - micromark-util-subtokenize@2.1.0: resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - micromark-util-symbol@1.1.0: - resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} - micromark-util-symbol@2.0.0: resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} - micromark-util-types@1.1.0: - resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} - micromark-util-types@2.0.0: resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} - micromark@3.2.0: - resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} - micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} @@ -2374,10 +2265,6 @@ packages: engines: {node: '>=10'} hasBin: true - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2478,9 +2365,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} @@ -2624,33 +2508,27 @@ packages: rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} - rehype-raw@7.0.0: - resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} - rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} - remark-mdx@2.3.0: - resolution: {integrity: sha512-g53hMkpM0I98MU266IzDFMrTD980gNF3BJnkyFcmN+dD873mQeD5rdMO3Y2X+x8umQfbSE0PcoEDl7ledSA+2g==} - remark-mdx@3.1.0: resolution: {integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==} - remark-parse@10.0.2: - resolution: {integrity: sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==} - remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - remark-rehype@10.1.0: - resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} - remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2667,10 +2545,6 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2730,11 +2604,11 @@ packages: solid-js@1.9.10: resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==} - solid-mdx@0.0.7: - resolution: {integrity: sha512-dYKGOu5ZiaX3sfEMZYtfyXm30u33kF+T/pr67CMeyHzENDkWD3st4XEJ12Akp0J0PG9jzyHe5sAAKEXSnEcDEw==} + solid-markdown@2.1.1: + resolution: {integrity: sha512-ly/5XbvCaFW5i6ds5GDU7+X4tRUfqa8Z7YUs+Ge18KMYXoFJGSlfJU91aW+tjz7bMSfxQrKcMLwB5z2lgErWIA==} + engines: {node: '>=18', pnpm: '>=9.15.9'} peerDependencies: - solid-js: ^1.2.6 - vite: '*' + solid-js: ^1.6.0 solid-presence@0.2.0: resolution: {integrity: sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA==} @@ -2794,8 +2668,8 @@ packages: style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} - style-to-object@0.4.4: - resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + style-to-object@0.3.0: + resolution: {integrity: sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==} style-to-object@1.0.9: resolution: {integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==} @@ -2890,45 +2764,27 @@ packages: resolution: {integrity: sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==} engines: {node: '>=18.17'} - unified@10.1.2: - resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} - unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - unist-util-generated@2.0.1: - resolution: {integrity: sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==} - unist-util-is@5.2.1: resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} - unist-util-position-from-estree@1.1.2: - resolution: {integrity: sha512-poZa0eXpS+/XpoQwGwl79UUdea4ol2ZuCYguVaJS4qzIOMDzbqz8a3erUCOmubSZkaOuGamb3tX790iwOIROww==} - unist-util-position-from-estree@2.0.0: resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} - unist-util-position@4.0.4: - resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} - unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - unist-util-remove-position@4.0.2: - resolution: {integrity: sha512-TkBb0HABNmxzAcfLf4qsIbFbaPDvMO6wa3b3j4VcEzFVaw1LBKwnW4/sRJ/atSLSzoIg41JWEdnE7N6DIhGDGQ==} - unist-util-remove-position@5.0.0: resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} - unist-util-stringify-position@3.0.3: - resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} - unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -2965,13 +2821,8 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true - uvu@0.5.6: - resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} - engines: {node: '>=8'} - hasBin: true - - valibot@1.0.0-beta.0: - resolution: {integrity: sha512-Q/oine+NPMXdIy3vwluw0vidHLk0mTPUQBRHc+EHZXnEWF3KzLx1YLsVHPVrgHaMGRfV58P9eGOgxJvi0a059w==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -2984,15 +2835,9 @@ packages: vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} - vfile-message@3.1.4: - resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} - vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} - vfile@5.3.7: - resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} - vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} @@ -3553,6 +3398,11 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -3576,7 +3426,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.15.0 debug: 4.4.3 @@ -3584,7 +3434,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4005,28 +3855,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mdx-js/mdx@2.3.0': - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/mdx': 2.0.13 - estree-util-build-jsx: 2.2.2 - estree-util-is-identifier-name: 2.1.0 - estree-util-to-js: 1.2.0 - estree-walker: 3.0.3 - hast-util-to-estree: 2.3.3 - markdown-extensions: 1.1.1 - periscopic: 3.1.0 - remark-mdx: 2.3.0 - remark-parse: 10.0.2 - remark-rehype: 10.1.0 - unified: 10.1.2 - unist-util-position-from-estree: 1.1.2 - unist-util-stringify-position: 3.0.3 - unist-util-visit: 4.1.2 - vfile: 5.3.7 - transitivePeerDependencies: - - supports-color - '@mdx-js/mdx@3.1.0(acorn@8.16.0)': dependencies: '@types/estree': 1.0.6 @@ -4068,10 +3896,10 @@ snapshots: - acorn - supports-color - '@modular-forms/solid@0.24.1(solid-js@1.9.10)(typescript@6.0.3)': + '@modular-forms/solid@0.25.1(solid-js@1.9.10)(typescript@6.0.3)': dependencies: solid-js: 1.9.10 - valibot: 1.0.0-beta.0(typescript@6.0.3) + valibot: 1.4.2(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -4222,9 +4050,14 @@ 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)': + '@solid-primitives/event-listener@2.4.6(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/intersection-observer@2.2.5(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) solid-js: 1.9.10 '@solid-primitives/map@0.7.2(solid-js@1.9.10)': @@ -4234,25 +4067,25 @@ snapshots: '@solid-primitives/refs@1.1.3(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/resize-observer@2.1.5(solid-js@1.9.10)': + '@solid-primitives/resize-observer@2.2.0(solid-js@1.9.10)': dependencies: - '@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-primitives/event-listener': 2.4.6(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.4(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.4(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/rootless@1.5.3(solid-js@1.9.10)': + '@solid-primitives/rootless@1.5.4(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/static-store@0.1.3(solid-js@1.9.10)': + '@solid-primitives/static-store@0.1.4(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) solid-js: 1.9.10 '@solid-primitives/timer@1.4.2(solid-js@1.9.10)': @@ -4261,14 +4094,14 @@ snapshots: '@solid-primitives/trigger@1.2.2(solid-js@1.9.10)': dependencies: - '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.1(solid-js@1.9.10) 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.4.0(solid-js@1.9.10)': + '@solid-primitives/utils@6.4.1(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 @@ -4282,10 +4115,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@types/acorn@4.0.6': - dependencies: - '@types/estree': 1.0.6 - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.25.7 @@ -4327,10 +4156,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/hast@2.3.10': - dependencies: - '@types/unist': 2.0.11 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4341,10 +4166,6 @@ snapshots: '@types/katex@0.16.7': {} - '@types/mdast@3.0.15': - dependencies: - '@types/unist': 2.0.11 - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -4535,7 +4356,7 @@ snapshots: bind-event-listener@3.0.0: {} - brace-expansion@1.1.14: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -4696,14 +4517,12 @@ snapshots: diff-sequences@29.6.3: {} - diff@5.2.0: {} - eastasianwidth@0.2.0: {} 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.1.3(solid-js@1.9.10) - '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.10) + '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.10) echarts: 6.1.0 solid-js: 1.9.10 @@ -4775,6 +4594,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-solid@0.14.5(eslint@9.39.4)(typescript@6.0.3): dependencies: '@typescript-eslint/utils': 8.57.0(eslint@9.39.4)(typescript@6.0.3) @@ -4801,12 +4622,12 @@ snapshots: eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.6 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -4854,20 +4675,10 @@ snapshots: estraverse@5.3.0: {} - estree-util-attach-comments@2.1.1: - dependencies: - '@types/estree': 1.0.6 - estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.6 - estree-util-build-jsx@2.2.2: - dependencies: - '@types/estree-jsx': 1.0.5 - estree-util-is-identifier-name: 2.1.0 - estree-walker: 3.0.3 - estree-util-build-jsx@3.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -4875,8 +4686,6 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-walker: 3.0.3 - estree-util-is-identifier-name@2.1.0: {} - estree-util-is-identifier-name@3.0.0: {} estree-util-scope@1.0.0: @@ -4884,23 +4693,12 @@ snapshots: '@types/estree': 1.0.6 devlop: 1.1.0 - estree-util-to-js@1.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - astring: 1.9.0 - source-map: 0.7.4 - estree-util-to-js@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 astring: 1.9.0 source-map: 0.7.4 - estree-util-visit@1.2.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/unist': 2.0.11 - estree-util-visit@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -4988,10 +4786,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.4: {} foreground-child@3.3.0: dependencies: @@ -5084,42 +4882,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hast-util-raw@9.0.4: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.2.0 - hast-util-from-parse5: 8.0.1 - hast-util-to-parse5: 8.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.0 - parse5: 7.1.2 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - - hast-util-to-estree@2.3.3: - dependencies: - '@types/estree': 1.0.6 - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/unist': 2.0.11 - comma-separated-tokens: 2.0.3 - estree-util-attach-comments: 2.1.1 - estree-util-is-identifier-name: 2.1.0 - hast-util-whitespace: 2.0.1 - mdast-util-mdx-expression: 1.3.2 - mdast-util-mdxjs-esm: 1.3.1 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - style-to-object: 0.4.4 - unist-util-position: 4.0.4 - zwitch: 2.0.4 - transitivePeerDependencies: - - supports-color - hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.6 @@ -5175,16 +4937,6 @@ snapshots: transitivePeerDependencies: - supports-color - hast-util-to-parse5@8.0.0: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -5192,8 +4944,6 @@ snapshots: hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 - hast-util-whitespace@2.0.1: {} - hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -5256,8 +5006,6 @@ snapshots: dependencies: binary-extensions: 2.3.0 - is-buffer@2.0.5: {} - is-decimal@2.0.1: {} is-extglob@2.1.1: {} @@ -5278,10 +5026,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-reference@3.0.2: - dependencies: - '@types/estree': 1.0.6 - is-there@4.5.1: {} is-what@4.1.16: {} @@ -5311,7 +5055,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -5335,8 +5079,6 @@ snapshots: dependencies: json-buffer: 3.0.1 - kleur@4.1.5: {} - known-css-properties@0.30.0: {} levn@0.4.1: @@ -5366,7 +5108,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-solid@0.471.0(solid-js@1.9.10): + lucide-solid@0.577.0(solid-js@1.9.10): dependencies: solid-js: 1.9.10 @@ -5376,8 +5118,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - markdown-extensions@1.1.1: {} - markdown-extensions@2.0.0: {} markdown-it@14.1.0: @@ -5389,28 +5129,14 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - mdast-util-definitions@5.1.2: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - unist-util-visit: 4.1.2 + markdown-table@3.0.4: {} - mdast-util-from-markdown@1.3.1: + mdast-util-find-and-replace@3.0.2: dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - decode-named-character-reference: 1.0.2 - mdast-util-to-string: 3.2.0 - micromark: 3.2.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-decode-string: 1.1.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-stringify-position: 3.0.3 - uvu: 0.5.6 - transitivePeerDependencies: - - supports-color + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 mdast-util-from-markdown@2.0.2: dependencies: @@ -5429,53 +5155,83 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-math@3.0.0: + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.1.0: dependencies: - '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - longest-streak: 3.1.0 mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 - unist-util-remove-position: 5.0.0 + micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@1.3.2: + mdast-util-gfm-strikethrough@2.0.0: dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: dependencies: - '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 + longest-streak: 3.1.0 mdast-util-from-markdown: 2.0.2 mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@2.1.4: + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - ccount: 2.0.1 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 - parse-entities: 4.0.1 - stringify-entities: 4.0.4 - unist-util-remove-position: 4.0.2 - unist-util-stringify-position: 3.0.3 - vfile-message: 3.1.4 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5496,16 +5252,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@2.0.1: - dependencies: - mdast-util-from-markdown: 1.3.1 - mdast-util-mdx-expression: 1.3.2 - mdast-util-mdx-jsx: 2.1.4 - mdast-util-mdxjs-esm: 1.3.1 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - mdast-util-mdx@3.0.0: dependencies: mdast-util-from-markdown: 2.0.2 @@ -5516,16 +5262,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@1.3.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -5537,27 +5273,11 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-phrasing@3.0.1: - dependencies: - '@types/mdast': 3.0.15 - unist-util-is: 5.2.1 - mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 unist-util-is: 6.0.0 - mdast-util-to-hast@12.3.0: - dependencies: - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-definitions: 5.1.2 - micromark-util-sanitize-uri: 1.2.0 - trim-lines: 3.0.1 - unist-util-generated: 2.0.1 - unist-util-position: 4.0.4 - unist-util-visit: 4.1.2 - mdast-util-to-hast@13.2.0: dependencies: '@types/hast': 3.0.4 @@ -5570,17 +5290,6 @@ snapshots: unist-util-visit: 5.0.0 vfile: 6.0.3 - mdast-util-to-markdown@1.5.0: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - longest-streak: 3.1.0 - mdast-util-phrasing: 3.0.1 - mdast-util-to-string: 3.2.0 - micromark-util-decode-string: 1.1.0 - unist-util-visit: 4.1.2 - zwitch: 2.0.4 - mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -5593,10 +5302,6 @@ snapshots: unist-util-visit: 5.0.0 zwitch: 2.0.4 - mdast-util-to-string@3.2.0: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -5607,25 +5312,6 @@ snapshots: dependencies: is-what: 4.1.16 - micromark-core-commonmark@1.1.0: - dependencies: - decode-named-character-reference: 1.0.2 - micromark-factory-destination: 1.1.0 - micromark-factory-label: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-factory-title: 1.1.0 - micromark-factory-whitespace: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-chunked: 1.1.0 - micromark-util-classify-character: 1.1.0 - micromark-util-html-tag-name: 1.2.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-subtokenize: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.0.2 @@ -5645,6 +5331,64 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.0 + micromark-extension-math@3.1.0: dependencies: '@types/katex': 0.16.7 @@ -5655,17 +5399,6 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-extension-mdx-expression@1.0.8: - dependencies: - '@types/estree': 1.0.6 - micromark-factory-mdx-expression: 1.0.9 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-events-to-acorn: 1.2.3 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.6 @@ -5677,19 +5410,6 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-extension-mdx-jsx@1.0.5: - dependencies: - '@types/acorn': 4.0.6 - '@types/estree': 1.0.6 - estree-util-is-identifier-name: 2.1.0 - micromark-factory-mdx-expression: 1.0.9 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - vfile-message: 3.1.4 - micromark-extension-mdx-jsx@3.0.2: dependencies: '@types/estree': 1.0.6 @@ -5703,26 +5423,10 @@ snapshots: micromark-util-types: 2.0.0 vfile-message: 4.0.2 - micromark-extension-mdx-md@1.0.1: - dependencies: - micromark-util-types: 1.1.0 - micromark-extension-mdx-md@2.0.0: dependencies: micromark-util-types: 2.0.0 - micromark-extension-mdxjs-esm@1.0.5: - dependencies: - '@types/estree': 1.0.6 - micromark-core-commonmark: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-events-to-acorn: 1.2.3 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-position-from-estree: 1.1.2 - uvu: 0.5.6 - vfile-message: 3.1.4 - micromark-extension-mdxjs-esm@3.0.0: dependencies: '@types/estree': 1.0.6 @@ -5735,17 +5439,6 @@ snapshots: unist-util-position-from-estree: 2.0.0 vfile-message: 4.0.2 - micromark-extension-mdxjs@1.0.1: - dependencies: - acorn: 8.12.1 - acorn-jsx: 5.3.2(acorn@8.12.1) - micromark-extension-mdx-expression: 1.0.8 - micromark-extension-mdx-jsx: 1.0.5 - micromark-extension-mdx-md: 1.0.1 - micromark-extension-mdxjs-esm: 1.0.5 - micromark-util-combine-extensions: 1.1.0 - micromark-util-types: 1.1.0 - micromark-extension-mdxjs@3.0.0: dependencies: acorn: 8.12.1 @@ -5757,25 +5450,12 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.0 - micromark-factory-destination@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.0 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-factory-label@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 @@ -5783,17 +5463,6 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-factory-mdx-expression@1.0.9: - dependencies: - '@types/estree': 1.0.6 - micromark-util-character: 1.2.0 - micromark-util-events-to-acorn: 1.2.3 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-position-from-estree: 1.1.2 - uvu: 0.5.6 - vfile-message: 3.1.4 - micromark-factory-mdx-expression@2.0.3: dependencies: '@types/estree': 1.0.6 @@ -5806,23 +5475,11 @@ snapshots: unist-util-position-from-estree: 2.0.0 vfile-message: 4.0.2 - micromark-factory-space@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-types: 1.1.0 - micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.0 micromark-util-types: 2.0.0 - micromark-factory-title@1.1.0: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-factory-title@2.0.1: dependencies: micromark-factory-space: 2.0.1 @@ -5830,13 +5487,6 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-factory-whitespace@1.1.0: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-factory-whitespace@2.0.1: dependencies: micromark-factory-space: 2.0.1 @@ -5844,61 +5494,30 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-util-character@1.2.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-util-character@2.1.0: dependencies: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-util-chunked@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-chunked@2.0.1: dependencies: micromark-util-symbol: 2.0.0 - micromark-util-classify-character@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - micromark-util-classify-character@2.0.1: dependencies: micromark-util-character: 2.1.0 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-util-combine-extensions@1.1.0: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-types: 1.1.0 - micromark-util-combine-extensions@2.0.1: dependencies: micromark-util-chunked: 2.0.1 micromark-util-types: 2.0.0 - micromark-util-decode-numeric-character-reference@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-decode-numeric-character-reference@2.0.2: dependencies: micromark-util-symbol: 2.0.0 - micromark-util-decode-string@1.1.0: - dependencies: - decode-named-character-reference: 1.0.2 - micromark-util-character: 1.2.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-decode-string@2.0.1: dependencies: decode-named-character-reference: 1.0.2 @@ -5906,21 +5525,8 @@ snapshots: micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.0 - micromark-util-encode@1.1.0: {} - micromark-util-encode@2.0.0: {} - micromark-util-events-to-acorn@1.2.3: - dependencies: - '@types/acorn': 4.0.6 - '@types/estree': 1.0.6 - '@types/unist': 2.0.11 - estree-util-visit: 1.2.1 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - vfile-message: 3.1.4 - micromark-util-events-to-acorn@2.0.3: dependencies: '@types/estree': 1.0.6 @@ -5931,45 +5537,22 @@ snapshots: micromark-util-types: 2.0.0 vfile-message: 4.0.2 - micromark-util-html-tag-name@1.2.0: {} - micromark-util-html-tag-name@2.0.1: {} - micromark-util-normalize-identifier@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-normalize-identifier@2.0.1: dependencies: micromark-util-symbol: 2.0.0 - micromark-util-resolve-all@1.1.0: - dependencies: - micromark-util-types: 1.1.0 - micromark-util-resolve-all@2.0.1: dependencies: micromark-util-types: 2.0.0 - micromark-util-sanitize-uri@1.2.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-encode: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-sanitize-uri@2.0.0: dependencies: micromark-util-character: 2.1.0 micromark-util-encode: 2.0.0 micromark-util-symbol: 2.0.0 - micromark-util-subtokenize@1.1.0: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - micromark-util-subtokenize@2.1.0: dependencies: devlop: 1.1.0 @@ -5977,36 +5560,10 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 - micromark-util-symbol@1.1.0: {} - micromark-util-symbol@2.0.0: {} - micromark-util-types@1.1.0: {} - micromark-util-types@2.0.0: {} - micromark@3.2.0: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.3 - decode-named-character-reference: 1.0.2 - micromark-core-commonmark: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-chunked: 1.1.0 - micromark-util-combine-extensions: 1.1.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-encode: 1.1.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-sanitize-uri: 1.2.0 - micromark-util-subtokenize: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - transitivePeerDependencies: - - supports-color - micromark@4.0.2: dependencies: '@types/debug': 4.1.12 @@ -6035,7 +5592,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.18 minimatch@9.0.5: dependencies: @@ -6049,8 +5606,6 @@ snapshots: mkdirp@3.0.1: {} - mri@1.2.0: {} - ms@2.1.3: {} nanoid@3.3.11: {} @@ -6131,12 +5686,6 @@ snapshots: pathe@2.0.3: {} - periscopic@3.1.0: - dependencies: - '@types/estree': 1.0.6 - estree-walker: 3.0.3 - is-reference: 3.0.2 - picocolors@1.1.0: {} picocolors@1.1.1: {} @@ -6325,12 +5874,6 @@ snapshots: unist-util-visit-parents: 6.0.1 vfile: 6.0.3 - rehype-raw@7.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-raw: 9.0.4 - vfile: 6.0.3 - rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.6 @@ -6339,19 +5882,23 @@ snapshots: transitivePeerDependencies: - supports-color - remark-math@6.0.0: + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-math: 3.0.0 - micromark-extension-math: 3.1.0 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@2.3.0: + remark-math@6.0.0: dependencies: - mdast-util-mdx: 2.0.1 - micromark-extension-mdxjs: 1.0.1 + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 transitivePeerDependencies: - supports-color @@ -6362,14 +5909,6 @@ snapshots: transitivePeerDependencies: - supports-color - remark-parse@10.0.2: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-from-markdown: 1.3.1 - unified: 10.1.2 - transitivePeerDependencies: - - supports-color - remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -6379,13 +5918,6 @@ snapshots: transitivePeerDependencies: - supports-color - remark-rehype@10.1.0: - dependencies: - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-to-hast: 12.3.0 - unified: 10.1.2 - remark-rehype@11.1.2: dependencies: '@types/hast': 3.0.4 @@ -6394,6 +5926,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-directory@2.1.1: {} resolve-from@4.0.0: {} @@ -6428,10 +5966,6 @@ snapshots: rope-sequence@1.3.4: {} - sade@1.8.1: - dependencies: - mri: 1.2.0 - safe-buffer@5.2.1: {} semver@6.3.1: {} @@ -6484,10 +6018,20 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) - solid-mdx@0.0.7(solid-js@1.9.10)(vite@7.2.2(@types/node@24.10.0)(yaml@2.5.1)): + solid-markdown@2.1.1(solid-js@1.9.10): dependencies: + comma-separated-tokens: 2.0.3 + property-information: 6.5.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 solid-js: 1.9.10 - vite: 7.2.2(@types/node@24.10.0)(yaml@2.5.1) + space-separated-tokens: 2.0.2 + style-to-object: 0.3.0 + unified: 11.0.5 + unist-util-visit: 4.1.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color solid-presence@0.2.0(solid-js@1.9.10): dependencies: @@ -6549,7 +6093,7 @@ snapshots: dependencies: style-to-object: 1.0.9 - style-to-object@0.4.4: + style-to-object@0.3.0: dependencies: inline-style-parser: 0.1.1 @@ -6643,16 +6187,6 @@ snapshots: undici@6.19.7: {} - unified@10.1.2: - dependencies: - '@types/unist': 2.0.11 - bail: 2.0.2 - extend: 3.0.2 - is-buffer: 2.0.5 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 5.3.7 - unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -6668,8 +6202,6 @@ snapshots: '@types/unist': 3.0.3 unist-util-is: 6.0.0 - unist-util-generated@2.0.1: {} - unist-util-is@5.2.1: dependencies: '@types/unist': 2.0.11 @@ -6678,36 +6210,19 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-position-from-estree@1.1.2: - dependencies: - '@types/unist': 2.0.11 - unist-util-position-from-estree@2.0.0: dependencies: '@types/unist': 3.0.3 - unist-util-position@4.0.4: - dependencies: - '@types/unist': 2.0.11 - unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 - unist-util-remove-position@4.0.2: - dependencies: - '@types/unist': 2.0.11 - unist-util-visit: 4.1.2 - unist-util-remove-position@5.0.0: dependencies: '@types/unist': 3.0.3 unist-util-visit: 5.0.0 - unist-util-stringify-position@3.0.3: - dependencies: - '@types/unist': 2.0.11 - unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -6750,14 +6265,7 @@ snapshots: uuid@9.0.1: {} - uvu@0.5.6: - dependencies: - dequal: 2.0.3 - diff: 5.2.0 - kleur: 4.1.5 - sade: 1.8.1 - - valibot@1.0.0-beta.0(typescript@6.0.3): + valibot@1.4.2(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -6768,23 +6276,11 @@ snapshots: '@types/unist': 3.0.3 vfile: 6.0.3 - vfile-message@3.1.4: - dependencies: - '@types/unist': 2.0.11 - unist-util-stringify-position: 3.0.3 - vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 - vfile@5.3.7: - dependencies: - '@types/unist': 2.0.11 - is-buffer: 2.0.5 - unist-util-stringify-position: 3.0.3 - vfile-message: 3.1.4 - vfile@6.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/packages/frontend/src/llm_conversation/conversation_controller.ts b/packages/frontend/src/llm_conversation/conversation_controller.ts new file mode 100644 index 0000000000..553d826d0d --- /dev/null +++ b/packages/frontend/src/llm_conversation/conversation_controller.ts @@ -0,0 +1,154 @@ +import type { Accessor } from "solid-js"; +import { createStore } from "solid-js/store"; + +import { LLMConversation } from "catcolab-document-methods"; +import { LLMInteraction } from "catcolab-document-types"; +import { useBinder } from "../api"; +import { ChatTurnEvent } from "../inference/chat"; +import { InferenceKeyResult } from "../user/inference_key_context"; +import { assertExhaustive } from "../util/assert_exhaustive"; +import { + LLMConversationTurnResult, + LLMConversationUserInput, + runLLMConversationTurn, +} from "./document"; +import type { ApiLLMConversation } from "./live_doc_compatibility"; + +/** Ephemeral state for an LLM turn. */ +export type LLMTurnState = { + liveInteractions: LLMInteraction[]; + streamingContent: string; + isRunning: boolean; + notice: LLMTurnNotice | null; +}; + +/** A notice presented to user after an LLM conversation turn. */ +export type LLMTurnNotice = { + kind: "error" | "note"; + message: string; + /** Whether the turn can be retried without resubmitting the user message. */ + retryable: boolean; +}; + +const newLLMTurnState = (overrides?: Partial) => ({ + liveInteractions: [], + streamingContent: "", + isRunning: false, + notice: null, + ...overrides, +}); + +/** Controller for an LLM conversation, intermediating between UI and harness. + +Handles events and exposes ephemeral state during turns of the LLM. Must be +created within a component with the binder provided as context. + */ +export type LLMConversationController = { + /** Reactive store for ephemeral turn state. */ + state: LLMTurnState; + + /** Run a turn of the LLM conversation. */ + runTurn: (userInput: LLMConversationUserInput) => Promise; +}; + +export function createLLMConversationController( + conversation: Accessor, + inferenceKey: Accessor, +): LLMConversationController { + const binder = useBinder(); + const [store, setStore] = createStore(newLLMTurnState()); + + const pushLiveInteraction = (interaction: LLMInteraction) => { + setStore("liveInteractions", store.liveInteractions.length, interaction); + }; + + // Correlates `ToolResult` events with the running entry they complete. + let ephemeralToolCallCount = 0; + let runningToolCallId: string | undefined; + const runningResult = { tag: "Ok", value: "Running…" } as const; + + const handleTurnEvent = (event: ChatTurnEvent) => { + switch (event.tag) { + case "Streaming": + setStore("streamingContent", event.snapshot); + break; + case "RunTool": { + // Finalize any narration streamed since the last tool call. + const narration = store.streamingContent.trim(); + if (narration.length > 0) { + pushLiveInteraction(LLMConversation.newLLMMessage(narration)); + setStore("streamingContent", ""); + } + + const toolCallId = `ephemeral-${ephemeralToolCallCount++}`; + runningToolCallId = toolCallId; + pushLiveInteraction( + LLMConversation.newLLMCodeExecution(toolCallId, event.code, runningResult), + ); + break; + } + case "ToolResult": { + const toolCallId = runningToolCallId; + runningToolCallId = undefined; + setStore( + "liveInteractions", + (interaction) => + interaction.tag === "llm-code-execution" && + interaction.toolCallId === toolCallId, + (interaction) => ({ ...interaction, result: event.result }), + ); + break; + } + default: + assertExhaustive(event); + } + }; + + const runTurn = async ( + userInput: LLMConversationUserInput, + ): Promise => { + const key = inferenceKey(); + if (!key) { + return { tag: "Failed", error: "Inference key is missing. It might still be loading" }; + } + + setStore(newLLMTurnState({ isRunning: true })); + try { + const result = await runLLMConversationTurn( + conversation(), + binder.store, + key, + userInput, + handleTurnEvent, + ); + setStore("notice", turnResultToNotice(result)); + setStore("liveInteractions", result.tag === "Retryable" ? result.attempts : []); + return result; + } finally { + setStore("isRunning", false); + setStore("streamingContent", ""); + } + }; + + return { + state: store, + runTurn, + }; +} + +function turnResultToNotice(result: LLMConversationTurnResult): LLMTurnNotice | null { + switch (result.tag) { + case "Completed": + return null; + case "Incomplete": + // The turn stopped without a final response, but the conversation + // state is coherent. + return { kind: "note", message: result.reason, retryable: false }; + case "Failed": + return { kind: "error", message: result.error, retryable: false }; + case "Retryable": + return { kind: "error", message: result.error, retryable: true }; + default: + assertExhaustive(result); + } +} diff --git a/packages/frontend/src/llm_conversation/conversation_editor.module.css b/packages/frontend/src/llm_conversation/conversation_editor.module.css new file mode 100644 index 0000000000..61665b7c1d --- /dev/null +++ b/packages/frontend/src/llm_conversation/conversation_editor.module.css @@ -0,0 +1,146 @@ +.conversation { + --gutter-width: 1.75rem; + + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + padding-left: var(--gutter-width); + padding-bottom: 1rem; +} + +.scrollSentinel { + position: absolute; + bottom: 0; + pointer-events: none; + + /* Scroll slack: autoscroll stays engaged within this height of the bottom. */ + height: 32px; +} + +.transcript { + --message-inset: 0.75rem; + --message-radius: 5px; + --markdown-paragraph-gap: 0.75em; + + display: flex; + flex-direction: column; + gap: var(--markdown-paragraph-gap); +} + +.userMessage { + padding: 0.5rem var(--message-inset); + border-radius: var(--message-radius); + background: var(--color-gray-100); +} + +.llmMessage { + position: relative; + padding-inline: var(--message-inset); +} + +/* Placeholder shown while the Markdown-rendering pipeline loads. */ +.plainMessage { + white-space: pre-wrap; +} + +.codeExecution { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-inline: var(--message-inset); + + & pre { + margin: 0; + } +} + +.executionSummary { + display: flex; + align-items: center; + gap: 0.375rem; + color: var(--color-text-secondary); +} + +.executedCode { + border: 1px solid var(--color-gray-400); + border-radius: var(--message-radius); + overflow-x: auto; + + & pre { + padding: 0.5rem var(--message-inset); + } +} + +.executionResult { + overflow-x: auto; + font-size: smaller; +} + +.gutter { + position: absolute; + top: 0; + left: 0; + transform: translateX(-100%); + + display: flex; + align-items: center; + height: 1lh; + + & :global(.lucide) { + color: var(--color-gray-600); + } +} + +.collapsedMessage { + max-height: 1lh; + overflow: hidden; + mask-image: linear-gradient(to right, black calc(100% - 4rem), transparent 100%); + + /* Render the visible block as ordinary body text, flush with the top. */ + & > * > :first-child { + margin-block-start: 0; + font-size: inherit; + } +} + +.composer { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.status { + color: var(--color-text-secondary); + font-size: smaller; + overflow-wrap: break-word; +} + +.note { + color: var(--color-alert-note); +} + +.error { + color: var(--color-alert-danger); +} + +.form { + display: flex; + align-items: flex-end; + + & textarea { + font: inherit; + flex: 1; + field-sizing: content; + min-height: 0; + max-height: 40vh; + resize: vertical; + outline: none; + } + + & button { + padding-inline: 0.25rem; + margin-bottom: 6px; + } +} diff --git a/packages/frontend/src/llm_conversation/conversation_editor.tsx b/packages/frontend/src/llm_conversation/conversation_editor.tsx new file mode 100644 index 0000000000..7183383323 --- /dev/null +++ b/packages/frontend/src/llm_conversation/conversation_editor.tsx @@ -0,0 +1,303 @@ +import * as Forms from "@modular-forms/solid"; +import type { SubmitHandler } from "@modular-forms/solid"; +import { makeEventListener } from "@solid-primitives/event-listener"; +import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; +import { createResizeObserver } from "@solid-primitives/resize-observer"; +import Check from "lucide-solid/icons/check"; +import ChevronDown from "lucide-solid/icons/chevron-down"; +import ChevronRight from "lucide-solid/icons/chevron-right"; +import Send from "lucide-solid/icons/send"; +import X from "lucide-solid/icons/x"; +import { + createMemo, + createSignal, + For, + lazy, + Match, + onMount, + Suspense, + Switch, + Show, +} from "solid-js"; + +import { LLMInteraction } from "catcolab-document-types"; +import { Button, CodeView, type FocusHandle, IconButton } from "catcolab-ui-components"; +import { useInferenceKey } from "../user/inference_key_context"; +import { createLLMConversationController, type LLMTurnNotice } from "./conversation_controller"; +import type { ApiLLMConversation } from "./live_doc_compatibility"; + +import styles from "./conversation_editor.module.css"; + +/** Form data for a message to send to the LLM. */ +type LLMMessageForm = { + message: string; +}; + +export function LLMConversationEditor(props: { + conversation: ApiLLMConversation; + focus: FocusHandle; +}) { + void LazyMarkdownMessage.preload(); + + const inferenceKey = useInferenceKey(); + const controller = createLLMConversationController(() => props.conversation, inferenceKey); + const resolveRequest: RequestResolver = (id, resolution) => + props.conversation.resolveFeedbackRequest(id, resolution); + + // Memoize complete list of interactions, persisted and ephemeral. + const interactions = createMemo(() => [ + ...props.conversation.interactions(), + ...controller.state.liveInteractions, + ]); + + // Set up form for user to send messages. + const [form, { Form, Field }] = Forms.createForm(); + + const canSubmit = (): boolean => { + const hasMessage = Boolean(Forms.getValue(form, "message")?.trim()); + return inferenceKey()?.tag === "Ready" && !form.submitting && hasMessage; + }; + + const onSubmit: SubmitHandler = (values) => { + Forms.reset(form, "message"); + Forms.focus(form, "message"); + return controller.runTurn({ content: values.message, files: [] }); + }; + + // Set up `Shift + Enter` shortcut to send message. + makeEventListener(window, "keydown", (evt) => { + if (!props.focus.hasFocus()) { + return; + } + if (evt.shiftKey && evt.key === "Enter" && canSubmit()) { + Forms.submit(form); + evt.preventDefault(); + } + }); + + // Open pane scrolled to bottom, and set up autoscroll. + let conversation!: HTMLDivElement; + let scrollSentinel!: HTMLDivElement; + onMount(() => { + scrollSentinel.scrollIntoView({ block: "end" }); + autoscroll(conversation, scrollSentinel); + }); + + // Status text displayed near text entry form. + const statusText = (): string => { + if (inferenceKey()?.tag !== "Ready") { + return "Loading inference key..."; + } + return controller.state.isRunning ? "Running..." : "Idle"; + }; + + return ( +
+
+ + {(interaction) => ( + + )} + + +
+ +
+
+
+
+
+ {statusText()} + + {(notice) => ( + <> + {" • "} + + + )} + +
+
+ + {(field, fieldProps) => ( +