Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
13e414a
WIP: Create model of th_signed_polynomial_system in mass_action.rs
tim-at-topos May 6, 2026
c8e1b88
WIP: Removing the pretend declarative migration; starting again
tim-at-topos Jun 10, 2026
4af72f9
WIP: Starting to meet in the middle [skip-ci]
tim-at-topos Jun 12, 2026
0e8b9b5
WIP: tests running (but failing, of course)
tim-at-topos Jun 12, 2026
9d8b231
WIP: Fixed Lotka-Volterra and LCC
tim-at-topos Jun 12, 2026
b061893
WIP: More documentation [skip-ci]
tim-at-topos Jun 12, 2026
c46dfa1
WIP: mass-action for Petri nets [skip-ci]
tim-at-topos Jun 12, 2026
684cde6
WIP: Fix all tests! [no-ci]
tim-at-topos Jun 12, 2026
0ac6aae
ENH: Documentation
tim-at-topos Jun 15, 2026
18aa6a2
WIP: LaTeX traits
tim-at-topos Jun 15, 2026
441cb5a
ENH: Documentation
tim-at-topos Jun 15, 2026
aeb26a6
WIP: Failing tests (but in a good way, I promise)
tim-at-topos Jun 16, 2026
0b983fa
WIP: Passing catlog tests; failing catlog-wasm tests (expected behavi…
tim-at-topos Jun 16, 2026
3e68073
WIP: Thoughts [skip-ci]
tim-at-topos Jun 16, 2026
bf360f6
WIP: ToLatexWithMap (all tests passing!)
tim-at-topos Jun 17, 2026
25e4ade
ENH: Combined latex_ob_names and latex_mor_names
tim-at-topos Jun 18, 2026
05d8dbf
Documentation
tim-at-topos Jun 18, 2026
2f40de0
WIP: More latex frontend tests
tim-at-topos Jun 18, 2026
cccc315
WIP: Refactor catlog-wasm/src/analyses
tim-at-topos Jun 18, 2026
7baf524
WIP: More tests for frontend ODE analyses Latex
tim-at-topos Jun 19, 2026
95d8116
WIP: Failing tests (but, again, that's good and intended I promise)
tim-at-topos Jun 19, 2026
cff0843
WIP: Simplify some of the repetition while we're here
tim-at-topos Jun 19, 2026
25aee3d
WIP: Deleting lots of (now) redundant code
tim-at-topos Jun 22, 2026
b90a4e3
FIX: Move front-end ODE equation tests to analyses.rs
tim-at-topos Jun 22, 2026
309efc4
ENH: Documentation
tim-at-topos Jun 23, 2026
40c6cb9
WIP: Tests revealing error in latex_names() for objects that are lists
tim-at-topos Jun 23, 2026
def96e7
FIX: Passing all Latex tests
tim-at-topos Jun 24, 2026
dfd4e39
WIP: Fix frontend
tim-at-topos Jun 24, 2026
f316617
FIX: Fix (??) frontend
tim-at-topos Jun 24, 2026
0124d43
WIP: Here's the problem
tim-at-topos Jun 24, 2026
891fdae
FIX: Working reusable mass-action config form
tim-at-topos Jun 25, 2026
4cdaa3d
ENH: Add (failing) test for polynomial ODE; simplify some types
tim-at-topos Jun 25, 2026
c9aceff
WIP: Namespace problems
tim-at-topos Jun 25, 2026
5a28cb5
FIX: Passing all Rust tests
tim-at-topos Jun 25, 2026
b29046a
merge main
tim-at-topos Jun 26, 2026
612ffa8
FIX: Single ode_semantics_equations.tsx; fix reactivity in mass_actio…
tim-at-topos Jun 26, 2026
98e121e
FIX: Errant bad rebase line
tim-at-topos Aug 1, 2026
20a07e8
FIX: Dynamically load ODESemanticsEquationsDisplay
tim-at-topos Aug 4, 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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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"
},
"engines": {
"node": "^24.4.0"
Expand Down
546 changes: 446 additions & 100 deletions packages/catlog-wasm/src/analyses.rs

Large diffs are not rendered by default.

261 changes: 31 additions & 230 deletions packages/catlog-wasm/src/latex.rs
Original file line number Diff line number Diff line change
@@ -1,241 +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::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}}}")
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::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}}}}}")
}
},
}
}

#[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;

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 = 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{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 = 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}"
.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()))
);
}
}
32 changes: 15 additions & 17 deletions packages/catlog-wasm/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,46 +427,42 @@ impl DblModel {
Ok(())
}

/// Gets label strings for the domain and codomain of a morphism generator.
/// Gets the domain and codomain of a morphism generator.
///
/// Returns `Some((dom_label, cod_label))` when the morphism has a domain
/// and codomain whose labels can be resolved from the namespace.
pub fn mor_generator_dom_cod_label_strings(
&self,
id: &QualifiedName,
) -> Option<(String, String)> {
/// Returns `Some((dom, cod))`.
pub fn mor_generator_dom_cod(&self, id: &QualifiedName) -> Option<(Ob, Ob)> {
let (dom, cod) = all_the_same!(match &self.model {
DblModelBox::[Discrete, DiscreteTab, ModalUnital, ModalNonUnital](model) => {
(Quoter.quote(model.get_dom(id)?),
Quoter.quote(model.get_cod(id)?))
}
});
Some((self.ob_label_string(&dom)?, self.ob_label_string(&cod)?))
Some((dom, cod))
}

/// Gets a label string for an object.
/// Gets the list of labels for an object.
///
/// For a single object returns its label (e.g. `"S"`). For a list of
/// objects returns bracketed labels (e.g. `"[S, I]"`).
fn ob_label_string(&self, ob: &Ob) -> Option<String> {
/// 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<Vec<QualifiedLabel>> {
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<Vec<String>> = 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,
}
Expand Down Expand Up @@ -864,10 +860,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 {
Expand Down
Loading