rafkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rafkit/__init__.py +29 -0
- rafkit/binary_polymer.py +185 -0
- rafkit/crs.py +130 -0
- rafkit/network.py +82 -0
- rafkit/raf.py +300 -0
- rafkit-0.1.0.dist-info/METADATA +164 -0
- rafkit-0.1.0.dist-info/RECORD +10 -0
- rafkit-0.1.0.dist-info/WHEEL +5 -0
- rafkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- rafkit-0.1.0.dist-info/top_level.txt +1 -0
rafkit/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""rafkit -- autocatalytic (RAF) sets in catalytic reaction networks.
|
|
2
|
+
|
|
3
|
+
A small, dependency-light implementation of RAF theory (Hordijk & Steel 2004):
|
|
4
|
+
maximal RAFs, the self-referential ("strictly autocatalytic") variant, irreducible
|
|
5
|
+
RAF sampling, and Kauffman's binary polymer model as a generator.
|
|
6
|
+
|
|
7
|
+
Every algorithm here is written from the published papers and carries hand-computed
|
|
8
|
+
known-answer tests, because a RAF algorithm that is subtly wrong produces plausible
|
|
9
|
+
numbers rather than errors.
|
|
10
|
+
|
|
11
|
+
See the README for the calibration against Steel, Hordijk & Smith (2012) and for
|
|
12
|
+
CatReNet interoperability.
|
|
13
|
+
"""
|
|
14
|
+
from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
|
|
15
|
+
from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
|
|
16
|
+
from rafkit.network import ReactionNetwork
|
|
17
|
+
from rafkit.raf import (
|
|
18
|
+
RafResult, catrenet_strictly_autocatalytic, exploitability, irrraf_census,
|
|
19
|
+
is_food_catalysed, max_raf, max_raf_strict, sample_irrraf,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BinaryPolymerNetwork", "binary_polymer", "ReactionNetwork",
|
|
26
|
+
"RafResult", "max_raf", "max_raf_strict", "sample_irrraf", "irrraf_census",
|
|
27
|
+
"exploitability", "is_food_catalysed", "catrenet_strictly_autocatalytic",
|
|
28
|
+
"parse_crs", "read_crs", "to_crs", "write_crs",
|
|
29
|
+
]
|
rafkit/binary_polymer.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""E4 — Kauffman's binary polymer model.
|
|
2
|
+
|
|
3
|
+
The canonical setting for the closure phase transition (Kauffman 1986; Hordijk &
|
|
4
|
+
Steel 2004), and the setting in which the closure phase transition is usually
|
|
5
|
+
located.
|
|
6
|
+
|
|
7
|
+
Molecules are binary strings of length 1..`max_len`. The food set is every string of
|
|
8
|
+
length <= `food_len`. Reactions are **ligations** `a + b -> ab` for every ordered pair
|
|
9
|
+
whose concatenation is within `max_len`, and -- when `cleavage=True` -- the reverse
|
|
10
|
+
`ab -> a + b` as well.
|
|
11
|
+
|
|
12
|
+
Cleavage was originally omitted on the grounds that it "makes every product trivially
|
|
13
|
+
reachable". That is overstated but **directionally right**, and a first attempt to
|
|
14
|
+
overturn it here was wrong and is recorded so it is not retried: the argument was that
|
|
15
|
+
a cleavage can only fire on a molecule you already have, and a long molecule was
|
|
16
|
+
ligated from shorter ones that must therefore already be present, so the closure could
|
|
17
|
+
not grow.
|
|
18
|
+
|
|
19
|
+
**Measured, it grows a lot** -- at `max_len=6`, `p=0.004`, from 6-15 molecules to ~120
|
|
20
|
+
in three of four seeds. The flaw in the argument is that a polymer has *many* splits:
|
|
21
|
+
`0101` may be reachable only as `01`+`01`, while the cleavage `0101 -> 010 + 1` yields
|
|
22
|
+
`010`, which no catalysed ligation produces. Production and cleavage need not use the
|
|
23
|
+
same split, so cleavage genuinely enlarges reachability.
|
|
24
|
+
|
|
25
|
+
More reachable molecules means more catalysts in the closure at any catalysis level,
|
|
26
|
+
so a ligation-only model sits at a markedly higher transition than a
|
|
27
|
+
cleavage-ligation one. See the README for the measured comparison.
|
|
28
|
+
|
|
29
|
+
It matters because the published binary-polymer references use cleavage-ligation
|
|
30
|
+
chemistries -- Steel, Hordijk & Smith (2012), whose f ~ 1.20 transition this model is
|
|
31
|
+
calibrated against, and Serra & Villani (2026) -- so a ligation-only model cannot be
|
|
32
|
+
compared to either without correction.
|
|
33
|
+
|
|
34
|
+
Catalysis is assigned independently: each (molecule, reaction) pair is a catalysis
|
|
35
|
+
edge with probability `p`. That uniform assignment is the model's defining
|
|
36
|
+
simplification and also its main limitation — real catalysis is structured, which is
|
|
37
|
+
what ensembles E5/E6 exist to test.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from itertools import product as _product
|
|
43
|
+
from typing import Iterator
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class BinaryPolymerNetwork:
|
|
50
|
+
"""A generated binary-polymer chemistry.
|
|
51
|
+
|
|
52
|
+
`reactions[i] = (a, b, ab)` as molecule indices, always stored in that order
|
|
53
|
+
regardless of which way the reaction runs. `directions[i]` is `+1` for a ligation
|
|
54
|
+
`a + b -> ab` and `-1` for a cleavage `ab -> a + b`; use `reactants()` and
|
|
55
|
+
`products()` rather than unpacking the triple by hand. `catalysts[i]` is the set of
|
|
56
|
+
molecule indices catalysing reaction `i`.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
molecules: tuple[str, ...]
|
|
60
|
+
food: frozenset[int]
|
|
61
|
+
reactions: tuple[tuple[int, int, int], ...]
|
|
62
|
+
catalysts: tuple[frozenset[int], ...]
|
|
63
|
+
p: float
|
|
64
|
+
max_len: int
|
|
65
|
+
food_len: int
|
|
66
|
+
directions: tuple[int, ...] = ()
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
if not self.directions: # default: all ligations
|
|
70
|
+
object.__setattr__(self, "directions", (1,) * len(self.reactions))
|
|
71
|
+
elif len(self.directions) != len(self.reactions):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"directions has {len(self.directions)} entries for "
|
|
74
|
+
f"{len(self.reactions)} reactions")
|
|
75
|
+
|
|
76
|
+
def reactants(self, r: int) -> tuple[int, ...]:
|
|
77
|
+
"""Molecules consumed by reaction `r`, in its stored direction."""
|
|
78
|
+
a, b, ab = self.reactions[r]
|
|
79
|
+
return (a, b) if self.directions[r] > 0 else (ab,)
|
|
80
|
+
|
|
81
|
+
def products(self, r: int) -> tuple[int, ...]:
|
|
82
|
+
"""Molecules produced by reaction `r`, in its stored direction."""
|
|
83
|
+
a, b, ab = self.reactions[r]
|
|
84
|
+
return (ab,) if self.directions[r] > 0 else (a, b)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def n_cleavages(self) -> int:
|
|
88
|
+
return sum(1 for d in self.directions if d < 0)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def catalysis_level(self) -> float:
|
|
92
|
+
"""`f` in the published convention: catalysed reactions per molecule.
|
|
93
|
+
|
|
94
|
+
Steel, Hordijk & Smith (2012) define f = p|R| where **R counts reversible
|
|
95
|
+
`cleavage-ligation` reactions**, i.e. a ligation and its reverse are ONE
|
|
96
|
+
reaction. Reporting `mean_catalysed_per_molecule` against their f therefore
|
|
97
|
+
double-counts a cleavage chemistry and lands 2x too high. This property is the
|
|
98
|
+
comparable quantity; use it, and not `mean_catalysed_per_molecule`, whenever a
|
|
99
|
+
number is set beside theirs.
|
|
100
|
+
"""
|
|
101
|
+
if not self.molecules:
|
|
102
|
+
return 0.0
|
|
103
|
+
n_pairs = self.n_reactions - self.n_cleavages
|
|
104
|
+
return sum(len(c) for c in self.catalysts[:n_pairs]) / len(self.molecules)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def n_molecules(self) -> int:
|
|
108
|
+
return len(self.molecules)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def n_reactions(self) -> int:
|
|
112
|
+
return len(self.reactions)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def mean_catalysed_per_molecule(self) -> float:
|
|
116
|
+
"""Reactions catalysed per molecule — the model's natural control variable.
|
|
117
|
+
|
|
118
|
+
Kauffman's transition is usually located in this quantity rather than in `p`
|
|
119
|
+
directly, because `p` alone is not comparable across network sizes.
|
|
120
|
+
"""
|
|
121
|
+
if not self.molecules:
|
|
122
|
+
return 0.0
|
|
123
|
+
return sum(len(c) for c in self.catalysts) / len(self.molecules)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _strings(max_len: int) -> Iterator[str]:
|
|
127
|
+
for n in range(1, max_len + 1):
|
|
128
|
+
for bits in _product("01", repeat=n):
|
|
129
|
+
yield "".join(bits)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
|
|
133
|
+
rng: np.random.Generator | None = None,
|
|
134
|
+
cleavage: bool = False,
|
|
135
|
+
paired_catalysis: bool = True) -> BinaryPolymerNetwork:
|
|
136
|
+
"""Generate one binary-polymer network with catalysis at probability `p`.
|
|
137
|
+
|
|
138
|
+
`cleavage=True` adds the reverse `ab -> a + b` of every ligation, doubling the
|
|
139
|
+
stored reaction count.
|
|
140
|
+
|
|
141
|
+
`paired_catalysis` (default True) makes a ligation and its reverse **one
|
|
142
|
+
catalysed unit**, sharing a catalyst set -- the reversible "cleavage-ligation"
|
|
143
|
+
reaction of Steel, Hordijk & Smith (2012), and the convention their f = p|R| is
|
|
144
|
+
measured in. Set False to draw the two directions independently; that is a
|
|
145
|
+
different chemistry and its f is not comparable to theirs.
|
|
146
|
+
"""
|
|
147
|
+
if max_len < 2:
|
|
148
|
+
raise ValueError(f"max_len must be at least 2, got {max_len}")
|
|
149
|
+
if not 0 <= food_len < max_len:
|
|
150
|
+
raise ValueError(f"food_len must be in [0, max_len), got {food_len}")
|
|
151
|
+
if not 0.0 <= p <= 1.0:
|
|
152
|
+
raise ValueError(f"p is a probability, got {p}")
|
|
153
|
+
rng = rng or np.random.default_rng()
|
|
154
|
+
|
|
155
|
+
molecules = tuple(_strings(max_len))
|
|
156
|
+
index = {m: i for i, m in enumerate(molecules)}
|
|
157
|
+
food = frozenset(i for i, m in enumerate(molecules) if len(m) <= food_len)
|
|
158
|
+
|
|
159
|
+
ligations = tuple(
|
|
160
|
+
(index[a], index[b], index[a + b])
|
|
161
|
+
for a in molecules for b in molecules if len(a) + len(b) <= max_len
|
|
162
|
+
)
|
|
163
|
+
if cleavage:
|
|
164
|
+
reactions = ligations + ligations
|
|
165
|
+
directions = (1,) * len(ligations) + (-1,) * len(ligations)
|
|
166
|
+
else:
|
|
167
|
+
reactions = ligations
|
|
168
|
+
directions = (1,) * len(ligations)
|
|
169
|
+
|
|
170
|
+
# Sparse Bernoulli draw: sampling the number of catalysts per reaction and then
|
|
171
|
+
# which ones is O(edges) rather than O(molecules x reactions), which matters --
|
|
172
|
+
# the dense product is ~10^8 at max_len=10.
|
|
173
|
+
n_mol = len(molecules)
|
|
174
|
+
n_draw = len(ligations) if (cleavage and paired_catalysis) else len(reactions)
|
|
175
|
+
counts = rng.binomial(n_mol, p, size=n_draw)
|
|
176
|
+
drawn = tuple(
|
|
177
|
+
frozenset(rng.choice(n_mol, size=int(k), replace=False).tolist()) if k else
|
|
178
|
+
frozenset()
|
|
179
|
+
for k in counts
|
|
180
|
+
)
|
|
181
|
+
# Paired: the cleavage half re-uses its ligation's catalysts rather than redrawing.
|
|
182
|
+
catalysts = drawn + drawn if (cleavage and paired_catalysis) else drawn
|
|
183
|
+
return BinaryPolymerNetwork(molecules=molecules, food=food, reactions=reactions,
|
|
184
|
+
catalysts=catalysts, p=p, max_len=max_len,
|
|
185
|
+
food_len=food_len, directions=directions)
|
rafkit/crs.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Read and write CatReNet's CRS format.
|
|
2
|
+
|
|
3
|
+
CRS is the interchange format of CatReNet (Huson, Xavier & Steel, *Bioinformatics*
|
|
4
|
+
40(8) btae515, 2024), the reference implementation of RAF theory. Supporting it means
|
|
5
|
+
networks can be round-tripped between this library and the field's existing tooling,
|
|
6
|
+
and that any result here can be checked against an independent implementation.
|
|
7
|
+
|
|
8
|
+
The format is a food line and one line per reaction::
|
|
9
|
+
|
|
10
|
+
# a comment
|
|
11
|
+
Food: a, b, aa, ab
|
|
12
|
+
|
|
13
|
+
r1 : aa + b [ab,ba] <-> aab
|
|
14
|
+
r2 : ab [aa] => abab
|
|
15
|
+
|
|
16
|
+
`[...]` lists catalysts, `<->` marks a reversible reaction and `=>` (or `->`) an
|
|
17
|
+
irreversible one. A reversible reaction is read as **two** reactions, forward and
|
|
18
|
+
reverse, sharing a catalyst set -- which is the reading its own generator uses.
|
|
19
|
+
|
|
20
|
+
`X + X -> Y` is written `X ... -> Y`, with the repeated reactant collapsed. Since
|
|
21
|
+
reactants are sets for every RAF computation, nothing is lost; stoichiometry is not
|
|
22
|
+
modelled here either way.
|
|
23
|
+
|
|
24
|
+
This module talks to CatReNet only through files. No CatReNet code is used or
|
|
25
|
+
derived from -- it is GPL v3, and this library is MIT.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from rafkit.network import ReactionNetwork
|
|
33
|
+
|
|
34
|
+
_ARROW = re.compile(r"\s*(<->|<=>|=>|->)\s*")
|
|
35
|
+
_LINE = re.compile(r"^\s*(?P<name>[^:]+?)\s*:\s*(?P<body>.*)$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _split_list(text: str) -> list[str]:
|
|
39
|
+
return [s for s in (t.strip() for t in re.split(r"[,+]", text)) if s]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_crs(text: str) -> ReactionNetwork:
|
|
43
|
+
"""Parse CRS text into a `ReactionNetwork`."""
|
|
44
|
+
food_names: list[str] = []
|
|
45
|
+
parsed: list[tuple[str, list[str], list[str], list[str], bool]] = []
|
|
46
|
+
|
|
47
|
+
for raw in text.splitlines():
|
|
48
|
+
line = raw.split("#", 1)[0].strip()
|
|
49
|
+
if not line:
|
|
50
|
+
continue
|
|
51
|
+
if line.lower().startswith("food:"):
|
|
52
|
+
food_names += _split_list(line.split(":", 1)[1])
|
|
53
|
+
continue
|
|
54
|
+
m = _LINE.match(line)
|
|
55
|
+
if not m:
|
|
56
|
+
continue
|
|
57
|
+
name, body = m.group("name"), m.group("body")
|
|
58
|
+
|
|
59
|
+
cats: list[str] = []
|
|
60
|
+
if "[" in body:
|
|
61
|
+
pre, rest = body.split("[", 1)
|
|
62
|
+
inside, post = rest.split("]", 1)
|
|
63
|
+
cats = _split_list(inside)
|
|
64
|
+
body = pre + " " + post
|
|
65
|
+
|
|
66
|
+
arrow = _ARROW.search(body)
|
|
67
|
+
if not arrow:
|
|
68
|
+
continue
|
|
69
|
+
lhs, rhs = body[:arrow.start()], body[arrow.end():]
|
|
70
|
+
parsed.append((name, _split_list(lhs), _split_list(rhs), cats,
|
|
71
|
+
arrow.group(1) in ("<->", "<=>")))
|
|
72
|
+
|
|
73
|
+
# Stable molecule indexing: food first, then order of appearance.
|
|
74
|
+
index: dict[str, int] = {}
|
|
75
|
+
for n in food_names:
|
|
76
|
+
index.setdefault(n, len(index))
|
|
77
|
+
for _, lhs, rhs, cats, _ in parsed:
|
|
78
|
+
for n in (*lhs, *rhs, *cats):
|
|
79
|
+
index.setdefault(n, len(index))
|
|
80
|
+
|
|
81
|
+
pairs, catalysts, names = [], [], []
|
|
82
|
+
for name, lhs, rhs, cats, reversible in parsed:
|
|
83
|
+
cat = frozenset(index[c] for c in cats)
|
|
84
|
+
fwd = (tuple(index[x] for x in lhs), tuple(index[x] for x in rhs))
|
|
85
|
+
pairs.append(fwd); catalysts.append(cat); names.append(name)
|
|
86
|
+
if reversible:
|
|
87
|
+
pairs.append((fwd[1], fwd[0]))
|
|
88
|
+
catalysts.append(cat)
|
|
89
|
+
names.append(f"{name}_rev")
|
|
90
|
+
|
|
91
|
+
molecules = tuple(sorted(index, key=index.get))
|
|
92
|
+
return ReactionNetwork(molecules=molecules,
|
|
93
|
+
food=frozenset(index[n] for n in food_names),
|
|
94
|
+
reaction_pairs=tuple(pairs),
|
|
95
|
+
catalysts=tuple(catalysts),
|
|
96
|
+
names=tuple(names))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_crs(path: str | Path) -> ReactionNetwork:
|
|
100
|
+
"""Read a CRS file."""
|
|
101
|
+
return parse_crs(Path(path).read_text())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def to_crs(net, comment: str = "") -> str:
|
|
105
|
+
"""Serialise any network exposing the rafkit protocol to CRS text.
|
|
106
|
+
|
|
107
|
+
Reversible pairs are **not** re-merged: each stored direction is written as its
|
|
108
|
+
own one-way reaction. That round-trips faithfully and keeps the output honest
|
|
109
|
+
about what the object actually holds.
|
|
110
|
+
"""
|
|
111
|
+
out = []
|
|
112
|
+
if comment:
|
|
113
|
+
out += [f"# {line}" for line in comment.splitlines()]
|
|
114
|
+
name = lambda m: net.molecules[m]
|
|
115
|
+
out.append("")
|
|
116
|
+
out.append("Food: " + ", ".join(sorted(name(m) for m in net.food)))
|
|
117
|
+
out.append("")
|
|
118
|
+
names = getattr(net, "names", None) or [f"r{i + 1}" for i in range(net.n_reactions)]
|
|
119
|
+
for r in range(net.n_reactions):
|
|
120
|
+
lhs = " + ".join(dict.fromkeys(name(x) for x in net.reactants(r)))
|
|
121
|
+
rhs = " + ".join(dict.fromkeys(name(x) for x in net.products(r)))
|
|
122
|
+
cats = ",".join(sorted(name(c) for c in net.catalysts[r]))
|
|
123
|
+
out.append(f"{names[r]} : {lhs} [{cats}] => {rhs}")
|
|
124
|
+
out.append("")
|
|
125
|
+
return "\n".join(out)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_crs(net, path: str | Path, comment: str = "") -> None:
|
|
129
|
+
"""Write a network to a CRS file."""
|
|
130
|
+
Path(path).write_text(to_crs(net, comment))
|
rafkit/network.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""A general catalytic reaction network.
|
|
2
|
+
|
|
3
|
+
`BinaryPolymerNetwork` stores its reactions compactly as `(a, b, ab)` triples, which
|
|
4
|
+
is what makes tens of thousands of them cheap, but it can only express ligation and
|
|
5
|
+
cleavage over binary strings. `ReactionNetwork` expresses an arbitrary catalytic
|
|
6
|
+
reaction system -- any molecule names, any number of reactants and products -- and
|
|
7
|
+
exposes the **same small protocol** the RAF algorithms use, so `max_raf`,
|
|
8
|
+
`max_raf_strict`, `sample_irrraf`, `irrraf_census` and `exploitability` all work on
|
|
9
|
+
either without change:
|
|
10
|
+
|
|
11
|
+
food molecule indices supplied from outside
|
|
12
|
+
molecules names, indexed
|
|
13
|
+
n_molecules
|
|
14
|
+
n_reactions
|
|
15
|
+
catalysts[r] frozenset of molecule indices catalysing reaction r
|
|
16
|
+
reactants(r) molecules consumed by r
|
|
17
|
+
products(r) molecules produced by r
|
|
18
|
+
reactions[r] every molecule involved in r (membership tests only)
|
|
19
|
+
|
|
20
|
+
This is what makes the library usable on networks that did not come from a polymer
|
|
21
|
+
model -- see `rafkit.crs` for reading them from CatReNet's CRS format.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ReactionNetwork:
|
|
30
|
+
"""An arbitrary catalytic reaction system.
|
|
31
|
+
|
|
32
|
+
`reaction_pairs[i] = (reactants, products)` as tuples of molecule indices.
|
|
33
|
+
Reactants and products are treated as **sets**: a reaction `X + X -> Y` requires
|
|
34
|
+
only that `X` be present, so listing `X` once is not a loss of information for
|
|
35
|
+
any RAF computation (it would be for stoichiometry, which this class does not
|
|
36
|
+
model).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
molecules: tuple[str, ...]
|
|
40
|
+
food: frozenset[int]
|
|
41
|
+
reaction_pairs: tuple[tuple[tuple[int, ...], tuple[int, ...]], ...]
|
|
42
|
+
catalysts: tuple[frozenset[int], ...]
|
|
43
|
+
names: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
if len(self.catalysts) != len(self.reaction_pairs):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"{len(self.catalysts)} catalyst sets for "
|
|
49
|
+
f"{len(self.reaction_pairs)} reactions")
|
|
50
|
+
if not self.names:
|
|
51
|
+
object.__setattr__(
|
|
52
|
+
self, "names", tuple(f"r{i + 1}" for i in range(len(self.reaction_pairs))))
|
|
53
|
+
elif len(self.names) != len(self.reaction_pairs):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"{len(self.names)} names for {len(self.reaction_pairs)} reactions")
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def n_molecules(self) -> int:
|
|
59
|
+
return len(self.molecules)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def n_reactions(self) -> int:
|
|
63
|
+
return len(self.reaction_pairs)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def reactions(self) -> tuple[tuple[int, ...], ...]:
|
|
67
|
+
"""Every molecule involved in each reaction; for membership tests only."""
|
|
68
|
+
return tuple(r + p for r, p in self.reaction_pairs)
|
|
69
|
+
|
|
70
|
+
def reactants(self, r: int) -> tuple[int, ...]:
|
|
71
|
+
return self.reaction_pairs[r][0]
|
|
72
|
+
|
|
73
|
+
def products(self, r: int) -> tuple[int, ...]:
|
|
74
|
+
return self.reaction_pairs[r][1]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def mean_catalysed_per_molecule(self) -> float:
|
|
78
|
+
if not self.molecules:
|
|
79
|
+
return 0.0
|
|
80
|
+
return sum(len(c) for c in self.catalysts) / len(self.molecules)
|
|
81
|
+
|
|
82
|
+
catalysis_level = mean_catalysed_per_molecule
|
rafkit/raf.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""RAF closure and exploitability.
|
|
2
|
+
|
|
3
|
+
`max_raf` is the standard Hordijk & Steel (2004) maximal-RAF algorithm. A reaction
|
|
4
|
+
set R' is a RAF over food set F when every reaction in R' is catalysed by a molecule
|
|
5
|
+
producible from F using R' (reflexively autocatalytic), and every reactant is itself
|
|
6
|
+
producible from F using R' (F-generated). The maximal RAF is unique and is reached by
|
|
7
|
+
iteratively discarding reactions that fail either condition.
|
|
8
|
+
|
|
9
|
+
`exploitability` measures how much of what a RAF produces contributes no catalysis
|
|
10
|
+
back to it. A molecule counts as an exploiter when the RAF produces it and it
|
|
11
|
+
catalyses nothing in the RAF -- reproduced by the closed set's catalysts, giving
|
|
12
|
+
nothing to closure in return. It is a cheap, purely structural observable; it says
|
|
13
|
+
nothing on its own about whether such a molecule could invade dynamically.
|
|
14
|
+
|
|
15
|
+
Several definitions are defensible, so one is fixed here as the PRIMARY and two
|
|
16
|
+
variants are computed alongside it, reported but never substituted for it:
|
|
17
|
+
|
|
18
|
+
* `strict` -- PRIMARY, as above.
|
|
19
|
+
* `unused` -- produced by the RAF, catalyses nothing in the RAF, **and** is not a
|
|
20
|
+
reactant of any RAF reaction. A strictly smaller set: these molecules
|
|
21
|
+
are dead weight in both roles.
|
|
22
|
+
* `dispensable` -- produced by the RAF and removable without shrinking the RAF. The
|
|
23
|
+
most demanding, and the closest to the hypercycle sense of a parasite:
|
|
24
|
+
the network's throughput builds it and would lose nothing by not.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from rafkit.binary_polymer import BinaryPolymerNetwork
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RafResult:
|
|
35
|
+
reactions: frozenset[int] # indices of the maximal RAF
|
|
36
|
+
closure: frozenset[int] # molecules producible from F using the RAF
|
|
37
|
+
n_rounds: int # fixpoint iterations taken
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def size(self) -> int:
|
|
41
|
+
return len(self.reactions)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_empty(self) -> bool:
|
|
45
|
+
return not self.reactions
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _closure(net: BinaryPolymerNetwork, reactions: frozenset[int]) -> frozenset[int]:
|
|
49
|
+
"""Molecules producible from the food set using `reactions`.
|
|
50
|
+
|
|
51
|
+
Direction-aware: a ligation fires when both its reactants are present, a cleavage
|
|
52
|
+
when its single reactant is. Cleavage **does** enlarge this set, because a polymer
|
|
53
|
+
has many splits and need not be cleaved along the one it was built from -- see the
|
|
54
|
+
`binary_polymer` docstring, where the contrary argument is recorded as refuted.
|
|
55
|
+
"""
|
|
56
|
+
have = set(net.food)
|
|
57
|
+
pending = [(net.reactants(r), net.products(r)) for r in reactions]
|
|
58
|
+
changed = True
|
|
59
|
+
while changed:
|
|
60
|
+
changed = False
|
|
61
|
+
still = []
|
|
62
|
+
for reactants, products in pending:
|
|
63
|
+
if all(x in have for x in reactants):
|
|
64
|
+
for x in products:
|
|
65
|
+
if x not in have:
|
|
66
|
+
have.add(x)
|
|
67
|
+
changed = True
|
|
68
|
+
else:
|
|
69
|
+
still.append((reactants, products))
|
|
70
|
+
pending = still
|
|
71
|
+
return frozenset(have)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def max_raf(net: BinaryPolymerNetwork) -> RafResult:
|
|
75
|
+
"""The maximal RAF, by iterative removal to a fixpoint (Hordijk & Steel 2004)."""
|
|
76
|
+
current = frozenset(range(net.n_reactions))
|
|
77
|
+
rounds = 0
|
|
78
|
+
while True:
|
|
79
|
+
rounds += 1
|
|
80
|
+
have = _closure(net, current)
|
|
81
|
+
keep = frozenset(
|
|
82
|
+
r for r in current
|
|
83
|
+
if all(x in have for x in net.reactants(r)) and (net.catalysts[r] & have)
|
|
84
|
+
)
|
|
85
|
+
if keep == current:
|
|
86
|
+
return RafResult(reactions=current, closure=have, n_rounds=rounds)
|
|
87
|
+
current = keep
|
|
88
|
+
if not current:
|
|
89
|
+
return RafResult(reactions=current, closure=_closure(net, current),
|
|
90
|
+
n_rounds=rounds)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def exploitability(net: BinaryPolymerNetwork, raf: RafResult) -> dict:
|
|
94
|
+
"""Exploiter fractions among the molecules the RAF produces.
|
|
95
|
+
|
|
96
|
+
Denominator is RAF products excluding food: food is supplied from outside, so
|
|
97
|
+
counting it would inflate the fraction with molecules the network never had to
|
|
98
|
+
make. Returns fractions and raw counts; `nan` fractions when the RAF is empty,
|
|
99
|
+
which is honest -- there is nothing to exploit.
|
|
100
|
+
"""
|
|
101
|
+
products = frozenset(raf.closure) - frozenset(net.food)
|
|
102
|
+
n = len(products)
|
|
103
|
+
if raf.is_empty or n == 0:
|
|
104
|
+
nan = float("nan")
|
|
105
|
+
return {"n_products": n, "strict": nan, "unused": nan, "dispensable": nan,
|
|
106
|
+
"n_strict": 0, "n_unused": 0, "n_dispensable": 0}
|
|
107
|
+
|
|
108
|
+
catalyses_in_raf = set()
|
|
109
|
+
reactants_in_raf = set()
|
|
110
|
+
for r in raf.reactions:
|
|
111
|
+
reactants_in_raf.update(net.reactants(r))
|
|
112
|
+
catalyses_in_raf |= (net.catalysts[r] & products)
|
|
113
|
+
|
|
114
|
+
strict = products - catalyses_in_raf
|
|
115
|
+
unused = strict - reactants_in_raf
|
|
116
|
+
|
|
117
|
+
# `dispensable`: removing the molecule must not shrink the RAF. Removing m kills
|
|
118
|
+
# every reaction producing or consuming m, and every reaction m alone catalysed.
|
|
119
|
+
dispensable = set()
|
|
120
|
+
for m in strict:
|
|
121
|
+
survives = frozenset(
|
|
122
|
+
r for r in raf.reactions
|
|
123
|
+
if m not in net.reactions[r] and net.catalysts[r] & (raf.closure - {m})
|
|
124
|
+
)
|
|
125
|
+
sub = RafResult(reactions=survives, closure=_closure(net, survives),
|
|
126
|
+
n_rounds=0)
|
|
127
|
+
again = _refine(net, sub.reactions)
|
|
128
|
+
if len(again) == len(raf.reactions) - _touching(net, raf, m):
|
|
129
|
+
dispensable.add(m)
|
|
130
|
+
|
|
131
|
+
return {"n_products": n,
|
|
132
|
+
"strict": len(strict) / n, "n_strict": len(strict),
|
|
133
|
+
"unused": len(unused) / n, "n_unused": len(unused),
|
|
134
|
+
"dispensable": len(dispensable) / n, "n_dispensable": len(dispensable)}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _touching(net: BinaryPolymerNetwork, raf: RafResult, m: int) -> int:
|
|
138
|
+
"""RAF reactions that directly involve `m` as reactant or product."""
|
|
139
|
+
return sum(1 for r in raf.reactions if m in net.reactions[r])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _refine(net: BinaryPolymerNetwork, reactions: frozenset[int],
|
|
143
|
+
strict: bool = False) -> frozenset[int]:
|
|
144
|
+
"""One RAF fixpoint restricted to a reaction subset.
|
|
145
|
+
|
|
146
|
+
`strict=True` requires every catalyst to be a **non-food** molecule the set
|
|
147
|
+
produces, which is the self-referential reading of "reflexively autocatalytic"
|
|
148
|
+
(see `is_food_catalysed`). Default `False` is the literal Hordijk & Steel
|
|
149
|
+
condition and is what `max_raf` and every prior result use.
|
|
150
|
+
"""
|
|
151
|
+
current = reactions
|
|
152
|
+
while True:
|
|
153
|
+
have = _closure(net, current)
|
|
154
|
+
pool = have - net.food if strict else have
|
|
155
|
+
keep = frozenset(
|
|
156
|
+
r for r in current
|
|
157
|
+
if all(x in have for x in net.reactants(r)) and (net.catalysts[r] & pool)
|
|
158
|
+
)
|
|
159
|
+
if keep == current:
|
|
160
|
+
return current
|
|
161
|
+
current = keep
|
|
162
|
+
if not current:
|
|
163
|
+
return current
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def max_raf_strict(net: BinaryPolymerNetwork) -> RafResult:
|
|
167
|
+
"""The maximal self-referential RAF: catalysts must be non-food products.
|
|
168
|
+
|
|
169
|
+
The subset of the maximal RAF that actually needs its own output to run. This is
|
|
170
|
+
the object a propagule would have to carry, so it -- not `max_raf` -- is where a
|
|
171
|
+
count of lineages has to be taken.
|
|
172
|
+
"""
|
|
173
|
+
current = _refine(net, frozenset(range(net.n_reactions)), strict=True)
|
|
174
|
+
return RafResult(reactions=current, closure=_closure(net, current), n_rounds=0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def sample_irrraf(net: BinaryPolymerNetwork, reactions: frozenset[int],
|
|
178
|
+
rng, strict: bool = False) -> frozenset[int]:
|
|
179
|
+
"""One irreducible RAF contained in `reactions`, by randomized shrinking.
|
|
180
|
+
|
|
181
|
+
An irreducible RAF (Hordijk & Steel) is a RAF with no proper subset that is
|
|
182
|
+
itself a RAF: a minimal self-sustaining core. It is the natural formal stand-in
|
|
183
|
+
for a *lineage* -- the smallest thing a propagule has to carry to re-establish
|
|
184
|
+
the network from food alone.
|
|
185
|
+
|
|
186
|
+
Walk the reactions in a random order and try to drop each one, keeping the
|
|
187
|
+
refined remainder whenever it is non-empty. One pass suffices, because maximal
|
|
188
|
+
RAF is monotone in the reaction set: if dropping `r` collapses the set, it also
|
|
189
|
+
collapses every subset, so a reaction that survives its own visit can never
|
|
190
|
+
become removable later. Every reaction present at the end was therefore visited
|
|
191
|
+
while present and found irremovable, which is the definition.
|
|
192
|
+
|
|
193
|
+
The random order is what makes this a *sampler* -- different orders land in
|
|
194
|
+
different irreducible cores. Distinct results are a lower bound on how many
|
|
195
|
+
exist, never an upper one.
|
|
196
|
+
|
|
197
|
+
**Prior art.** This is Steel, Hordijk & Smith, "Minimal autocatalytic networks"
|
|
198
|
+
(arXiv:1212.4450, 2012), which describes the same remove-and-refine procedure and
|
|
199
|
+
the same randomised re-ordering to sample. It was reinvented here on 2026-08-15
|
|
200
|
+
and the attribution added on discovery. The same paper proves there may be exponentially many irrRAFs and that finding the
|
|
201
|
+
smallest RAF is NP-hard, so a distinct-count that never saturates is the expected
|
|
202
|
+
result rather than a surprising one.
|
|
203
|
+
"""
|
|
204
|
+
current = _refine(net, reactions, strict=strict)
|
|
205
|
+
order = list(current)
|
|
206
|
+
rng.shuffle(order)
|
|
207
|
+
for r in order:
|
|
208
|
+
if r not in current:
|
|
209
|
+
continue
|
|
210
|
+
trial = _refine(net, current - {r}, strict=strict)
|
|
211
|
+
if trial:
|
|
212
|
+
current = trial
|
|
213
|
+
return current
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def irrraf_census(net: BinaryPolymerNetwork, raf: RafResult, n_samples: int,
|
|
217
|
+
rng, strict: bool = False) -> dict:
|
|
218
|
+
"""Sample irreducible RAFs and report how many distinct ones turn up.
|
|
219
|
+
|
|
220
|
+
The count is the quantity of interest: it upper-bounds the number of
|
|
221
|
+
distinguishable lineages the chemistry can carry, so a census of 1 means there
|
|
222
|
+
is nothing to inherit and no ecology is possible regardless of the dynamics
|
|
223
|
+
later placed on top.
|
|
224
|
+
"""
|
|
225
|
+
if raf.is_empty:
|
|
226
|
+
return {"n_samples": 0, "n_distinct": 0, "sizes": [], "mean_size": float("nan"),
|
|
227
|
+
"mean_jaccard": float("nan"), "min_jaccard": float("nan"),
|
|
228
|
+
"union_size": 0, "core_size": 0}
|
|
229
|
+
|
|
230
|
+
found: list[frozenset[int]] = []
|
|
231
|
+
seen: set[frozenset[int]] = set()
|
|
232
|
+
for _ in range(n_samples):
|
|
233
|
+
s = sample_irrraf(net, raf.reactions, rng, strict=strict)
|
|
234
|
+
found.append(s)
|
|
235
|
+
seen.add(s)
|
|
236
|
+
|
|
237
|
+
distinct = sorted(seen, key=len)
|
|
238
|
+
jac = []
|
|
239
|
+
for i in range(len(distinct)):
|
|
240
|
+
for j in range(i + 1, len(distinct)):
|
|
241
|
+
a, b = distinct[i], distinct[j]
|
|
242
|
+
jac.append(len(a & b) / len(a | b))
|
|
243
|
+
self_ref = [c for c in distinct if not is_food_catalysed(net, c)]
|
|
244
|
+
union: frozenset[int] = frozenset().union(*distinct)
|
|
245
|
+
core: frozenset[int] = distinct[0]
|
|
246
|
+
for s in distinct[1:]:
|
|
247
|
+
core = core & s
|
|
248
|
+
|
|
249
|
+
return {"n_samples": n_samples,
|
|
250
|
+
"n_distinct": len(distinct),
|
|
251
|
+
"n_self_referential": len(self_ref),
|
|
252
|
+
"self_ref_sizes": [len(c) for c in self_ref],
|
|
253
|
+
"sizes": [len(s) for s in distinct],
|
|
254
|
+
"mean_size": sum(len(s) for s in found) / len(found),
|
|
255
|
+
"mean_jaccard": sum(jac) / len(jac) if jac else float("nan"),
|
|
256
|
+
"min_jaccard": min(jac) if jac else float("nan"),
|
|
257
|
+
"union_size": len(union),
|
|
258
|
+
"core_size": len(core)}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def is_food_catalysed(net: BinaryPolymerNetwork, core: frozenset[int]) -> bool:
|
|
262
|
+
"""Whether every reaction in `core` has a catalyst in the food set.
|
|
263
|
+
|
|
264
|
+
Such a core is a RAF by the letter of the definition -- food is in the closure,
|
|
265
|
+
so "catalysed by a molecule producible from F" is satisfied -- but it is not
|
|
266
|
+
self-referential: it runs wherever the food runs, needs none of its own products,
|
|
267
|
+
and therefore carries no heredity. A propagule is not required to establish it.
|
|
268
|
+
|
|
269
|
+
This is a real degeneracy of the RAF definition rather than a quirk of E4, and it
|
|
270
|
+
has to be split out before any count of cores can be read as a count of lineages.
|
|
271
|
+
"""
|
|
272
|
+
if not core:
|
|
273
|
+
return False
|
|
274
|
+
return all(net.catalysts[r] & net.food for r in core)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def catrenet_strictly_autocatalytic(net, raf: RafResult | None = None) -> frozenset[int]:
|
|
278
|
+
"""CatReNet's `strictlyAutocatalyticMaxRaf`, for cross-checking.
|
|
279
|
+
|
|
280
|
+
CatReNet documents this as "a Max RAF that has the additional property that any
|
|
281
|
+
contained reaction requires at least one molecule type for catalyzation that is
|
|
282
|
+
not in the food set". Reproduced here by **black-box behavioural inference** from
|
|
283
|
+
its published output on a generated network -- no CatReNet source was read, and
|
|
284
|
+
none could be used, since it is GPL v3 and this library is MIT.
|
|
285
|
+
|
|
286
|
+
The operation is a **filter on the maximal RAF, without re-refinement**: keep
|
|
287
|
+
every reaction having at least one non-food catalyst, and stop. That is *not* the
|
|
288
|
+
same as `max_raf_strict`, which imposes the same condition inside the fixpoint
|
|
289
|
+
and therefore returns a set that is itself a RAF. Dropping reactions can break
|
|
290
|
+
F-generation for the ones that remain, so this result need not be a RAF -- which
|
|
291
|
+
is exactly why it is offered for interoperability rather than for analysis.
|
|
292
|
+
|
|
293
|
+
On the committed CatReNet fixture: `max_raf` 183, this 175, `max_raf_strict` 161.
|
|
294
|
+
"""
|
|
295
|
+
if raf is None:
|
|
296
|
+
raf = max_raf(net)
|
|
297
|
+
if raf.is_empty:
|
|
298
|
+
return frozenset()
|
|
299
|
+
pool = raf.closure - net.food
|
|
300
|
+
return frozenset(r for r in raf.reactions if net.catalysts[r] & pool)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rafkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models.
|
|
5
|
+
Author: James P. Galasyn, Claude Théodore
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jimgalasyn/rafkit
|
|
8
|
+
Project-URL: Issues, https://github.com/jimgalasyn/rafkit/issues
|
|
9
|
+
Keywords: autocatalytic-sets,RAF,origin-of-life,chemical-reaction-networks,systems-chemistry,binary-polymer-model,catalysis,abiogenesis
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Life
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.24
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
25
|
+
Requires-Dist: pytest-cov>=4; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-xdist>=3; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# rafkit
|
|
30
|
+
|
|
31
|
+
[](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
|
|
32
|
+
[](https://codecov.io/gh/JimGalasyn/rafkit)
|
|
33
|
+
[](https://pypi.org/project/rafkit/)
|
|
34
|
+
[](https://pypi.org/project/rafkit/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
|
|
38
|
+
cores, Kauffman binary polymer models, and interoperability with
|
|
39
|
+
[CatReNet](https://github.com/husonlab/catrenet).
|
|
40
|
+
|
|
41
|
+
Pure Python and NumPy. No Java, no GUI, no install beyond `pip`.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from rafkit import binary_polymer, max_raf, sample_irrraf
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
net = binary_polymer(max_len=8, food_len=2, p=1.5e-3, cleavage=True)
|
|
48
|
+
raf = max_raf(net)
|
|
49
|
+
print(raf.size, "reactions in the maximal RAF")
|
|
50
|
+
|
|
51
|
+
core = sample_irrraf(net, raf.reactions, np.random.default_rng(0))
|
|
52
|
+
print(len(core), "reactions in one irreducible core")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why this exists
|
|
56
|
+
|
|
57
|
+
RAF theory (Hordijk & Steel 2004) formalises collectively autocatalytic sets: a set of
|
|
58
|
+
reactions is a RAF over a food set when every reaction is catalysed by something the
|
|
59
|
+
set can make, and every reactant can be built up from food using the set. The
|
|
60
|
+
reference implementation, **CatReNet**, is an excellent Java/JavaFX desktop
|
|
61
|
+
application. This is a small library for people who want the same algorithms inside a
|
|
62
|
+
Python analysis pipeline.
|
|
63
|
+
|
|
64
|
+
## Validated against the reference implementation
|
|
65
|
+
|
|
66
|
+
`tests/data/catrenet_polymer_n6.crs` was generated by CatReNet's own `polymer-tool`,
|
|
67
|
+
and the expected counts in `tests/test_crs.py` are what CatReNet's `catrenet-tool`
|
|
68
|
+
reports on it. The test suite therefore checks this implementation against an
|
|
69
|
+
independent one on every run, with no Java required.
|
|
70
|
+
|
|
71
|
+
| algorithm | rafkit | CatReNet 1.1.0 |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `max_raf` | 183 | 183 |
|
|
74
|
+
| `catrenet_strictly_autocatalytic` | 175 | 175 |
|
|
75
|
+
| `max_raf_strict` | 161 | *(different object — see below)* |
|
|
76
|
+
|
|
77
|
+
**A documented divergence.** CatReNet's `strictlyAutocatalyticMaxRaf` *filters* the
|
|
78
|
+
maximal RAF for reactions having a non-food catalyst, without re-refining, so its
|
|
79
|
+
result need not itself be a RAF. `max_raf_strict` imposes the same condition inside
|
|
80
|
+
the fixpoint, so its result is a RAF, and is correspondingly smaller. Both are
|
|
81
|
+
available; they answer different questions. CatReNet's behaviour was reproduced by
|
|
82
|
+
black-box inference from its output — no CatReNet source was read or used.
|
|
83
|
+
|
|
84
|
+
## Calibration
|
|
85
|
+
|
|
86
|
+
The RAF phase transition in Kauffman's binary polymer model, measured here against
|
|
87
|
+
the published value of *f* ≈ 1.20 (Steel, Hordijk & Smith 2012, n=10, t=2), where
|
|
88
|
+
*f* = p|R| is the mean number of catalysed reactions per molecule:
|
|
89
|
+
|
|
90
|
+
| model | transition |
|
|
91
|
+
|---|---|
|
|
92
|
+
| ligation only | *f* ≈ 4.7 |
|
|
93
|
+
| ligation + cleavage | *f* ≈ 3.1 |
|
|
94
|
+
| ligation + cleavage, **catalysis paired per reversible reaction** | 0 seeds at *f* ≤ 1.22, all seeds by *f* ≈ 1.59 |
|
|
95
|
+
|
|
96
|
+
Two conventions have to match before any comparison to the literature means anything:
|
|
97
|
+
the model must include **cleavage**, and a reversible cleavage–ligation pair must be
|
|
98
|
+
counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — not
|
|
99
|
+
`mean_catalysed_per_molecule` — whenever a number is placed beside a published *f*.
|
|
100
|
+
|
|
101
|
+
## What's implemented
|
|
102
|
+
|
|
103
|
+
| | |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `max_raf` | maximal RAF, by fixpoint (Hordijk & Steel 2004) |
|
|
106
|
+
| `max_raf_strict` | maximal RAF whose catalysts must be non-food products |
|
|
107
|
+
| `catrenet_strictly_autocatalytic` | CatReNet's similarly-named filter, for interop |
|
|
108
|
+
| `sample_irrraf` | one irreducible RAF, by randomised shrinking (Steel, Hordijk & Smith 2012) |
|
|
109
|
+
| `irrraf_census` | how many *distinct* irreducible cores a network carries |
|
|
110
|
+
| `exploitability` | share of RAF products contributing no catalysis back |
|
|
111
|
+
| `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
|
|
112
|
+
| `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
|
|
113
|
+
| `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
|
|
114
|
+
| `read_crs` / `write_crs` | CatReNet's CRS interchange format |
|
|
115
|
+
|
|
116
|
+
Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
|
|
117
|
+
is subtly wrong produces plausible numbers rather than errors.
|
|
118
|
+
|
|
119
|
+
## Notes on irreducible cores
|
|
120
|
+
|
|
121
|
+
There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
|
|
122
|
+
finding the smallest is NP-hard (Steel, Hordijk & Smith 2012). `sample_irrraf` returns
|
|
123
|
+
*one*, chosen by the random order it walks; `irrraf_census` samples repeatedly and
|
|
124
|
+
reports how many distinct ones it saw. That count is always a **lower bound**, never
|
|
125
|
+
an upper one.
|
|
126
|
+
|
|
127
|
+
`is_food_catalysed` exists because a core whose every reaction has a food catalyst
|
|
128
|
+
satisfies the letter of the RAF definition while being in no sense self-referential —
|
|
129
|
+
it runs wherever the food runs. Split those out before reading a count of cores as a
|
|
130
|
+
count of anything biological.
|
|
131
|
+
|
|
132
|
+
## Install
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install rafkit
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Development:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
pip install -e ".[test]"
|
|
142
|
+
pytest -q
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
|
|
146
|
+
[docs/RELEASING.md](docs/RELEASING.md).
|
|
147
|
+
|
|
148
|
+
## References
|
|
149
|
+
|
|
150
|
+
- Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
|
|
151
|
+
systems," *J. Theor. Biol.* 227, 451 (2004).
|
|
152
|
+
- Steel, Hordijk & Smith, "Minimal autocatalytic networks," *J. Theor. Biol.* 332, 96
|
|
153
|
+
(2013); arXiv:1212.4450.
|
|
154
|
+
- Hordijk & Steel, "Autocatalytic sets extended: dynamics, inhibition, and a
|
|
155
|
+
generalization," *J. Syst. Chem.* 3, 5 (2012); arXiv:1206.1017.
|
|
156
|
+
- Huson, Xavier & Steel, "CatReNet: interactive analysis of (auto-)catalytic reaction
|
|
157
|
+
networks," *Bioinformatics* 40(8), btae515 (2024).
|
|
158
|
+
- Serra & Villani, "Template-Based Catalysis and the Emergence of Collectively
|
|
159
|
+
Autocatalytic Systems," *Entropy* 28(2), 184 (2026).
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT. CatReNet is GPL v3 and is **not** a dependency — this library interoperates with
|
|
164
|
+
it only through files, and contains no code derived from it.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
rafkit/__init__.py,sha256=vFS1jwt22jDA10PISTmg-P9m0sQqnkM_xNE0Ghgy4lE,1267
|
|
2
|
+
rafkit/binary_polymer.py,sha256=1mPeqKnaF94PFTuQIbPJqAc5L8uayCgOIkiwURjtqAI,8164
|
|
3
|
+
rafkit/crs.py,sha256=uDBgptk4eRVfbd8U8k8Y6tbxVARBuVPv7xKQo5tz9bE,4853
|
|
4
|
+
rafkit/network.py,sha256=Fdzvdrjh_PxFELc7xhApxdL642SaUqu1b7s2pb5gG9A,3198
|
|
5
|
+
rafkit/raf.py,sha256=pF8aC2LLUJ-PBEl5oVYBElwtXjX_juwaZ_A2JS2FSYU,13314
|
|
6
|
+
rafkit-0.1.0.dist-info/licenses/LICENSE,sha256=pXi-REGP6qna7DadI_rzujveoXI4u3PxyD-Af-G_XPc,1073
|
|
7
|
+
rafkit-0.1.0.dist-info/METADATA,sha256=t4wZRi5Syfyoa92nxkAAh0J_XmIP2z1TRujmJ9Sw5YQ,7464
|
|
8
|
+
rafkit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
rafkit-0.1.0.dist-info/top_level.txt,sha256=88Ez83YyTIb7nzD1WhnXkYIS4JNfKKMmIvhHNJZiVZc,7
|
|
10
|
+
rafkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James P. Galasyn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rafkit
|