Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d201a80
WIP: Create model of th_signed_polynomial_system in mass_action.rs
tim-at-topos May 6, 2026
aafac79
WIP: Removing the pretend declarative migration; starting again
tim-at-topos Jun 10, 2026
a04ec46
WIP: Starting to meet in the middle [skip-ci]
tim-at-topos Jun 12, 2026
51f23ff
WIP: tests running (but failing, of course)
tim-at-topos Jun 12, 2026
d6cd50f
WIP: Fixed Lotka-Volterra and LCC
tim-at-topos Jun 12, 2026
121eb6e
WIP: More documentation [skip-ci]
tim-at-topos Jun 12, 2026
7f42ba4
WIP: mass-action for Petri nets [skip-ci]
tim-at-topos Jun 12, 2026
17fb71c
WIP: Fix all tests! [no-ci]
tim-at-topos Jun 12, 2026
aad1f32
ENH: Documentation
tim-at-topos Jun 15, 2026
3f9e052
ENH: Documentation
tim-at-topos Jun 15, 2026
7a8e39c
WIP: LaTeX traits
tim-at-topos Jun 15, 2026
77c71f8
Merge branch 'tim/mass-action-polynomial-ode-system' into tim/refacto…
tim-at-topos Jun 16, 2026
1f881f6
WIP: Failing tests (but in a good way, I promise)
tim-at-topos Jun 16, 2026
b19727e
WIP: Passing catlog tests; failing catlog-wasm tests (expected behavi…
tim-at-topos Jun 16, 2026
09bbfb8
WIP: Thoughts [skip-ci]
tim-at-topos Jun 16, 2026
46de547
WIP: ToLatexWithMap (all tests passing!)
tim-at-topos Jun 17, 2026
c4888bc
ENH: Combined latex_ob_names and latex_mor_names
tim-at-topos Jun 18, 2026
0ec751a
Documentation
tim-at-topos Jun 18, 2026
8701ee9
WIP: More latex frontend tests
tim-at-topos Jun 18, 2026
b8071ee
WIP: Refactor catlog-wasm/src/analyses
tim-at-topos Jun 18, 2026
5c654bc
WIP: More tests for frontend ODE analyses Latex
tim-at-topos Jun 19, 2026
247bfa3
WIP: Failing tests (but, again, that's good and intended I promise)
tim-at-topos Jun 19, 2026
79fbf79
FIX: Backwards compatibility
tim-at-topos Jun 19, 2026
68a66e4
Merge branch 'tim/mass-action-polynomial-ode-system' into tim/refacto…
tim-at-topos Jun 19, 2026
5ffcb6e
WIP: Simplify some of the repetition while we're here
tim-at-topos Jun 19, 2026
59249d0
WIP: Deleting lots of (now) redundant code
tim-at-topos Jun 22, 2026
edcd5b7
FIX: Move front-end ODE equation tests to analyses.rs
tim-at-topos Jun 22, 2026
b77997c
ENH: Documentation
tim-at-topos Jun 23, 2026
c1a3497
WIP: Tests revealing error in latex_names() for objects that are lists
tim-at-topos Jun 23, 2026
70f2f4a
FIX: Passing all Latex tests
tim-at-topos Jun 24, 2026
3a5cc77
WIP: Fix frontend
tim-at-topos Jun 24, 2026
dc95774
FIX: Fix (??) frontend
tim-at-topos Jun 24, 2026
e923149
WIP: Here's the problem
tim-at-topos Jun 24, 2026
582951b
FIX: Working reusable mass-action config form
tim-at-topos Jun 25, 2026
991adca
ENH: Add (failing) test for polynomial ODE; simplify some types
tim-at-topos Jun 25, 2026
b080327
WIP: Namespace problems
tim-at-topos Jun 25, 2026
d350cf7
FIX: Passing all Rust tests
tim-at-topos Jun 25, 2026
fe5fd6c
merge main
tim-at-topos Jun 26, 2026
29a8b4e
FIX: Single ode_semantics_equations.tsx; fix reactivity in mass_actio…
tim-at-topos Jun 26, 2026
7a19017
Merge branch 'tim/mass-action-polynomial-ode-system' into tim/refacto…
tim-at-topos Jun 26, 2026
c0847f4
fix bad merge
tim-at-topos Jun 26, 2026
65d9ac5
FIX: Dynamically load analysis
tim-at-topos Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:deps": "pnpm --filter ./packages/frontend run build:deps",
"dev": "pnpm --filter ./packages/frontend run dev"
"dev": "pnpm --filter ./packages/frontend run dev",
"check": "cargo +nightly fmt && cargo clippy && pnpm --filter ./packages/frontend run check"
},
"devDependencies": {
"depcheck": "^1.4.7",
Expand Down
627 changes: 438 additions & 189 deletions packages/catlog-wasm/src/analyses.rs

Large diffs are not rendered by default.

341 changes: 31 additions & 310 deletions packages/catlog-wasm/src/latex.rs
Original file line number Diff line number Diff line change
@@ -1,321 +1,42 @@
//! 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<LatexEquation>);

/// Creates a closure that formats object names for LaTeX output.
pub(crate) fn latex_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String {
/// Creates a closure that formats object and morphism names for LaTeX output. When a morphism has a
/// name (and thus label), it is used directly; when unnamed, the label falls back to the format
/// `domain→codomain` (e.g., `X \to Y`).
pub(crate) fn latex_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String {
|id: &QualifiedName| {
let name = model.ob_namespace.label_string(id);
if name.chars().count() > 1 {
format!("\\text{{{name}}}")
} else {
name
}
}
}

/// 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}}}")
}
};

move |id: &QualifiedName| {
let sub = morphism_subscript(id);
format!("\\lambda_{{{sub}}}")
}
}

/// Creates a closure that formats morphism names for mass-action LaTeX output.
///
/// When a morphism has a label, it is used directly. When unnamed, the label
/// falls back to the domain→codomain format (e.g., `X \to Y`).
pub(crate) fn latex_mor_names_mass_action(
model: &DblModel,
) -> impl Fn(&ode::MassActionParameter) -> String {
// Returns a LaTeX fragment for a transition, suitable for use as a subscript.
// Named morphisms produce `\text{name}`, unnamed ones produce
// `\text{dom} \to \text{cod}` so that `\to` is in math mode.
let transition_subscript = |transition: &QualifiedName| -> String {
if let Some(label) = model.mor_namespace.label(transition) {
format!("\\text{{{label}}}")
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 {
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}}}")
.mor_generator_dom_cod(id)
.expect("Morphism in equation system should have domain and codomain.");
let dom_labels: Vec<String> = 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<String> = 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)
)
}
};

move |id: &ode::MassActionParameter| match id {
ode::MassActionParameter::Balanced { flow: transition } => {
let sub = transition_subscript(transition);
format!("r_{{{sub}}}")
}
ode::MassActionParameter::Unbalanced { direction, parameter } => {
match (direction, parameter) {
(
ode::Direction::IncomingFlow,
ode::RateParameter::PerTransition { flow: transition },
) => {
let sub = transition_subscript(transition);
format!("\\rho_{{{sub}}}")
}
(
ode::Direction::OutgoingFlow,
ode::RateParameter::PerTransition { flow: transition },
) => {
let sub = transition_subscript(transition);
format!("\\kappa_{{{sub}}}")
}
(
ode::Direction::IncomingFlow,
ode::RateParameter::PerPlace { flow: transition, stock: place },
) => {
let sub = transition_subscript(transition);
let output_place_label = model.ob_namespace.label_string(place);
format!("\\rho_{{{sub}}}^{{\\text{{{output_place_label}}}}}")
}
(
ode::Direction::OutgoingFlow,
ode::RateParameter::PerPlace { flow: transition, stock: place },
) => {
let sub = transition_subscript(transition);
let input_place_label = model.ob_namespace.label_string(place);
format!("\\kappa_{{{sub}}}^{{\\text{{{input_place_label}}}}}")
}
}
}
}
}

/// Creates a closure that formats morphism names for Lotka-Volterra LaTeX output.
///
/// When a morphism has a label, it is used directly. When unnamed, the label
/// falls back to the domain→codomain format (e.g., `X \to Y`).
pub(crate) fn latex_mor_names_lotka_volterra(
model: &DblModel,
) -> impl Fn(&ode::LotkaVolterraParameter) -> String {
// Returns a LaTeX fragment for a transition, suitable for use as a subscript.
// Named morphisms produce `\text{name}`, unnamed ones produce
// `\text{dom} \to \text{cod}` so that `\to` is in math mode.
let transition_subscript = |transition: &QualifiedName| -> String {
if let Some(label) = model.mor_namespace.label(transition) {
format!("\\text{{{label}}}")
} else {
let (dom, cod) = model
.mor_generator_dom_cod_label_strings(transition)
.expect("Morphism in equation system should have domain and codomain");
format!("\\text{{{dom}}} \\to \\text{{{cod}}}")
}
};

move |id: &ode::LotkaVolterraParameter| match id {
ode::LotkaVolterraParameter::Growth { variable } => {
format!("g_{{{variable}}}")
}
ode::LotkaVolterraParameter::Interaction { link } => {
let sub = transition_subscript(link);
format!("k_{{{sub}}}")
}
}
}

/// Creates a closure that formats morphism names for mass-action LaTeX output.
///
/// When a morphism has a label, it is used directly. When unnamed, the label
/// falls back to the domain→codomain format (e.g., `X \to Y`).
pub(crate) fn latex_mor_names_linear_ode(
model: &DblModel,
) -> impl Fn(&ode::LinearODEParameter) -> String {
// Returns a LaTeX fragment for a transition, suitable for use as a subscript.
// Named morphisms produce `\text{name}`, unnamed ones produce
// `\text{dom} \to \text{cod}` so that `\to` is in math mode.
let transition_subscript = |transition: &QualifiedName| -> String {
if let Some(label) = model.mor_namespace.label(transition) {
format!("\\text{{{label}}}")
} else {
let (dom, cod) = model
.mor_generator_dom_cod_label_strings(transition)
.expect("Morphism in equation system should have domain and codomain");
format!("\\text{{{dom}}} \\to \\text{{{cod}}}")
}
};

move |id: &ode::LinearODEParameter| match id {
ode::LinearODEParameter::Parameter { morphism } => {
let sub = transition_subscript(morphism);
format!("\\lambda_{{{sub}}}")
}
}
}

#[cfg(test)]
mod tests {
use catlog::dbl::modal::{List, ModalMorType, ModalOb, ModalObType};
use catlog::dbl::model::{ModalDblModel, MutDblModel};
use catlog::simulate::ode::LatexEquation;
use catlog::stdlib::analyses::ode::{StockFlowMassActionAnalysis, ode_semantics::*};
use catlog::stdlib::{analyses::ode, theories};
use catlog::zero::{LabelSegment, Namespace, QualifiedName};
use std::rc::Rc;
use uuid::Uuid;

use super::*;
use crate::model::{DblModel, tests::backward_link};

#[test]
fn unbalanced_mass_action_latex_equations() {
let model = backward_link("xxx", "yyy", "fff");
let tab_model = model.discrete_tab().unwrap();
let analysis = StockFlowMassActionAnalysis {
mass_conservation_type: ode::MassConservationType::Unbalanced(
ode::RateGranularity::PerTransition,
),
..StockFlowMassActionAnalysis::default()
};
let sys = analysis.build_system(tab_model);
let equations = sys
.map_variables(latex_ob_names(&model))
.extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model)))
.to_latex_equations();

let expected = 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(),
},
LatexEquation {
lhs: "\\frac{\\mathrm{d}}{\\mathrm{d}t} \\text{yyy}".to_string(),
rhs: "\\rho_{\\text{fff}} \\cdot \\text{xxx} \\cdot \\text{yyy}".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 analysis = StockFlowMassActionAnalysis {
mass_conservation_type: ode::MassConservationType::Unbalanced(
ode::RateGranularity::PerTransition,
),
..StockFlowMassActionAnalysis::default()
};
let sys = analysis.build_system(tab_model);
let equations = sys
.map_variables(latex_ob_names(&model))
.extend_scalars(|param| param.map_variables(latex_mor_names_mass_action(&model)))
.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}"
.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(),
},
];
assert_eq!(equations, expected);
}

#[test]
fn modal_mor_dom_cod_labels() {
let th = Rc::new(theories::th_sym_monoidal_category());
let ob_type = ModalObType::new(QualifiedName::from("Object"));
let op = QualifiedName::from("tensor");

let [s_id, i_id, r_id] = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()];
let [infect_id, recover_id] = [Uuid::now_v7(), Uuid::now_v7()];

let mut inner = ModalDblModel::new(th);
inner.add_ob(s_id.into(), ob_type.clone());
inner.add_ob(i_id.into(), ob_type.clone());
inner.add_ob(r_id.into(), ob_type.clone());

// infect: tensor(S, I) -> tensor(I, I) — product-typed dom and cod.
inner.add_mor(
infect_id.into(),
ModalOb::App(
ModalOb::List(
List::Symmetric,
vec![ModalOb::Generator(s_id.into()), ModalOb::Generator(i_id.into())],
)
.into(),
op.clone(),
),
ModalOb::App(
ModalOb::List(
List::Symmetric,
vec![ModalOb::Generator(i_id.into()), ModalOb::Generator(i_id.into())],
)
.into(),
op.clone(),
),
ModalMorType::Zero(ob_type.clone()),
);

// recover: I -> R — simple generator dom and cod.
inner.add_mor(
recover_id.into(),
ModalOb::Generator(i_id.into()),
ModalOb::Generator(r_id.into()),
ModalMorType::Zero(ob_type),
);

let mut ob_namespace = Namespace::new_for_uuid();
ob_namespace.set_label(s_id, LabelSegment::Text("S".into()));
ob_namespace.set_label(i_id, LabelSegment::Text("I".into()));
ob_namespace.set_label(r_id, LabelSegment::Text("R".into()));

let model = DblModel {
model: inner.into(),
ty: None,
ob_namespace,
mor_namespace: Namespace::new_for_uuid(),
};

// Morphism with basic generator dom/cod resolves labels.
assert_eq!(
model.mor_generator_dom_cod_label_strings(&recover_id.into()),
Some(("I".to_string(), "R".to_string()))
);

// Morphism with product-typed dom/cod resolves to bracketed labels.
assert_eq!(
model.mor_generator_dom_cod_label_strings(&infect_id.into()),
Some(("[S, I]".to_string(), "[I, I]".to_string()))
);
}
}
Loading
Loading