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 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
+ ]
@@ -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
+ [![CI](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
32
+ [![codecov](https://codecov.io/gh/JimGalasyn/rafkit/branch/main/graph/badge.svg)](https://codecov.io/gh/JimGalasyn/rafkit)
33
+ [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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