Introduction
Attributed C-Sets (ACSets) are a category-theoretic generalization of relational databases and graphs. If you’re familiar with data frames or SQL tables, you can think of an ACSet as a collection of interlinked tables where the links (foreign keys) and data columns are all specified up front by a schema.
More formally, an ACSet is a functor from a schema category to the category of sets — but you don’t need to know category theory to use this package. The key ideas are:
- Objects are like tables (e.g., vertices, edges).
- Morphisms (homs) are like foreign keys linking one table to another (e.g., every edge has a source vertex and a target vertex).
- Attributes attach data values to objects (e.g., a vertex label or an edge weight).
This framework is powerful enough to represent directed graphs, Petri nets, wiring diagrams, and many other structured data types — all with a uniform API for creation, querying, mutation, and serialization.
The acsets package is an R port of the AlgebraicJulia
ACSets.jl package.
Schemas
A schema defines the shape of your data. Let’s start with the simplest interesting example: a directed graph.
A directed graph has two kinds of things:
- V — vertices
- E — edges
And two relationships:
- src — every edge has a source vertex
- tgt — every edge has a target vertex
SchGraph <- BasicSchema(
obs = c("V", "E"),
homs = list(hom("src", "E", "V"),
hom("tgt", "E", "V")),
attrtypes = character(),
attrs = list()
)
SchGraph
#> <acsets::BasicSchema>
#> @ obs : chr [1:2] "V" "E"
#> @ homs :List of 2
#> .. $ :List of 3
#> .. ..$ name : chr "src"
#> .. ..$ dom : chr "E"
#> .. ..$ codom: chr "V"
#> .. $ :List of 3
#> .. ..$ name : chr "tgt"
#> .. ..$ dom : chr "E"
#> .. ..$ codom: chr "V"
#> @ attrtypes: chr(0)
#> @ attrs : list()The hom() helper creates a morphism specification. Its
three arguments are the morphism name, the domain (source object), and
the codomain (target object). Here, hom("src", "E", "V")
means “src is a function from edges to vertices”.
You can inspect a schema with accessor functions:
objects(SchGraph)
#> [1] "V" "E"
homs(SchGraph)
#> [[1]]
#> [[1]]$name
#> [1] "src"
#>
#> [[1]]$dom
#> [1] "E"
#>
#> [[1]]$codom
#> [1] "V"
#>
#>
#> [[2]]
#> [[2]]$name
#> [1] "tgt"
#>
#> [[2]]$dom
#> [1] "E"
#>
#> [[2]]$codom
#> [1] "V"Adding attributes
Plain graphs are useful, but often we want to attach data — labels, weights, colours. Attributes serve this purpose. Let’s define a labelled weighted graph:
SchLWGraph <- BasicSchema(
obs = c("V", "E"),
homs = list(hom("src", "E", "V"),
hom("tgt", "E", "V")),
attrtypes = c("String", "Numeric"),
attrs = list(attr_spec("label", "V", "String"),
attr_spec("weight", "E", "Numeric"))
)The attr_spec() helper works like hom() but
the codomain is an attribute type rather than an object.
Attribute types are symbolic names (like "String" or
"Numeric") — they tell the schema what kind of data lives
in that column, but the actual R type is determined at runtime.
attrs(SchLWGraph)
#> [[1]]
#> [[1]]$name
#> [1] "label"
#>
#> [[1]]$dom
#> [1] "V"
#>
#> [[1]]$codom
#> [1] "String"
#>
#>
#> [[2]]
#> [[2]]$name
#> [1] "weight"
#>
#> [[2]]$dom
#> [1] "E"
#>
#> [[2]]$codom
#> [1] "Numeric"
attrtypes(SchLWGraph)
#> [1] "String" "Numeric"The arrows() function returns all arrows (both morphisms
and attributes), and dom() / codom() look up
the domain and codomain of any arrow by name:
Creating and Populating ACSets
From a schema
Given a schema, you can create an empty ACSet:
g <- ACSet(SchGraph)
g
#> <acsets::ACSet>
#> @ schema: <acsets::BasicSchema>
#> .. @ obs : chr [1:2] "V" "E"
#> .. @ homs :List of 2
#> .. .. $ :List of 3
#> .. .. ..$ name : chr "src"
#> .. .. ..$ dom : chr "E"
#> .. .. ..$ codom: chr "V"
#> .. .. $ :List of 3
#> .. .. ..$ name : chr "tgt"
#> .. .. ..$ dom : chr "E"
#> .. .. ..$ codom: chr "V"
#> .. @ attrtypes: chr(0)
#> .. @ attrs : list()
#> @ .data :<environment: 0x564ad7234638>Adding parts
Use add_part() to add a single part (row) to an object
(table). It returns the new part’s integer ID. Use
add_parts() to add multiple parts at once.
When adding edges, you can set morphism values inline:
e1 <- add_part(g, "E", src = 1, tgt = 2)
e2 <- add_part(g, "E", src = 2, tgt = 3)
e3 <- add_part(g, "E", src = 3, tgt = 1)Batch addition with add_parts():
The factory pattern
For repeated use of the same schema, acset_type()
creates a dedicated constructor function. The index
argument specifies which morphisms or attributes should be indexed for
fast reverse lookups.
Graph <- acset_type(SchGraph, name = "Graph", index = c("src", "tgt"))The constructor accepts initial data — object counts and arrow values:
g <- Graph(V = 4, E = 4,
src = c(1, 1, 2, 3),
tgt = c(2, 3, 3, 4))
g
#> <acsets::ACSet>
#> @ schema: <acsets::BasicSchema>
#> .. @ obs : chr [1:2] "V" "E"
#> .. @ homs :List of 2
#> .. .. $ :List of 3
#> .. .. ..$ name : chr "src"
#> .. .. ..$ dom : chr "E"
#> .. .. ..$ codom: chr "V"
#> .. .. $ :List of 3
#> .. .. ..$ name : chr "tgt"
#> .. .. ..$ dom : chr "E"
#> .. .. ..$ codom: chr "V"
#> .. @ attrtypes: chr(0)
#> .. @ attrs : list()
#> @ .data :<environment: 0x564ad683b188>This is the most convenient way to create ACSets. Let’s also create a constructor for our labelled weighted graph schema:
Querying ACSets
subpart: reading values
subpart() is the primary read function. It retrieves
morphism or attribute values for one or more parts.
# Source vertex of edge 1
subpart(g, 1, "src")
#> [1] 1
# Target vertex of edge 2
subpart(g, 2, "tgt")
#> [1] 3Pass NULL to get values for all parts:
For the labelled graph, reading attributes works the same way:
Composed subparts
You can follow a chain of morphisms by passing a character vector. For example, to get the label of the source vertex of edge 1:
This is equivalent to
subpart(lg, subpart(lg, 1, "src"), "label") but more
concise.
incident: reverse lookup
incident() finds all parts whose subpart value matches a
given value. For example, “which edges have vertex 2 as their
target?”
incident(g, 2, "tgt")
#> [1] 1With indexing enabled (as we did with
index = c("src", "tgt")), this is an O(1) lookup.
# All edges originating from vertex 1
incident(g, 1, "src")
#> [1] 1 2Data frames and tables
as_data_frame() converts a single object to a data
frame:
as_data_frame(g, "E")
#> id src tgt
#> 1 1 1 2
#> 2 2 1 3
#> 3 3 2 3
#> 4 4 3 4tables() returns all objects as a named list of data
frames:
tables(lg)
#> $V
#> id label
#> 1 1 A
#> 2 2 B
#> 3 3 C
#>
#> $E
#> id src tgt weight
#> 1 1 1 2 1.5
#> 2 2 2 3 2.5Utility queries
nparts(g, "V")
#> [1] 4
parts(g, "E")
#> [1] 1 2 3 4
has_part(g, "V", 3)
#> [1] TRUE
has_part(g, "V", 99)
#> [1] FALSE
has_subpart(g, "src")
#> [1] TRUE
has_subpart(g, "colour")
#> [1] FALSEQuery DSL
The package provides a SQL-like query interface using
From(), Where(), and Select(),
which can be chained with the pipe operator:
# Find all edges from vertex 1
From(g, "E") |>
Where("src", `==`, 1) |>
Select("src", "tgt")
#> id src tgt
#> 1 1 1 2
#> 2 2 1 3Where() accepts any binary comparison operator:
Mutation
ACSets are mutable. They use reference semantics
(backed by R environments), so modifying an ACSet changes it in place —
copies are not made automatically. Use
copy_acset() when you need an independent copy.
set_subpart
Set a single morphism or attribute value:
g_mut <- Graph(V = 3, E = 2, src = c(1, 2), tgt = c(2, 3))
as_data_frame(g_mut, "E")
#> id src tgt
#> 1 1 1 2
#> 2 2 2 3
set_subpart(g_mut, 1, "tgt", 3)
as_data_frame(g_mut, "E")
#> id src tgt
#> 1 1 1 3
#> 2 2 2 3set_subparts
Set multiple morphisms/attributes at once for a given part:
set_subparts(g_mut, 2, src = 3, tgt = 1)
as_data_frame(g_mut, "E")
#> id src tgt
#> 1 1 1 3
#> 2 2 3 1clear_subpart
Reset a value to NA:
wg <- LWGraph(V = 2, E = 1, src = 1, tgt = 2, weight = 10)
subpart(wg, 1, "weight")
#> [1] 10
clear_subpart(wg, 1, "weight")
subpart(wg, 1, "weight")
#> [1] NAReference semantics
Because ACSets are mutable, assignment does not create an independent copy:
g_ref <- g_mut
set_subpart(g_ref, 1, "src", 3)
# g_mut is also changed!
subpart(g_mut, 1, "src")
#> [1] 3Use copy_acset() for a true deep copy:
g_copy <- copy_acset(g_mut)
set_subpart(g_copy, 1, "src", 1)
# g_mut is unaffected
subpart(g_mut, 1, "src")
#> [1] 3
subpart(g_copy, 1, "src")
#> [1] 1Deletion
rem_part: pop-and-swap
rem_part() removes a part using a
pop-and-swap strategy: the last part takes the deleted
part’s ID. This gives O(1) deletion but means part IDs can change.
Morphisms pointing to the removed part are cleared to
NA.
g_del <- Graph(V = 4, E = 3, src = c(1, 2, 3), tgt = c(2, 3, 4))
as_data_frame(g_del, "E")
#> id src tgt
#> 1 1 1 2
#> 2 2 2 3
#> 3 3 3 4
# Remove vertex 2: vertex 4 (the last) takes ID 2
rem_part(g_del, "V", 2)
as_data_frame(g_del, "V")
#> id
#> 1 1
#> 2 2
#> 3 3Edges that referenced the removed vertex have their values cleared, while edges that referenced the last vertex (4) are updated to the new ID (2):
as_data_frame(g_del, "E")
#> id src tgt
#> 1 1 1 NA
#> 2 2 NA 3
#> 3 3 3 2cascading_rem_part: cascading deletion
cascading_rem_part() removes a part and
all parts in other objects that reference it via morphisms. This is
analogous to ON DELETE CASCADE in SQL.
g_casc <- Graph(V = 4, E = 4,
src = c(1, 1, 2, 3),
tgt = c(2, 3, 3, 4))
cat("Before: V =", nparts(g_casc, "V"), ", E =", nparts(g_casc, "E"), "\n")
#> Before: V = 4 , E = 4
as_data_frame(g_casc, "E")
#> id src tgt
#> 1 1 1 2
#> 2 2 1 3
#> 3 3 2 3
#> 4 4 3 4
# Remove vertex 3: all edges that reference vertex 3 are also removed
cascading_rem_part(g_casc, "V", 3)
cat("After: V =", nparts(g_casc, "V"), ", E =", nparts(g_casc, "E"), "\n")
#> After: V = 3 , E = 1
as_data_frame(g_casc, "E")
#> id src tgt
#> 1 1 1 2JSON Serialization
ACSets can be serialized to and from JSON, making it easy to exchange data with other ACSet implementations (Python, Julia, TypeScript).
In-memory round-trip
g_json <- Graph(V = 3, E = 3,
src = c(1, 2, 3),
tgt = c(2, 3, 1))
json_data <- generate_json_acset(g_json)
str(json_data, max.level = 2)
#> List of 2
#> $ V:List of 3
#> ..$ :List of 1
#> ..$ :List of 1
#> ..$ :List of 1
#> $ E:List of 3
#> ..$ :List of 3
#> ..$ :List of 3
#> ..$ :List of 3Parse it back using the same constructor:
g_parsed <- parse_json_acset(Graph, json_data)
acset_equal(g_json, g_parsed)
#> [1] TRUEFile round-trip
tmp <- tempfile(fileext = ".json")
write_json_acset(g_json, tmp)
g_from_file <- read_json_acset(Graph, tmp)
acset_equal(g_json, g_from_file)
#> [1] TRUEYou can also serialize the schema itself:
str(generate_json_schema(SchGraph))
#> List of 4
#> $ obs :List of 2
#> ..$ : chr "V"
#> ..$ : chr "E"
#> $ homs :List of 2
#> ..$ :List of 3
#> .. ..$ name : chr "src"
#> .. ..$ dom : chr "E"
#> .. ..$ codom: chr "V"
#> ..$ :List of 3
#> .. ..$ name : chr "tgt"
#> .. ..$ dom : chr "E"
#> .. ..$ codom: chr "V"
#> $ attrtypes: list()
#> $ attrs : list()Example: Petri Net
A Petri net is a bipartite graph used to model concurrent systems. It has:
- S — species (places)
- T — transitions
- I — input arcs (from a species to a transition)
- O — output arcs (from a transition to a species)
Let’s model an SIR epidemiological model as a Petri net.
Build the SIR model
PetriNet <- acset_type(SchPetriNet, name = "PetriNet",
index = c("is", "it", "os", "ot"))
sir <- PetriNet()
# Species
s_S <- add_part(sir, "S", sname = "S")
s_I <- add_part(sir, "S", sname = "I")
s_R <- add_part(sir, "S", sname = "R")
# Transitions
t_inf <- add_part(sir, "T", tname = "infection")
t_rec <- add_part(sir, "T", tname = "recovery")
# Infection: S + I → 2I
invisible(add_part(sir, "I", is = s_S, it = t_inf))
invisible(add_part(sir, "I", is = s_I, it = t_inf))
invisible(add_part(sir, "O", os = s_I, ot = t_inf))
invisible(add_part(sir, "O", os = s_I, ot = t_inf))
# Recovery: I → R
invisible(add_part(sir, "I", is = s_I, it = t_rec))
invisible(add_part(sir, "O", os = s_R, ot = t_rec))Inspect the model
tables(sir)
#> $S
#> id sname
#> 1 1 S
#> 2 2 I
#> 3 3 R
#>
#> $T
#> id tname
#> 1 1 infection
#> 2 2 recovery
#>
#> $I
#> id is it
#> 1 1 1 1
#> 2 2 2 1
#> 3 3 2 2
#>
#> $O
#> id os ot
#> 1 1 2 1
#> 2 2 2 1
#> 3 3 3 2Query the model
Which species feed into the infection transition?
inf_inputs <- incident(sir, t_inf, "it")
inf_inputs
#> [1] 1 2What are their names? We can use composed subparts:
Which species are produced by recovery?
Cascading deletion
What happens if we remove the infection transition?
sir2 <- copy_acset(sir)
cascading_rem_part(sir2, "T", t_inf)All input and output arcs connected to infection are removed:
tables(sir2)
#> $S
#> id sname
#> 1 1 S
#> 2 2 I
#> 3 3 R
#>
#> $T
#> id tname
#> 1 1 recovery
#>
#> $I
#> id is it
#> 1 1 2 1
#>
#> $O
#> id os ot
#> 1 1 3 1Example: Social Network
Let’s model a social network with people and friendships.
Define the schema
SchSocial <- BasicSchema(
obs = c("Person", "Friendship"),
homs = list(hom("person1", "Friendship", "Person"),
hom("person2", "Friendship", "Person")),
attrtypes = c("String"),
attrs = list(attr_spec("name", "Person", "String"),
attr_spec("since", "Friendship", "String"))
)
SocialNet <- acset_type(SchSocial, name = "SocialNet",
index = c("person1", "person2"))Build the network
net <- SocialNet()
alice <- add_part(net, "Person", name = "Alice")
bob <- add_part(net, "Person", name = "Bob")
carol <- add_part(net, "Person", name = "Carol")
dave <- add_part(net, "Person", name = "Dave")
invisible(add_part(net, "Friendship",
person1 = alice, person2 = bob, since = "2020-01-15"))
invisible(add_part(net, "Friendship",
person1 = alice, person2 = carol, since = "2021-06-01"))
invisible(add_part(net, "Friendship",
person1 = bob, person2 = dave, since = "2022-03-10"))
invisible(add_part(net, "Friendship",
person1 = carol, person2 = dave, since = "2023-09-20"))
tables(net)
#> $Person
#> id name
#> 1 1 Alice
#> 2 2 Bob
#> 3 3 Carol
#> 4 4 Dave
#>
#> $Friendship
#> id person1 person2 since
#> 1 1 1 2 2020-01-15
#> 2 2 1 3 2021-06-01
#> 3 3 2 4 2022-03-10
#> 4 4 3 4 2023-09-20Disjoint union
Suppose we have two separate networks and want to merge them:
net1 <- SocialNet()
invisible(add_parts(net1, "Person", 2, name = c("Xander", "Yara")))
invisible(add_part(net1, "Friendship",
person1 = 1, person2 = 2, since = "2024-01-01"))
net2 <- SocialNet()
invisible(add_parts(net2, "Person", 2, name = c("Zoe", "Will")))
invisible(add_part(net2, "Friendship",
person1 = 1, person2 = 2, since = "2024-06-15"))
merged <- disjoint_union(net1, net2)
tables(merged)
#> $Person
#> id name
#> 1 1 Xander
#> 2 2 Yara
#> 3 3 Zoe
#> 4 4 Will
#>
#> $Friendship
#> id person1 person2 since
#> 1 1 1 2 2024-01-01
#> 2 2 3 4 2024-06-15Note how the IDs from net2 are offset in the merged
result — the friendship from net2 now links persons 3 and 4
instead of 1 and 2.
Structural equality
net_copy <- copy_acset(net)
acset_equal(net, net_copy)
#> [1] TRUE