0006 Notebook Typescript API
This RFC is tracked in #1334.
1 Summary & Motivation
Catlog and its WASM interface expose a rich and flexible API surface for formal modeling. The full flexibility of this is available to our frontend via Typescript, with types largely generated from Rust, but the ergonomics and type-safety of this API are sub-par.
Colocation of the Typescript handling catlog-wasm and our catcolab-document-types with the frontend has led to entanglement of UI framework concerns with the modeling API.
We want to improve this API surface with the aim of helping three main use-cases:
- Our use in the CatColab frontend
- Other programmatic use from Javascript and Typescript
- Users instructing LLMs
We will focus on notebook documents, since that is the only document currently in use, but this API should be expandable to other types of documents in the future.
2 Technical approach
We will replace our current catcolab-document-methods package with a package called catcolab-documents that makes use of catcolab-document-types and wraps notebook creation and editing in a more ergonomic interface.
We will also add a package called catcolab-logics that defines the logics we currently make available to users on the frontend.
In the future we may publish these packages on public registries such as NPM and JSR.
2.1 Creating notebooks
catcolab-documents provides a default binder API instance and defines the RichText and Instantiation cells.
import { binder, RichText, Instantiation } from "catcolab-documents";
import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";All documents are created through a binder instance. The default binder uses a plain in-memory store for documents and provides no reactivity on its document view. Creating custom binder instances is covered in Use with SolidJS and Automerge.
const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });2.2 Editing notebooks
All cells are added with a single add method. The first argument selects the kind of cell: RichText for prose, or an object/morphism type from the logic for formal cells in the case of editing models.
const intro = notebook.add(RichText, { content: "We define a simple olog." });We can create objects and morphisms in the notebook.
const source = notebook.add(Type, {
label: "A",
});
const target = notebook.add(Type, {
label: "B",
});
const arrow = notebook.add(Aspect, {
label: "has",
from: source,
to: target,
});We can update any item.
notebook.update({ title: "A simple Olog example" });
intro.update({
content: "We define a simple olog with two objects and one arrow.",
});
source.update({
label: "Source",
});
arrow.update({
label: "has as",
from: source,
to: target,
});We can also do partial updates.
arrow.update({
label: "has as example",
});2.3 Instantiation
Instantiation is just another cell type provided by catcolab-documents. In our initial implementation we only support instantiation of notebooks already defined in the local binder.
const anotherOlog = await binder.createNotebook(SimpleOlog, { title: "Another Olog" });
const thing = anotherOlog.add(Type, { label: "Thing" });
const instantiation = notebook.add(Instantiation, {
label: "ImportedOlog",
model: anotherOlog,
// maps ImportedOlog.Thing <- B
specializations: [{ object: thing, as: target }],
});2.4 Iterating through cells
A cells method is provided to iterate through all cells of the notebook.
import { CellKind } from "catcolab-documents";
for (const cell of notebook.cells()) {
switch (cell.kind) {
case CellKind.RichText:
console.log("text:", cell.content);
break;
case CellKind.Object:
console.log("object:", cell.label, "type:", cell.type.obType.content);
break;
case CellKind.Morphism:
console.log("morphism:", cell.label, "type tag:", cell.type.morType.tag);
break;
}
}text: We define a simple olog with two objects and one arrow.
object: Source type: Object
object: B type: Object
morphism: has as type tag: Hom
Additionally we provide a cellsOf method where we can filter by cell type (and by Shape, see Generic shapes of notebooks).
import { Attr, AttrType, Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
const person = notebook.add(Entity, { label: "Person" });
const company = notebook.add(Entity, { label: "Company" });
const mapping = notebook.add(Mapping, { label: "employer", from: person, to: company });const entities = notebook.cellsOf(Entity);
const mappings = notebook.cellsOf(Mapping);
console.log("entities:", entities.map((cell) => cell.label).join(", "));
console.log("mappings:", mappings.map((cell) => cell.label).join(", "));entities: Person, Company
mappings: employer
cells and cellsOf do not recurse into instantiations.
Code example
import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
const person = notebook.add(Entity, { label: "Person" });
const company = notebook.add(Entity, { label: "Company" });
const mapping = notebook.add(Mapping, { label: "employer", from: person, to: company });import { Instantiation } from "catcolab-documents";
const anotherSchema = await binder.createNotebook(SimpleSchema, { title: "Another schema" });
const enterprise = anotherSchema.add(Entity, { label: "Enterprise" });
const building = anotherSchema.add(Entity, { label: "Building" });
const owner = anotherSchema.add(Mapping, { label: "owner", from: enterprise, to: building });
const instantiation = notebook.add(Instantiation, {
label: "ImportedSchema",
model: anotherSchema,
specializations: [{ object: enterprise, as: company }],
});const instantiations = notebook.cellsOf(Instantiation);
const entities = notebook.cellsOf(Entity);
const mappings = notebook.cellsOf(Mapping);
console.log("instantiations:", instantiations.map((cell) => cell.label).join(", "));
console.log("entities:", entities.map((cell) => cell.label).join(", "));
console.log("mappings:", mappings.map((cell) => cell.label).join(", "));instantiations: ImportedSchema
entities: Person, Company
mappings: employer
2.5 Further details on notebook editing
2.5.1 Duplicating cells
We can duplicate cells. Copies keep the same logical shape but receive fresh identities, and their handles can be updated independently.
Code example
import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder, RichText } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const a = notebook.add(Type, { label: "A" });
const b = a.duplicate();
b.update({
label: `B (copy of ${a.label})`,
});
console.log("a:", a.label);
console.log("b:", b.label);a: A
b: B (copy of A)
2.5.2 Re-ordering cells
Every cell handle can move itself within the notebook. Moves locate the cell by its id at the moment the change applies, so they remain valid even if the notebook was edited after the handle was obtained.
Code examples
import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder, RichText } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const a = notebook.add(Type, { label: "A" });
const b = notebook.add(Type, { label: "B" });
const c = notebook.add(Type, { label: "C" });
function names() {
return notebook
.cellsOf(Type)
.map((cell) => cell.label)
.join(", ");
}moveUp and moveDown shift a cell one position; moveTo moves it to an index, interpreted after the cell is removed from its current position.
c.moveUp();
console.log(names());
a.moveDown();
console.log(names());
b.moveTo(0);
console.log(names());A, C, B
C, A, B
B, C, A
Impossible moves are silent no-ops and out-of-range targets clamp to the ends of the notebook.
a.moveUp();
c.moveDown();
console.log(names());
b.moveTo(99);
console.log(names());A, B, C
A, C, B
2.5.3 Deleting cells
Every cell handle can remove itself from the notebook with delete. Like the reorder methods, delete locates the cell by its id when the change applies, so it stays valid even if the notebook was edited after the handle was obtained.
Code examples
Deleting a cell removes it from the notebook’s order and contents.
console.log(names());
b.delete();
console.log(names());A, B, C
A, C
Rich-text cells can be deleted in the same way.
const note = notebook.add(RichText, { content: "A note." });
console.log(notebook.cells().length);
note.delete();
console.log(notebook.cells().length);4
3
After deletion, reading fields off the stale handle returns undefined.
b.delete();
console.log(b.label);undefined
Deleting an already-deleted cell is a silent no-op.
b.delete();
b.delete();
console.log(names());A, C
2.5.4 Getting a cell by id
We can retrieve a cell by its id, filtering by the type of cell we are looking for.
Code example
const retrievedA = notebook.get(Type, a.id);
if (retrievedA.tag === "Ok") {
console.log("retrieved:", retrievedA.content.label);
}retrieved: A
2.5.5 Subscribing to changes.
We can subscribe to notebook changes with onChange. This triggers on any change to the notebook. No arguments are passed to the callback.
Code example
const unsubscribe = notebook.onChange(() => {
console.log("changed");
});2.6 A Result type
In cases where where functions take in unknown and user generated inputs we want a standardized way to return the result on success and diagnostics on failure.
Our Result type is a tagged union structurally identical to the JsResult type in our Rust bindings (catlog-wasm). Results coming out of the Rust layer can therefore pass through untouched.
When a failure is about validation of unknown or user generated input, the Err content is an array of issues following the Standard Schema definition. This allows for flexibility in validation libraries and implementation while sticking to a standard format, and lets us design and make use of general purpose libraries that follow this standard.
/** The result of a fallible operation. */
type Result<T, E = ReadonlyArray<Issue>> =
| { readonly tag: "Ok"; readonly content: T }
| { readonly tag: "Err"; readonly content: E };
/** The issue interface of the failure output. */
interface Issue {
/** The error message of the issue. */
readonly message: string;
/** The path of the issue, if any. */
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
}
/** The path segment interface of the issue. */
interface PathSegment {
/** The key representing a path segment. */
readonly key: PropertyKey;
}Since it is general enough, in the case of e.g. validation failure of a model, we can use the path field to point at specific object or morphism. If we have the need we can extend this in the future e.g. adding a uuid field to the PathSegment interface, without breaking compatibility with the standard.
2.7 Model validation
2.7.1 Validate method
validate is an asynchronous method that returns a Result: an Ok carrying the elaborated model as content, or an Err carrying an array of issues.
import type { DblModel } from "catlog-wasm";
import type { Result } from "catcolab-documents";
import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder } from "catcolab-documents";
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 });A well-formed notebook validates to an Ok carrying the model as content.
const result: Result<DblModel> = await notebook.validate();
console.log("valid:", result.tag === "Ok");valid: true
The validated model is available on the result and can be queried.
const result = await notebook.validate();
if (result.tag === "Ok") {
console.log("objects:", result.content.obGenerators().length);
console.log("morphisms:", result.content.morGenerators().length);
}objects: 2
morphisms: 1
2.7.2 Subscribing to validation changes
You can subscribe to validation changes with onValidate and get notified when any of the formal content of the notebook affected the validation result.
onValidate will trigger a validation if the notebook has not been validated yet and should always call the callback at least once with the current validation.
const unsubscribe = notebook.onValidate((result: Result<DblModel>) => {
console.log("valid:", result.tag === "Ok");
});2.7.3 Validation result
An ill-formed notebook results in an Err carrying issues.
import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder, Instantiation } from "catcolab-documents";
const first = await binder.createNotebook(SimpleOlog, { title: "First" });
const second = await binder.createNotebook(SimpleOlog, { title: "Second" });
first.add(Type, { label: "A" });
second.add(Type, { label: "B" });
// A cycle: `first` instantiates `second`, which instantiates `first`.
first.add(Instantiation, { label: "ImportedSecond", model: second });
second.add(Instantiation, { label: "ImportedFirst", model: first });
const result = await first.validate();
console.log("valid:", result.tag === "Ok");
if (result.tag === "Err") {
console.log("issues:", result.content.map((issue) => issue.message).join("; "));
}valid: false
issues: Instantiation cycle detected: "First" → "Second" → "First". A notebook cannot instantiate itself, directly or indirectly. To fix, remove one of the instantiations in this chain.
2.8 Serialization
import { PetriNet } from "catcolab-logics/petri-net";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(PetriNet, { title: "Example Petri-net" });We can dump a notebook.
const petriNetData = notebook.dump();And load it.
const loaded = await binder.loadNotebook(PetriNet, petriNetData);
console.log("tag:", loaded.tag);
if (loaded.tag === "Ok") {
console.log("loaded:", loaded.content.title);
}tag: Ok
loaded: Example Petri-net
Trying to load a document with the wrong shape yields an Err result whose issues describe the mismatch.
import { SimpleOlog } from "catcolab-logics/simple-olog";
const wrong = await binder.loadNotebook(SimpleOlog, petriNetData);
if (wrong.tag === "Err") {
console.log("issues:", wrong.content.map((issue) => issue.message).join("; "));
}issues: Cannot load document with theory "petri-net" using a shape with theory "simple-olog".
We can also load a notebook from a DocumentRef.
const loaded = await binder.loadNotebookFromRef(PetriNet, {
id: "some-document-id",
version: null,
});2.9 Migrating between logics
import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
import { binder } from "catcolab-documents";
const olog = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const a = olog.add(Type, { label: "A" });
const b = olog.add(Type, { label: "B" });
olog.add(Aspect, { label: "has", from: a, to: b });migrateTo returns a Result.
const migration = await olog.migrateTo(SimpleSchema);
console.log("tag:", migration.tag);
if (migration.tag === "Ok") {
const schema = migration.content;
// The original document was rewritten in place, not copied.
console.log("same document:", schema.document === olog.document);
console.log("theory:", schema.document.theory);
console.log(
"entities:",
schema
.cellsOf(Entity)
.map((cell) => cell.label)
.join(", "),
);
console.log(
"mappings:",
schema
.cellsOf(Mapping)
.map((cell) => cell.label)
.join(", "),
);
console.log("valid:", (await schema.validate()).tag === "Ok");
}tag: Ok
same document: true
theory: simple-schema
entities: A, B
mappings: has
valid: true
2.10 Diagram notebooks
Diagrams are another type of notebook and have a very similar editing interface.
2.10.1 Diagram creation
model definition
import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder, RichText } from "catcolab-documents";
const model = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const A = model.add(Type, { label: "A" });
const B = model.add(Type, { label: "B" });
const has = model.add(Aspect, { label: "has", from: A, to: B });const diagram = await binder.createNotebook(SimpleOlog.Diagram, {
title: "Olog diagram",
in: model,
});console.log("name:", diagram.title);
console.log("theory:", diagram.theory);name: Olog diagram
theory: simple-olog
2.10.2 Adding cells over the model
diagram.add(RichText, { content: "We picture two instances of the olog." });
const x = diagram.add(SimpleOlog.Diagram.Individual, { label: "x", over: A });
const y = diagram.add(SimpleOlog.Diagram.Individual, { label: "y", over: B });console.log("over:", x.over.label);
console.log("type:", x.type.obType.content);over: A
type: Object
const f = diagram.add(SimpleOlog.Diagram.Aspect, { from: x, to: y, over: has });console.log("over:", f.over.label);
console.log("from:", f.from.label);
console.log("to:", f.to.label);over: has
from: x
to: y
2.11 Analysis notebooks
Analyses are yet another type of notebook, again with a similar editing interface. Common analysis cell types will be packaged by us into catcolab-analyses.
model definition
import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder, RichText } from "catcolab-documents";
const model = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const source = model.add(Type, { label: "A" });
const target = model.add(Type, { label: "B" });const analysis = await binder.createNotebook(SimpleOlog.Analysis, {
title: "Olog analysis",
of: model,
});console.log("name:", analysis.title);
console.log("type:", analysis.analysisType);name: Olog analysis
type: model
import { Visualization } from "catcolab-analyses";
analysis.add(RichText, { content: "We visualize the olog." });
const viz = analysis.add(Visualization);console.log("analysis id:", viz.type.id);
console.log("layout:", viz.params.layout);analysis id: diagram
layout: graphviz-directed
viz.update({ direction: "horizontal" });
console.log("layout:", viz.params.layout);
console.log("direction:", viz.params.direction);layout: graphviz-directed
direction: horizontal
2.11.1 Running an analysis
Running an analysis is an asynchronous function that returns a Result: an Ok carrying the analysis output as content, or an Err carrying an array of issues.
const result = await viz.run();
if (result.tag === "Ok") {
const { svg } = result.content;
console.log("is svg:", svg.includes("<svg"));
console.log("has A:", svg.includes(">A<"));
console.log("has B:", svg.includes(">B<"));
}is svg: true
has A: true
has B: true
2.11.2 Mass-action dynamics example
Code example
import { MassActionDynamics } from "catcolab-analyses";
import { PetriNet, Place, Transition } from "catcolab-logics/petri-net";
import { binder } from "catcolab-documents";
const petriNet = await binder.createNotebook(PetriNet, { title: "SIR" });
const susceptible = petriNet.add(Place, { label: "S" });
const infected = petriNet.add(Place, { label: "I" });
petriNet.add(Transition, { label: "infection", from: [susceptible, infected], to: [infected] });
const analysis = await binder.createNotebook(PetriNet.Analysis, {
title: "Petri net analysis",
of: petriNet,
});
const sim = analysis.add(MassActionDynamics);console.log("analysis id:", sim.type.id);
console.log("duration:", sim.params.duration);analysis id: mass-action
duration: 10
sim.update({ duration: 3, initialValues: { [susceptible.id]: 1 } });console.log("duration:", sim.params.duration);
console.log("S initial:", sim.params.initialValues[susceptible.id]);duration: 3
S initial: 1
const result = await sim.run();
if (result.tag === "Ok") {
const { solution, latexEquations } = result.content;
if (solution.tag === "Ok") {
console.log("has times:", solution.content.time.length > 0);
console.log("states:", [...solution.content.states.keys()].length);
}
console.log("equations:", latexEquations.length > 0);
}has times: true
states: 2
equations: true
2.12 Type errors & runtime errors
We make use of Typescript type checking to have confidence that our use of the Notebook API makes sense and is free of misspellings or other issues that would throw errors at runtime.
Since the API can also be used from Javascript directly without type checking we want runtime validation that inputs match our expected schema.
At runtime invalid inputs:
- Should throw an exception, ideally carrying a Standard Schema compatible
issuesarray, if it is something Typescript would have caught - Should return a
Resulttype otherwise
2.12.1 Type errors
import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const source = notebook.add(Type, { label: "A" });
const target = notebook.add(Type, { label: "B" });
const arrow = notebook.add(Aspect, { label: "has", from: source, to: target });Invalid shapes should be type errors.
// @ts-expect-error Arrays are not valid endpoints in a simple olog.
arrow.update({ from: [source] });
// @ts-expect-error Arrays are not valid endpoints in a simple olog.
notebook.add(Aspect, { label: "bad", from: [source, target], to: target });
// @ts-expect-error Missing required fields.
notebook.add(Aspect, {});
// null fields are allowed.
notebook.add(Aspect, { label: null, from: null, to: null });2.12.2 Further type errors
Further type errors
A mapping’s endpoints must be entities, not attribute types:
import { AttrType, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
import { binder } from "catcolab-documents";
const schema = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
const str = schema.add(AttrType, { label: "String" });
// @ts-expect-error A mapping's endpoints must be entities, not attribute types.
schema.add(Mapping, {
label: "bad",
from: str,
to: str,
});But adapt to the underlying logic:
import { PetriNet, Place, Transition } from "catcolab-logics/petri-net";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(PetriNet, { title: "Example Petri-net" });
const a = notebook.add(Place, { label: "A" });
const b = notebook.add(Place, { label: "B" });
const c = notebook.add(Place, { label: "C" });
notebook.add(Transition, {
label: "t1",
from: [a, b],
to: [c],
});
// @ts-expect-error Petri net transitions require arrays of places.
notebook.add(Transition, {
label: "bad",
from: a,
to: [c],
});2.12.3 Runtime errors
The same invalid inputs that Typescript catches are also rejected at runtime. Each one throws an error carrying a Standard Schema compatible issues array.
import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
const source = notebook.add(Type, { label: "A" });
const target = notebook.add(Type, { label: "B" });
const arrow = notebook.add(Aspect, { label: "has", from: source, to: target });Invalid shapes throw at runtime, with a Standard Schema compatible issues array:
try {
// Arrays are not valid endpoints in a simple olog.
arrow.update({ from: [source] });
} catch (error) {
console.log("update:", error.issues?.map((issue) => issue.message).join("; "));
}
try {
// Arrays are not valid endpoints in a simple olog.
notebook.add(Aspect, { label: "bad", from: [source, target], to: target });
} catch (error) {
console.log("add:", error.issues?.map((issue) => issue.message).join("; "));
}
try {
// Missing required fields.
notebook.add(Aspect, {});
} catch (error) {
console.log("missing:", error.issues?.map((issue) => issue.message).join("; "));
}
// null fields are allowed.
notebook.add(Aspect, { label: null, from: null, to: null });update: `from` must be an object cell or null (was an array)
add: `from` must be an object cell or null (was an array)
missing: `from` must be an object cell or null (was missing); `label` must be a string or null (was missing); `to` must be an object cell or null (was missing)
2.12.4 Further runtime errors
Further runtime errors
A mapping’s endpoints must be entities, not attribute types:
import { AttrType, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
import { binder } from "catcolab-documents";
const schema = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
const str = schema.add(AttrType, { label: "String" });try {
schema.add(Mapping, { label: "bad", from: str, to: str });
} catch (error) {
console.log("mapping:", error.issues?.map((issue) => issue.message).join("; "));
}mapping: `from` must be an object cell of type Entity (was an object cell of type AttrType); `to` must be an object cell of type Entity (was an object cell of type AttrType)
Validation also adapts to the underlying logic:
import { PetriNet, Place, Transition } from "catcolab-logics/petri-net";
import { binder } from "catcolab-documents";
const notebook = await binder.createNotebook(PetriNet, { title: "Example Petri-net" });
const a = notebook.add(Place, { label: "A" });
const c = notebook.add(Place, { label: "C" });try {
// Petri net transitions require arrays of places.
notebook.add(Transition, { label: "bad", from: a, to: [c] });
} catch (error) {
console.log("transition:", error.issues?.map((issue) => issue.message).join("; "));
}transition: `from` must be an array or null (was an object cell of type Object)
2.13 Use with SolidJS and Automerge
The Binder abstraction exsits to keep the API consistent while being able to plug in custom backends. We offer the createBinder method and the DocumentStore type to be able to do so.
import type { Document } from "catcolab-document-types";
import type { Result } from "catcolab-documents";
interface DocumentRef {
id: string;
version: string | null;
server?: string;
}
interface DocumentStore<Handle> {
// An async function to create a document handle from inital data
createHandle(initialDoc: Document): Promise<Handle>;
// An async function to get a document handle from a `DocumentRef`,
// as a `Result` (`Ok` with the handle, or `Err` with issues).
getHandle(ref: DocumentRef): Promise<Result<Handle>>;
// Apply modifications to a handle.
changeDocument(handle: Handle, fn: (doc: Document) => void): void;
// Subscribe change callbacks to our store for `onChange`. Returns a
// function to unsubscribe.
subscribe(handle: Handle, callback: () => void): () => void;
// Copy values (with any proxies removed)
copyValue<T>(handle: Handle, value: T): T;
// Get the reference for a handle
getDocumentRef(handle: Handle): DocumentRef;
// Get a document view from a handle
getDocumentView(handle: Handle): Readonly<Document>;
}2.13.1 SolidJS binder
An example of a simple SolidJS binder:
import { createEffect, createRoot } from "solid-js";
import { createStore, reconcile, type SetStoreFunction, unwrap } from "solid-js/store";
import { binder, createBinder, type DocumentStore } from "catcolab-documents";
import { type Document } from "catcolab-document-types";
type SolidStoreHandle = {
draftDoc: Document;
docView: Document;
setDocView: SetStoreFunction<Document>;
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<SolidStoreHandle, string>();
const solidStoreIdFor = (handle: SolidStoreHandle): string => {
let id = solidStoreIds.get(handle);
if (!id) {
id = crypto.randomUUID();
solidStoreIds.set(handle, id);
}
return id;
};
const solidStore: DocumentStore<SolidStoreHandle> = {
async createHandle(initialDoc) {
const draftDoc = structuredClone(initialDoc as Document);
const [docView, setDocView] = createStore<Document>(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." }],
}),
};
const solidBinder = createBinder(solidStore);Which can be used just as the default binder.
import { SimpleOlog } from "catcolab-logics/simple-olog";
const notebook = await solidBinder.createNotebook(SimpleOlog, { title: "An Olog" });2.13.2 Automerge binder
A simple Automerge binder:
import { type Doc, getBackend, getObjectId } from "@automerge/automerge";
import { type DocHandle, Repo } from "@automerge/automerge-repo";
import { createBinder, type DocumentStore } from "catcolab-documents";
import { type Document } from "catcolab-document-types";
const repo = new Repo();
const automergeStore: DocumentStore<DocHandle<Document>> = {
createHandle: async (initialDoc) => {
return repo.create<Document>(initialDoc as Document);
},
changeDocument: (handle, fn) => handle.change(fn),
subscribe: (handle, callback) => {
const onChange = () => callback();
handle.on("change", onChange);
return () => handle.off("change", onChange);
},
copyValue: (handle, value) => {
const doc = handle.doc();
const objId = getObjectId(value as object);
return getBackend(doc).materialize(objId!) as typeof value;
},
getDocumentView: (handle) => handle.doc(),
getDocumentRef: (handle) => ({ id: handle.documentId, version: null, server: "" }),
// Link resolution omitted for brevity.
getHandle: async () => ({
tag: "Err",
content: [{ message: "This store cannot resolve references." }],
}),
};
const automergeBinder = createBinder(automergeStore);import { SimpleOlog } from "catcolab-logics/simple-olog";
const notebook = await automergeBinder.createNotebook(SimpleOlog, { title: "An Olog" });2.13.3 An Automerge and SolidJS binder for our backend
Here is an approximation of how we can define a binder to work with our backend and frontend using Automerge, SolidJS and our RPC client (mocked here as FakeBackend).
import { getBackend, getObjectId } from "@automerge/automerge";
import { type DocHandle, type DocumentId } from "@automerge/automerge-repo";
import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives";
import { createBinder, type DocumentStore, Instantiation } from "catcolab-documents";
import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
import type { Document } from "catcolab-document-types";
import { FakeBackend } from "document-methods/test/fake_backend";
const backend = new FakeBackend();
const repo = backend.repo;
type StoreHandle = {
docHandle: DocHandle<Document>;
docView: Document;
};
const makeHandle = (docHandle: DocHandle<Document>): StoreHandle => ({
docHandle,
docView: makeDocumentProjection(docHandle),
});
const refByDocId = new Map<DocumentId, string>();
const handleByRefId = new Map<string, StoreHandle>();
const backendStore: DocumentStore<StoreHandle> = {
createHandle: async (initialDoc: Document) => {
const created = await backend.new_ref(initialDoc);
if (created.tag !== "Ok") {
throw new Error(created.message);
}
const refId = created.content;
const fetched = await backend.get_doc(refId);
if (fetched.tag !== "Ok") {
throw new Error(fetched.message);
}
const docHandle = await repo.find<Document>(fetched.content.docId as DocumentId);
const handle = makeHandle(docHandle);
refByDocId.set(docHandle.documentId, refId);
handleByRefId.set(refId, handle);
return handle;
},
getDocumentView: (handle) => handle.docView,
changeDocument: (handle, fn) => handle.docHandle.change(fn),
subscribe: (handle, callback) => {
handle.docHandle.on("change", callback);
return () => handle.docHandle.off("change", callback);
},
copyValue: (handle, value) => {
const doc = handle.docHandle.doc();
const objId = getObjectId(value as object);
return getBackend(doc).materialize(objId!) as typeof value;
},
getDocumentRef: (handle) => {
const refId = refByDocId.get(handle.docHandle.documentId);
if (!refId) {
throw new Error("handle is not registered with this store");
}
return { id: refId, version: null, server: backend.serverHost };
},
getHandle: async (ref) => {
const refId = ref.id;
const cached = handleByRefId.get(refId);
if (cached) {
return { tag: "Ok", content: cached };
}
const result = await backend.get_doc(refId);
if (result.tag !== "Ok") {
return {
tag: "Err",
content: [{ message: `Cannot resolve reference "${refId}".`, path: ["id"] }],
};
}
const docHandle = await repo.find<Document>(result.content.docId as DocumentId);
const handle = makeHandle(docHandle);
handleByRefId.set(refId, handle);
refByDocId.set(docHandle.documentId, refId);
return { tag: "Ok", content: handle };
},
};
const backendBinder = createBinder(backendStore);Code verification
const imported = await backendBinder.createNotebook(SimpleOlog, { title: "Imported" });
imported.add(Type, { label: "Thing" });
const notebook = await backendBinder.createNotebook(SimpleOlog, { title: "Main" });
notebook.add(Type, { label: "A" });
notebook.add(Instantiation, { label: "ImportedOlog", model: imported });
const result = await notebook.validate();
console.log(result.tag);Ok
2.14 Defining notebook shapes
2.14.1 A logic
We provide some utilities for defining a logic: defineMorphism, defineObject and defineShape. Here is what the definition of the PetriNet logic looks like:
import { MassActionDynamics, Visualization } from "catcolab-analyses";
import { defineMorphism, defineObject, defineShape } from "catcolab-documents";
export const Place = defineObject({ tag: "Basic", content: "Object" });
export const Transition = defineMorphism(
{ tag: "Hom", content: Place.obType },
{
domain: { apply: { tag: "Basic", content: "tensor" }, modality: "SymmetricList" },
codomain: { apply: { tag: "Basic", content: "tensor" }, modality: "SymmetricList" },
},
);
export const PetriNet = defineShape({
theory: "petri-net",
getCoreTheory: async () => {
const { ThSymMonoidalCategory } = await import("catlog-wasm");
return new ThSymMonoidalCategory().theory();
},
objects: [Place],
morphisms: [Transition],
modelAnalyses: [Visualization, MassActionDynamics /*, ... */],
});To illustrate how migrations are defined here is the definition of SimpleSchema.
import { defineMorphism, defineObject, defineShape, RichText } from "catcolab-documents";
export const Entity = defineObject({ tag: "Basic", content: "Entity" });
export const AttrType = defineObject({ tag: "Basic", content: "AttrType" });
export const Mapping = defineMorphism({ tag: "Hom", content: Entity.obType });
export const Attr = defineMorphism(
{ tag: "Basic", content: "Attr" },
{ domain: Entity.obType, codomain: AttrType.obType },
);
export const SimpleSchema = defineShape({
theory: "simple-schema",
getCoreTheory: async () => {
const { ThSchema } = await import("catlog-wasm");
return new ThSchema().theory();
},
objects: [Entity, AttrType],
morphisms: [Mapping, Attr],
supportsInstances: true,
informal: [RichText],
analyses: [
/* ... */
],
migrations: [
{
target: "simple-olog",
migrate: async (model, targetTheory) => {
const { ThSchema } = await import("catlog-wasm");
return ThSchema.toCategory(model, targetTheory);
},
},
],
});supportsIntances is a boolean flag that indicates whether the notebook supports diagrams. When included the .Diagram is generated for the logic. See Diagram notebooks for using the .Diagram shape.
2.14.2 Analysis definitions
For analysis cell definitions we can use the defineAnalysis helper. Here is an example for MassActionDynamics.
import { defineAnalysis } from "catcolab-documents";
import type {
MassActionProblemData,
ODEResultWithEquations,
ThSymMonoidalCategory,
DblModel,
} from "catlog-wasm";
let thSymMonoidalCategory: Promise<ThSymMonoidalCategory> | undefined;
function getCachedTheory(): Promise<ThSymMonoidalCategory> {
if (thSymMonoidalCategory === undefined) {
thSymMonoidalCategory = import("catlog-wasm").then(
({ ThSymMonoidalCategory }) => new ThSymMonoidalCategory(),
);
}
return thSymMonoidalCategory;
}
export const MassActionDynamics = defineAnalysis({
id: "mass-action",
getInitialParams: (): MassActionProblemData => ({
massConservationType: { type: "Balanced" },
rates: {},
transitionProductionRates: {},
transitionConsumptionRates: {},
placeProductionRates: {},
placeConsumptionRates: {},
initialValues: {},
duration: 10,
}),
run: async (
model: DblModel,
params: MassActionProblemData,
): Promise<ODEResultWithEquations> => {
const th = await getCachedTheory();
return th.massAction(model, params);
},
});2.14.3 Generic shapes of notebooks
Using defineShape we don’t have to define a complete notebook. We can define shapes that are subsets of a notebook and/or span across different notebooks.
Here is an example of a shape that only deals with basic objects:
import { defineObject, defineShape } from "catcolab-documents";
const BasicObj = defineObject({ tag: "Basic", content: "Object" });
const OnlyBasicObj = defineShape({
objects: [BasicObj],
});Because OnlyBasicObj is a subset of both the olog and petri-net logics (they share the basic Object), we can write a generic function against it that works with notebooks from either logic.
import { binder, type Notebook } from "catcolab-documents";
import { SimpleOlog } from "catcolab-logics/simple-olog";
import { PetriNet } from "catcolab-logics/petri-net";
function addObjects(notebook: Notebook<typeof OnlyBasicObj>) {
notebook.add(BasicObj, { label: "A" });
notebook.add(BasicObj, { label: "B" });
}Both an olog and a petri-net notebook support BasicObj, so addObjects adds to them:
const olog = await binder.createNotebook(SimpleOlog, { title: "olog" });
const petriNet = await binder.createNotebook(PetriNet, { title: "petri-net" });
addObjects(olog);
addObjects(petriNet);A schema notebook has no basic Object so we get a type error.
import { SimpleSchema } from "catcolab-logics/simple-schema";
const schema = await binder.createNotebook(SimpleSchema, { title: "schema" });
// @ts-expect-error A SimpleSchema notebook lacks the basic `Object` that OnlyBasicObj requires.
addObjects(schema);We can pass a union of shapes to Notebook but we need to use supports to narrow the type to confirm it supports adding cells of a particular shape.
import { defineObject, defineShape, defineMorphism } from "catcolab-documents";
import type { Notebook } from "catcolab-documents";
const BasicObj = defineObject({ tag: "Basic", content: "Object" });
const OnlyBasicObj = defineShape({
objects: [BasicObj],
});
const EntityObj = defineObject({ tag: "Basic", content: "Entity" });
const OnlyEntityObj = defineShape({
objects: [EntityObj],
});function addBasicAndEntityObjects(notebook: Notebook<typeof OnlyBasicObj | typeof OnlyEntityObj>) {
// @ts-expect-error We can't add a BasicObj without narrowing the notebook
// type because EntityWithMor does not support BasicObj.
notebook.add(BasicObj, { label: "A" });
// This is ok because we narrowed the notebook type.
if (notebook.supports(BasicObj)) {
notebook.add(BasicObj, { label: "B" });
}
// @ts-expect-error We can't add a BasicObj without narrowing the notebook
// type because OnlyBasicObj does not suppor EntityObj.
notebook.add(EntityObj, { label: "C" });
// This is ok because we narrowed the notebook type.
if (notebook.supports(EntityObj)) {
notebook.add(EntityObj, { label: "D" });
}
}supports can also take a shape as an argument.
export const Morphism = defineMorphism({
tag: "Hom",
content: EntityObj.obType,
});
const EntityWithMor = defineShape({
objects: [EntityObj],
morphisms: [Morphism],
});
function addBasicEntityAndMor(notebook: Notebook<typeof OnlyBasicObj | typeof EntityWithMor>) {
// @ts-expect-error We can't add a BasicObj without narrowing the notebook
// type because EntityWithMor does not support BasicObj.
notebook.add(BasicObj, { label: "A" });
// This is ok because we narrowed the notebook type.
if (notebook.supports(OnlyBasicObj)) {
notebook.add(BasicObj, { label: "B" });
}
// @ts-expect-error We can't add a BasicObj without narrowing the notebook
// type because OnlyBasicObj does not support EntityObj.
notebook.add(EntityObj, { label: "C" });
// This is ok because we narrowed the notebook type to supporting both
// `EntiyObj` and `Morphism`.
if (notebook.supports(EntityWithMor)) {
const d = notebook.add(EntityObj, { label: "D" });
notebook.add(Morphism, { label: "f", from: d, to: d });
}
}cellsOf takes Shape as an alternative to a cell definition as an argument to filter by. cellsOf narrows the cell shape type in a similar way to supports.
import { SimpleSchema, Entity, AttrType, Mapping, Attr } from "catcolab-logics/simple-schema";
import { binder, defineShape } from "catcolab-documents";
const EntityShape = defineShape({
objects: [Entity],
morphisms: [Mapping],
});
const schema = await binder.createNotebook(SimpleSchema, { title: "Example" });
const a = schema.add(Entity, { label: "A" });
const b = schema.add(AttrType, { label: "B" });
const c = schema.add(Entity, { label: "C" });
schema.add(Attr, { label: "f", from: a, to: b });
schema.add(Mapping, { label: "g", from: a, to: c });
for (const cell of schema.cellsOf(EntityShape)) {
console.log(cell.label);
}A
C
g
2.14.4 Further shape examples
2.14.4.1 Implementing a list shape
Here is a complete example that accepts morphisms with lists as domain and codomain of all the variants we currently have defined.
Code example
import { defineMorphism, defineObject, defineShape, type Notebook } from "catcolab-documents";
const BasicObj = defineObject({ tag: "Basic", content: "Object" });
const tensor = { tag: "Basic", content: "tensor" } as const;
const ListMor = defineMorphism(
{ tag: "Hom", content: BasicObj.obType },
{
domain: { apply: tensor, modality: "List" },
codomain: { apply: tensor, modality: "List" },
},
);
const ListShape = defineShape({
objects: [BasicObj],
morphisms: [ListMor],
});
const SymmetricListMor = defineMorphism(
{ tag: "Hom", content: BasicObj.obType },
{
domain: { apply: tensor, modality: "SymmetricList" },
codomain: { apply: tensor, modality: "SymmetricList" },
},
);
const SymmetricListShape = defineShape({
objects: [BasicObj],
morphisms: [SymmetricListMor],
});
const CocartesianListMor = defineMorphism(
{ tag: "Hom", content: BasicObj.obType },
{
domain: { apply: tensor, modality: "CocartesianList" },
codomain: { apply: tensor, modality: "CocartesianList" },
},
);
const CocartesianListShape = defineShape({
objects: [BasicObj],
morphisms: [CocartesianListMor],
});
const CartesianListMor = defineMorphism(
{ tag: "Hom", content: BasicObj.obType },
{
domain: { apply: tensor, modality: "CartesianList" },
codomain: { apply: tensor, modality: "CartesianList" },
},
);
const CartesianListShape = defineShape({
objects: [BasicObj],
morphisms: [CartesianListMor],
});
const AdditiveListMor = defineMorphism(
{ tag: "Hom", content: BasicObj.obType },
{
domain: { apply: tensor, modality: "AdditiveList" },
codomain: { apply: tensor, modality: "AdditiveList" },
},
);
const AdditiveListShape = defineShape({
objects: [BasicObj],
morphisms: [AdditiveListMor],
});addListMorphism works on any notebook that supports any of the morphisms our list shapes support. When implementing a generic consumer like this we need to narrow down what object and morphism types the notebook actually supports by using notebook.supports.
type NotebookOfLists = Notebook<
| typeof ListShape
| typeof SymmetricListShape
| typeof CocartesianListShape
| typeof CartesianListShape
| typeof AdditiveListShape
>;
function addListMorphism(notebook: NotebookOfLists) {
const a = notebook.add(BasicObj, { label: "A" });
const b = notebook.add(BasicObj, { label: "B" });
const c = notebook.add(BasicObj, { label: "C" });
if (notebook.supports(ListMor)) {
notebook.add(ListMor, { label: "L", from: [a, b], to: [c] });
} else if (notebook.supports(SymmetricListMor)) {
console.log("Adding SymmetricListMor!");
notebook.add(SymmetricListMor, { label: "L", from: [a, b], to: [c] });
} else if (notebook.supports(CocartesianListMor)) {
notebook.add(CocartesianListMor, { label: "L", from: [a, b], to: [c] });
} else if (notebook.supports(CartesianListMor)) {
notebook.add(CartesianListMor, { label: "L", from: [a, b], to: [c] });
} else if (notebook.supports(AdditiveListMor)) {
notebook.add(AdditiveListMor, { label: "L", from: [a, b], to: [c] });
} else {
// If the code type checked this should be unreachable.
throw new Error("Did not find any supported List morphism in the notebook.");
}
}function badAddListMorphism(notebook: NotebookOfLists) {
const a = notebook.add(BasicObj, { label: "A" });
const b = notebook.add(BasicObj, { label: "B" });
const c = notebook.add(BasicObj, { label: "C" });
//@ts-expect-error Not all variants support adding a `ListMor`. You need to narrow the type using the `supports` method.
notebook.add(ListMor, { label: "L", from: [a, b], to: [c] });
}2.14.4.2 A structurally compatible notebook is accepted and the appropriate morphism is added
Code example
import { binder } from "catcolab-documents";
import { PetriNet } from "catcolab-logics/petri-net";
const petriNet = await binder.createNotebook(PetriNet, { title: "example" });
addListMorphism(petriNet);Adding SymmetricListMor!
const entityObType = defineObject({ tag: "Basic", content: "Entity" });
const EntityObjectShape = defineShape({
theory: "entity-objects",
objects: [entityObType, BasicObj],
morphisms: [ListMor],
});
const entityObjects = await binder.createNotebook(EntityObjectShape, { title: "example" });
addListMorphism(entityObjects);2.14.4.3 A structurally incompatible notebook should be rejected
Code examples
import { SimpleOlog } from "catcolab-logics/simple-olog";
const simpleOlog = await binder.createNotebook(SimpleOlog, { title: "example" });
// @ts-expect-error A SimpleOlog notebook lacks the list-valued morphisms ListShape requires.
addListMorphism(simpleOlog);const JustObjectShape = defineShape({
theory: "just-objects",
objects: [BasicObj],
});
const justObjects = await binder.createNotebook(JustObjectShape, { title: "example" });
// @ts-expect-error We have no morphisms in `JustObjectShape`.
addListMorphism(justObjects);const JustMorphismShape = defineShape({
theory: "just-morphisms",
morphisms: [ListMor],
});
const justMorphisms = await binder.createNotebook(JustMorphismShape, { title: "example" });
// @ts-expect-error We have no objects in `JustMorphismShape`.
addListMorphism(justMorphisms);const EntityObj = defineObject({ tag: "Basic", content: "Entity" });
const EntityListMor = defineMorphism(
{ tag: "Hom", content: EntityObj.obType },
{
domain: { apply: tensor, modality: "List" },
codomain: { apply: tensor, modality: "List" },
},
);
const MultiObjectListShape = defineShape({
objects: [BasicObj, EntityObj],
morphisms: [ListMor, EntityListMor],
});
function badAddListMorphism2(notebook: Notebook<typeof MultiObjectListShape>) {
const a = notebook.add(BasicObj, { label: "A" });
const b = notebook.add(BasicObj, { label: "B" });
const e = notebook.add(EntityObj, { label: "E" });
notebook.add(ListMor, { label: "L1", from: [a, b], to: [b] });
//@ts-expect-error We can't use an EntityObj with a ListMor
notebook.add(ListMor, { label: "L2", from: [a, b], to: [e] });
}const entityObType = defineObject({ tag: "Basic", content: "Entity" });
const entityListMorType = defineMorphism(
{ tag: "Hom", content: entityObType.obType },
{
domain: { apply: tensor, modality: "List" },
codomain: { apply: tensor, modality: "List" },
},
);
const EntityObjectListShape = defineShape({
objects: [entityObType],
morphisms: [entityListMorType],
});
type NotebookOfListsWithEntity = Notebook<
| typeof ListShape
| typeof SymmetricListShape
| typeof CocartesianListShape
| typeof CartesianListShape
| typeof AdditiveListShape
| typeof EntityObjectListShape
>;
const EntityObj = entityObType;
function goodAddObject(notebook: NotebookOfListsWithEntity) {
if (notebook.supports(BasicObj)) {
notebook.add(BasicObj, { label: "A" });
}
if (notebook.supports(EntityObj)) {
notebook.add(EntityObj, { label: "E" });
}
}
const BothObjectsShape = defineShape({
objects: [BasicObj, entityObType],
});
function goodAddObject2(notebook: NotebookOfListsWithEntity) {
if (notebook.supports(BothObjectsShape)) {
notebook.add(BasicObj, { label: "A" });
notebook.add(EntityObj, { label: "E" });
}
}
type JustEntityObjectListShape = Notebook<typeof EntityObjectListShape>;
function goodAddObject3(notebook: JustEntityObjectListShape) {
notebook.add(EntityObj, { label: "E" });
}
function badAddObject(notebook: NotebookOfListsWithEntity) {
//@ts-expect-error We can't add a BasicObj without narrowing the notebook type because EntityObjectListShape does not support BasicObj.
notebook.add(BasicObj, { label: "A" });
//@ts-expect-error We can't add a EntityObj without narrowing the notebook type because not all notebooks support EntityObj.
notebook.add(EntityObj, { label: "E" });
}
function badAddObject2(notebook: Notebook<typeof BothObjectsShape>) {
const a = notebook.add(BasicObj, { label: "A" });
const b = notebook.add(BasicObj, { label: "B" });
//@ts-expect-error BothObjectsShape can never support CocartesianListMor.
if (notebook.supports(CocartesianListMor)) {
//@ts-expect-error BothObjectsShape does not support CocartesianListMor.
notebook.add(CocartesianListMor, { label: "L", from: [a, b], to: [b] });
}
}2.14.5 Use with SolidJS
2.14.5.1 Shape consumer
We can define the shape our components consume.
Code example
import {
type NotebookCell,
CellKind,
createBinder,
defineMorphism,
defineObject,
type DocumentStore,
type Notebook,
defineShape,
RichText,
} from "catcolab-documents";
import { PetriNet, Place, Transition } from "catcolab-logics/petri-net";
import { For } from "solid-js";
import { createStore, reconcile, type SetStoreFunction, unwrap } from "solid-js/store";
import { render } from "solid-js/web";
import type { Document } from "catcolab-document-types";
type SolidStoreHandle = {
draftDoc: Document;
docView: Document;
setDocView: SetStoreFunction<Document>;
listeners: Set<() => void>;
};
const solidStoreIds = new WeakMap<SolidStoreHandle, string>();
const solidStoreIdFor = (handle: SolidStoreHandle): string => {
let id = solidStoreIds.get(handle);
if (!id) {
id = crypto.randomUUID();
solidStoreIds.set(handle, id);
}
return id;
};
const solidStore: DocumentStore<SolidStoreHandle> = {
async createHandle(initialDoc) {
const draftDoc = structuredClone(initialDoc as Document);
const [docView, setDocView] = createStore<Document>(initialDoc as Document);
return { draftDoc, docView, setDocView, listeners: new Set() };
},
getDocumentView: (handle) => handle.docView,
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)),
getDocumentRef: (handle) => ({ id: solidStoreIdFor(handle), version: null, server: "" }),
// Link resolution omitted for brevity.
getHandle: async () => ({
tag: "Err",
content: [{ message: "This store cannot resolve references." }],
}),
};
const solidBinder = createBinder(solidStore);
const basicObject = defineObject({ tag: "Basic", content: "Object" });
const symmetricListMorphism = defineMorphism(
{ tag: "Hom", content: basicObject.obType },
{
domain: { apply: { tag: "Basic", content: "tensor" }, modality: "SymmetricList" },
codomain: { apply: { tag: "Basic", content: "tensor" }, modality: "SymmetricList" },
},
);
type BasicObCell = NotebookCell<typeof basicObject>;
type SymmetricListCell = NotebookCell<typeof symmetricListMorphism>;
const GenericShape = defineShape({
objects: [basicObject],
morphisms: [symmetricListMorphism],
informal: [RichText],
});
function ObListEditor(props: { objects: BasicObCell[] }) {
return <span>[{props.objects.map((place) => place.label).join(", ")}]</span>;
}
function MorphismCellEditor(props: {
notebook: Notebook<typeof GenericShape>;
morphism: SymmetricListCell;
}) {
// Contrived test example: adding an arbitrary but valid input place
const runTestMutation = () => {
const referenced = new Set(
[...props.morphism.from, ...props.morphism.to].map((ob) => ob.id),
);
const input = props.notebook.cellsOf(basicObject).find((ob) => !referenced.has(ob.id));
if (input) {
props.morphism.update({ from: [...props.morphism.from, input] });
}
};
return (
<li>
<span class="cell-label">
Transition: <ObListEditor objects={props.morphism.from} />
<span> -> </span>
<ObListEditor objects={props.morphism.to} />
<span>{props.morphism.label}</span>
</span>
<button aria-label="run test mutation" onClick={runTestMutation} />
</li>
);
}
function ModelCellEditor(props: {
notebook: Notebook<typeof GenericShape>;
cell: NotebookCell<typeof GenericShape>;
}) {
const cell = props.cell;
if (cell.kind === CellKind.Morphism) {
return <MorphismCellEditor notebook={props.notebook} morphism={cell} />;
}
if (cell.kind === CellKind.Object) {
return (
<li>
<span class="cell-label">Place: {cell.label}</span>
</li>
);
}
if (cell.kind === CellKind.RichText) {
return (
<li>
<span class="cell-label">Text: {cell.content}</span>
</li>
);
}
return null;
}
function ModelNotebookEditor(props: { notebook: Notebook<typeof GenericShape> }) {
return (
<section>
<h1>{props.notebook.title}</h1>
<ul>
<For each={props.notebook.cellsOf(GenericShape)}>
{(cell) => <ModelCellEditor notebook={props.notebook} cell={cell} />}
</For>
</ul>
</section>
);
}const notebook = await solidBinder.createNotebook(PetriNet, { title: "Petri net" });
const a = notebook.add(Place, { label: "A" });
notebook.add(Place, { label: "B" });
const c = notebook.add(Place, { label: "C" });
notebook.add(Transition, { label: "fires", from: [a], to: [c] });
const container = document.createElement("div");
document.body.appendChild(container);
const dispose = render(() => <ModelNotebookEditor notebook={notebook} />, container);
console.log(container.innerHTML);
const appendButton = container.querySelector<HTMLButtonElement>(
'[aria-label="run test mutation"]',
)!;
appendButton.click();
console.log(container.innerHTML);
dispose();<section><h1>Petri net</h1><ul><li><span class="cell-label">Place: A</span></li><li><span class="cell-label">Place: B</span></li><li><span class="cell-label">Place: C</span></li><li><span class="cell-label">Transition: <span>[A<!---->]</span><span> -> </span><span>[C<!---->]</span><span>fires</span></span><button aria-label="run test mutation"></button></li></ul></section>
<section><h1>Petri net</h1><ul><li><span class="cell-label">Place: A</span></li><li><span class="cell-label">Place: B</span></li><li><span class="cell-label">Place: C</span></li><li><span class="cell-label">Transition: <span>[A, B<!---->]</span><span> -> </span><span>[C<!---->]</span><span>fires</span></span><button aria-label="run test mutation"></button></li></ul></section>
2.14.5.2 SolidJS example with validation & completions
Wiring in the validated model through the components for completions looks something like this, using onValidate to feed a signal with validation results. Future work could be to bring the validated model API closer to our notebook API.
Code example
import {
type NotebookCell,
CellKind,
createBinder,
defineMorphism,
defineObject,
defineShape,
type DocumentStore,
type ModelValidationResult,
type Notebook,
RichText,
type ValidatableNotebook,
} from "catcolab-documents";
import {
createContext,
createSignal,
For,
onCleanup,
Show,
useContext,
type Accessor,
} from "solid-js";
import { createStore, reconcile, type SetStoreFunction, unwrap } from "solid-js/store";
import { render } from "solid-js/web";
import type { Document } from "catcolab-document-types";
import type { DblModel, ObType, QualifiedName } from "catlog-wasm";
import { selfResolving } from "document-methods/test/self_resolving";
type SolidStoreHandle = {
draftDoc: Document;
docView: Document;
setDocView: SetStoreFunction<Document>;
listeners: Set<() => void>;
};
const solidStore: DocumentStore<SolidStoreHandle> = {
async createHandle(initialDoc) {
const draftDoc = structuredClone(initialDoc as Document);
const [docView, setDocView] = createStore<Document>(initialDoc as Document);
return { draftDoc, docView, setDocView, listeners: new Set() };
},
getDocumentView: (handle) => handle.docView,
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)),
...selfResolving<SolidStoreHandle>(),
};
const solidBinder = createBinder(solidStore);const MyEntity = defineObject({ tag: "Basic", content: "Entity" });
const MyAttrType = defineObject({ tag: "Basic", content: "AttrType" });
const MyAttr = defineMorphism(
{ tag: "Basic", content: "Attr" },
{ domain: MyEntity.obType, codomain: MyAttrType.obType },
);
const MyShape = defineShape({
objects: [MyEntity, MyAttrType],
morphisms: [MyAttr],
informal: [RichText],
});
type MyNotebook = Notebook<typeof MyShape>;function label(model: DblModel, id: QualifiedName): string {
return model.obGeneratorLabel(id)?.join(".") ?? "?";
}
function filteredCompletions(model: DblModel, obType: ObType, text: string): QualifiedName[] {
const needle = text.toLowerCase();
return model
.obGeneratorsWithType(obType)
.filter((id) => label(model, id).toLowerCase().includes(needle));
}
function codomainLabel(model: DblModel | undefined, morphism: NotebookCell<typeof MyAttr>): string {
if (!model) {
return "?";
}
const cod = model?.morPresentation(morphism.id)?.cod;
return cod?.tag === "Basic" ? label(model, cod.content) : "?";
}type MyContextValue = {
notebook: MyNotebook;
model: Accessor<DblModel>;
};
const MyContext = createContext<MyContextValue>();
function useMyContext() {
const context = useContext(MyContext);
if (!context) {
throw new Error("Schema editor context is missing.");
}
return context;
}
function selectCodomain(
notebook: MyNotebook,
attrCell: NotebookCell<typeof MyAttr>,
id: QualifiedName,
) {
const result = notebook.get(MyAttrType, id);
if (result.tag === "Ok") {
attrCell.update({ to: result.content });
}
}
function CompletionPicker(props: {
obType: ObType;
selected: string;
text: Accessor<string>;
onSelect: (id: QualifiedName) => void;
}) {
const context = useMyContext();
return (
<span class="picker">
<span class="selected">{props.selected}</span>
<ul class="completion-list">
<For each={filteredCompletions(context.model(), props.obType, props.text())}>
{(id) => (
<li onClick={() => props.onSelect(id)}>{label(context.model(), id)}</li>
)}
</For>
</ul>
</span>
);
}
function MyAttrCellEditor(props: {
attrCell: NotebookCell<typeof MyAttr>;
text: Accessor<string>;
}) {
const context = useMyContext();
return (
<li>
Attr: {props.attrCell.label}: {props.attrCell.from.label} ->{" "}
<CompletionPicker
obType={MyAttrType.obType}
selected={codomainLabel(context.model(), props.attrCell)}
text={props.text}
onSelect={(id) => selectCodomain(context.notebook, props.attrCell, id)}
/>
</li>
);
}
function MyCellEditor(props: { cell: NotebookCell<typeof MyShape>; text: Accessor<string> }) {
if (props.cell.kind === CellKind.Morphism) {
return <MyAttrCellEditor attrCell={props.cell} text={props.text} />;
}
if (props.cell.kind === CellKind.Object) {
const kind = props.cell.type === MyAttrType ? "MyAttrType" : "MyEntity";
return (
<li>
{kind}: {props.cell.label}
</li>
);
}
if (props.cell.kind === CellKind.RichText) {
return <li>Text: {props.cell.content}</li>;
}
return null;
}
// Globals for testing
let testCurrentModel!: () => DblModel | undefined;
function MyNotebookEditor(props: {
notebook: MyNotebook & ValidatableNotebook;
text: Accessor<string>;
}) {
// `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<ModelValidationResult>();
const unsubscribe = props.notebook.onValidate(setValidation);
onCleanup(unsubscribe);
const model = () => {
const result = validation();
return result?.tag === "Ok" ? result.content : undefined;
};
testCurrentModel = model;
return (
<section>
<h1>{props.notebook.title}</h1>
<Show when={model()} fallback={<p>Validating...</p>}>
{(m) => (
<MyContext.Provider value={{ notebook: props.notebook, model: m }}>
<ul>
<For each={props.notebook.cellsOf(MyShape)}>
{(cell) => <MyCellEditor cell={cell} text={props.text} />}
</For>
</ul>
</MyContext.Provider>
)}
</Show>
</section>
);
}import { Attr, AttrType, Entity, SimpleSchema } from "catcolab-logics/simple-schema";
async function until(predicate: () => boolean) {
while (!predicate()) {
await new Promise((resolve) => setTimeout(resolve));
}
}
const notebook = await solidBinder.createNotebook(SimpleSchema, { title: "Company schema" });
const person = notebook.add(Entity, { label: "Person" });
const string = notebook.add(AttrType, { label: "String" });
notebook.add(AttrType, { label: "Integer" });
notebook.add(AttrType, { label: "Boolean" });
const name = notebook.add(Attr, { label: "name", from: person, to: string });
const [text, setText] = createSignal("");
const container = document.createElement("div");
document.body.appendChild(container);
const dispose = render(() => <MyNotebookEditor notebook={notebook} text={text} />, container);
await until(() => testCurrentModel() !== undefined);
console.log(
"all:",
[...container.querySelectorAll(".completion-list li")].map((li) => li.textContent).join(", "),
);
console.log("codomain:", codomainLabel(testCurrentModel(), name));
setText("in");
console.log(
"filtered:",
[...container.querySelectorAll(".completion-list li")].map((li) => li.textContent).join(", "),
);
const model = testCurrentModel();
if (!model) {
throw new Error("Expected a valid model.");
}
const integer = filteredCompletions(model, MyAttrType.obType, "in").find(
(id) => label(model, id) === "Integer",
)!;
selectCodomain(notebook, name, integer);
await until(() => codomainLabel(testCurrentModel(), name) === "Integer");
console.log("codomain:", codomainLabel(testCurrentModel(), name));
dispose();
container.remove();all: String, Integer, Boolean
codomain: String
filtered: String, Integer
codomain: Integer
2.15 Rich text handling
Rich text is a special case because our editor components will need to bypass our notebook interface to work with ProseMirror and the Automerge plugin for ProseMirror.
We need to define a getRichTextRef method on our store for this to work. This will add a editorRef field to the RichText cell that our editor component can make use of.
Code example
import * as Automerge from "@automerge/automerge";
import { type Doc, getBackend, getObjectId, type Patch } from "@automerge/automerge";
import {
type DocHandle,
type DocHandleChangePayload,
type Prop,
Repo,
} from "@automerge/automerge-repo";
import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives";
import { basicSchemaAdapter, init } from "@automerge/prosemirror";
import {
createBinder,
defineShape,
type DocumentStore,
RichText,
type RichTextCell,
} from "catcolab-documents";
import { SimpleOlog } from "catcolab-logics/simple-olog";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { createEffect, createSignal, onCleanup } from "solid-js";
import { For, render } from "solid-js/web";
import { expect } from "vitest";
import type { Document } from "catcolab-document-types";
type StoreHandle = {
readonly docHandle: DocHandle<Document>;
readonly docView: Document;
};
function makeAutomergeRichTextStore(): DocumentStore<StoreHandle> {
const repo = new Repo();
return {
createHandle: async (initialDoc) => {
const docHandle = repo.create<Document>(initialDoc as Document);
return { docHandle, docView: makeDocumentProjection(docHandle) };
},
getDocumentView: (handle) => handle.docView,
changeDocument: (handle, fn) => handle.docHandle.change(fn),
subscribe: (handle, callback) => {
handle.docHandle.on("change", callback);
return () => handle.docHandle.off("change", callback);
},
copyValue: (handle, value) => {
const objId = getObjectId(value as object);
return getBackend(handle.docHandle.doc()).materialize(objId!) as typeof value;
},
getDocumentRef: (handle) => ({
id: handle.docHandle.documentId,
version: null,
server: "",
}),
getHandle: async () => ({
tag: "Err",
content: [{ message: "This store cannot resolve references." }],
}),
// This is needed for the RichTextCellEditor to bypass the notebook interface.
getRichTextRef: (handle, cellId) => ({
docHandle: handle.docHandle,
path: ["notebook", "cellContents", cellId, "content"],
}),
};
}
function isPathPrefixOf(candidate: Prop[], target: readonly Prop[]) {
return (
candidate.length <= target.length &&
candidate.every((part, index) => part === target[index])
);
}
function hasStructuralReplacement(patches: Patch[], textPath: readonly Prop[]) {
return patches.some(
(patch) =>
(patch.action === "put" || patch.action === "del") &&
isPathPrefixOf(patch.path, textPath),
);
}
function RichTextCellEditor(props: { cell: RichTextCell; onView: (view: EditorView) => void }) {
let editorRoot!: HTMLDivElement;
const [reinitTrigger, setReinitTrigger] = createSignal(0);
createEffect(() => {
void reinitTrigger();
// The editorRef is exposed on the cell.
const ref = props.cell.editorRef;
if (!ref) {
throw new Error("RichTextCellEditor: cell has no editorRef");
}
const docHandle = ref.docHandle as DocHandle<unknown>;
const path = [...ref.path];
const { schema, pmDoc, plugin } = init(docHandle, path, {
schemaAdapter: basicSchemaAdapter,
});
const state = EditorState.create({ schema, plugins: [plugin], doc: pmDoc });
const view: EditorView = new EditorView(editorRoot, {
state,
dispatchTransaction: (tx) => {
if (!view.isDestroyed) {
view.updateState(view.state.apply(tx));
}
},
});
const onRemoteChange = (payload: DocHandleChangePayload<unknown>) => {
if (hasStructuralReplacement(payload.patches, path)) {
setReinitTrigger((n) => n + 1);
}
};
docHandle.on("change", onRemoteChange);
props.onView(view);
onCleanup(() => {
docHandle.off("change", onRemoteChange);
view.destroy();
});
});
return <div class="rich-text-cell" ref={editorRoot} />;
}const InformalShape = defineShape({ informal: [RichText] });
const store = makeAutomergeRichTextStore();
const frontendBinder = createBinder(store);
const notebook = await frontendBinder.createNotebook(SimpleOlog, { title: "Notes" });
const note = notebook.add(RichText, { content: "" });
let view: EditorView | undefined;
const container = document.createElement("div");
document.body.appendChild(container);
const dispose = render(
() => (
<For each={notebook.cellsOf(InformalShape)}>
{(cell) => <RichTextCellEditor cell={cell} onView={(nextView) => (view = nextView)} />}
</For>
),
container,
);
view!.dispatch(view!.state.tr.insertText("Hello from ProseMirror"));
expect(note.content).toBe("Hello from ProseMirror");
const ref = note.editorRef!;
const docHandle = ref.docHandle as DocHandle<Document>;
docHandle.change((doc) => {
Automerge.splice(doc as Doc<unknown>, [...ref.path], 0, 5, "Howdy");
});
expect(view!.state.doc.textContent).toBe("Howdy from ProseMirror");
const staleView = view;
note.update({ content: "Replaced programmatically" });
expect(view).not.toBe(staleView);
expect(staleView?.isDestroyed).toBe(true);
expect(view!.state.doc.textContent).toBe("Replaced programmatically");
dispose();
container.remove();2.16 Future extensions
2.16.1 Path equations
As this RFC is written, path equations are being added to the simple-olog and simple-schema theories in the frontend. PathEquation is another cell type, with a CellKind.PathEquation discriminant, and a shape opts into it via supportsEquations: true.
import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
import { binder, PathEquation } from "catcolab-documents";
const notebook = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
const person = notebook.add(Entity, { label: "Person" });
const department = notebook.add(Entity, { label: "Department" });
const company = notebook.add(Entity, { label: "Company" });
const worksIn = notebook.add(Mapping, { label: "works in", from: person, to: department });
const partOf = notebook.add(Mapping, { label: "part of", from: department, to: company });
const employer = notebook.add(Mapping, { label: "employer", from: person, to: company });
// An employee's employer is the company their department is part of.
const equation = notebook.add(PathEquation, {
label: "employment",
lhs: [worksIn, partOf],
rhs: [employer],
});Code verification
console.log("equation:", equation.label);
console.log("lhs:", equation.lhs.map((step) => step.label).join(" ; "));
console.log("rhs:", equation.rhs.map((step) => step.label).join(" ; "));
console.log("equations:", notebook.cellsOf(PathEquation).length);
const result = await notebook.validate();
console.log("valid:", result.tag === "Ok");equation: employment
lhs: works in ; part of
rhs: employer
equations: 1
valid: true
2.16.2 Tabular instances
Tabular instances are being added to CatColab. We can envision an API for instances that mirrors our Notebook API.
import { binder } from "catcolab-documents";
import { SimpleSchema, Mapping, Attr, Entity, AttrType } from "catcolab-logics/simple-schema";
const schema = await binder.createNotebook(SimpleSchema, { title: "Company schema" });
const person = schema.add(Entity, { label: "Person" });
const company = schema.add(Entity, { label: "Company" });
const str = schema.add(AttrType, { label: "String" });
const employer = schema.add(Mapping, { label: "employer", from: person, to: company });
const name = schema.add(Attr, { label: "name", from: person, to: str });An instance can have a very similar add method.
const instance = await binder.createInstance(schema, { title: "Company instance" });
const acme = instance.add(company, {});
instance.add(person, { name: "Alice", employer: acme });
instance.add(person, { name: "Bob", employer: acme });A “table” could be retrieved via a rowsOf that mirrors cellsOf though, unlike cells it may be better to keep a separate values field on rows, to keep them in their own namespace.
for (const row of instance.rowsOf(person)) {
console.log(row.values["name"]);
}Alice
Bob
Our API could in principle allow us to define logic shapes as notebooks of SimpleSchema. To illustrate this here is the SimpleSchema logic defined as a notebook of SimpleSchema by aliasing the real SimpleSchema to SuperSchema first.
import {
Attr as SuperAttr,
AttrType as SuperAttrType,
Entity as SuperEntity,
Mapping as SuperMapping,
SimpleSchema as SuperSchema,
} from "catcolab-logics/simple-schema";
import { binder } from "catcolab-documents";
const SimpleSchema = await binder.createNotebook(SuperSchema, { title: "simple-schema" });
const Entity = SimpleSchema.add(SuperEntity, { label: "Entity" });
const AttrType = SimpleSchema.add(SuperEntity, { label: "AttrType" });
const String = SimpleSchema.add(SuperAttrType, { label: "String" });
const Mapping = SimpleSchema.add(SuperEntity, { label: "Mapping" });
SimpleSchema.add(SuperMapping, { label: "from", from: Mapping, to: Entity });
SimpleSchema.add(SuperMapping, { label: "to", from: Mapping, to: Entity });
SimpleSchema.add(SuperAttr, { label: "label", from: Mapping, to: String });
const Attr = SimpleSchema.add(SuperEntity, { label: "Attr" });
SimpleSchema.add(SuperMapping, { label: "from", from: Attr, to: Entity });
SimpleSchema.add(SuperMapping, { label: "to", from: Attr, to: AttrType });
SimpleSchema.add(SuperAttr, { label: "label", from: Entity, to: String });Schemas can can then be defined as instances of the SimpleSchema notebook as follows.
const schema = await binder.createInstance(SimpleSchema, { title: "Company schema" });
const person = schema.add(Entity, { label: "Person" });
const company = schema.add(Entity, { label: "Company" });
const str = schema.add(AttrType, { label: "String" });
const employer = schema.add(Mapping, { label: "employer", from: person, to: company });
const name = schema.add(Attr, { label: "name", from: person, to: str });To use this flexibility for defining logics through notebooks in practice a lot of details would still need to be worked out of course.
2.16.3 Validated models
There is some awkwardness in the discrepancy between the validated model and the corresponding notebook. While we can clearly define the shape of the notebook we are working with, when it comes to querying the validated model we just have a DblModel and its associated methods that don’t let us work with the same shapes and formal content types we have already defined.
This can be seen in the SolidJS example with validation & completions in e.g. this extract:
function label(model: DblModel, id: QualifiedName): string {
return model.obGeneratorLabel(id)?.join(".") ?? "?";
}
function filteredCompletions(model: DblModel, obType: ObType, text: string): QualifiedName[] {
const needle = text.toLowerCase();
return model
.obGeneratorsWithType(obType)
.filter((id) => label(model, id).toLowerCase().includes(needle));
}
function codomainLabel(model: DblModel | undefined, morphism: NotebookCell<typeof MyAttr>): string {
if (!model) {
return "?";
}
const cod = model?.morPresentation(morphism.id)?.cod;
return cod?.tag === "Basic" ? label(model, cod.content) : "?";
}We can instead imagine passing around a validated model that is aware of the underlying shape of the formal content and could be queried similar to how our notebooks can be queried.
const model: ValidatedModel<typeof SimpleSchema> = await notebook.validate();
for (const mapping of model.judgmentsOf(Mapping)) {
console.log(mapping.label);
console.log(mapping.from);
console.log(mapping.to);
}2.17 Rollout
Our implementation should loosely follow these steps.
Test suite for model notebooks
We will implement the tests first. This very document forms part of a spec we can test against. We should write some more tests covering further feature details and edge cases. If possible we should implement property based tests that show our new API will not cause document corruption.
Package for model notebooks
A package that meets the spec outline in the test suite. We will not implement diagram or analysis notebooks initially.
CatColab script execution
Our initial feature will be a way, possibly through a secret “developer” interface, a user can write Javascript that makes use of this API to create and edit model notebooks.
LLM integration in CatColab
Our LLM integration should make use of the CatColab scripting capability to build out a way for users to use LLMs to write scripts.
Test suite & implement analyses and diagrams
We will spec and implement a package for analyses and add diagram capability to the relevant models. These will become available to the script execution and LLM integration.
New features use the API & port existing features
Once we have proven the stability and usefuleness of the new API we should dictate that any new features need to be made using the new API. We will also begin the process of porting existing features to the new API.