rafkit 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
rafkit-0.1.0/PKG-INFO ADDED
@@ -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.
rafkit-0.1.0/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # rafkit
2
+
3
+ [![CI](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/JimGalasyn/rafkit/branch/main/graph/badge.svg)](https://codecov.io/gh/JimGalasyn/rafkit)
5
+ [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+
9
+ Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
10
+ cores, Kauffman binary polymer models, and interoperability with
11
+ [CatReNet](https://github.com/husonlab/catrenet).
12
+
13
+ Pure Python and NumPy. No Java, no GUI, no install beyond `pip`.
14
+
15
+ ```python
16
+ from rafkit import binary_polymer, max_raf, sample_irrraf
17
+ import numpy as np
18
+
19
+ net = binary_polymer(max_len=8, food_len=2, p=1.5e-3, cleavage=True)
20
+ raf = max_raf(net)
21
+ print(raf.size, "reactions in the maximal RAF")
22
+
23
+ core = sample_irrraf(net, raf.reactions, np.random.default_rng(0))
24
+ print(len(core), "reactions in one irreducible core")
25
+ ```
26
+
27
+ ## Why this exists
28
+
29
+ RAF theory (Hordijk & Steel 2004) formalises collectively autocatalytic sets: a set of
30
+ reactions is a RAF over a food set when every reaction is catalysed by something the
31
+ set can make, and every reactant can be built up from food using the set. The
32
+ reference implementation, **CatReNet**, is an excellent Java/JavaFX desktop
33
+ application. This is a small library for people who want the same algorithms inside a
34
+ Python analysis pipeline.
35
+
36
+ ## Validated against the reference implementation
37
+
38
+ `tests/data/catrenet_polymer_n6.crs` was generated by CatReNet's own `polymer-tool`,
39
+ and the expected counts in `tests/test_crs.py` are what CatReNet's `catrenet-tool`
40
+ reports on it. The test suite therefore checks this implementation against an
41
+ independent one on every run, with no Java required.
42
+
43
+ | algorithm | rafkit | CatReNet 1.1.0 |
44
+ |---|---|---|
45
+ | `max_raf` | 183 | 183 |
46
+ | `catrenet_strictly_autocatalytic` | 175 | 175 |
47
+ | `max_raf_strict` | 161 | *(different object — see below)* |
48
+
49
+ **A documented divergence.** CatReNet's `strictlyAutocatalyticMaxRaf` *filters* the
50
+ maximal RAF for reactions having a non-food catalyst, without re-refining, so its
51
+ result need not itself be a RAF. `max_raf_strict` imposes the same condition inside
52
+ the fixpoint, so its result is a RAF, and is correspondingly smaller. Both are
53
+ available; they answer different questions. CatReNet's behaviour was reproduced by
54
+ black-box inference from its output — no CatReNet source was read or used.
55
+
56
+ ## Calibration
57
+
58
+ The RAF phase transition in Kauffman's binary polymer model, measured here against
59
+ the published value of *f* ≈ 1.20 (Steel, Hordijk & Smith 2012, n=10, t=2), where
60
+ *f* = p|R| is the mean number of catalysed reactions per molecule:
61
+
62
+ | model | transition |
63
+ |---|---|
64
+ | ligation only | *f* ≈ 4.7 |
65
+ | ligation + cleavage | *f* ≈ 3.1 |
66
+ | ligation + cleavage, **catalysis paired per reversible reaction** | 0 seeds at *f* ≤ 1.22, all seeds by *f* ≈ 1.59 |
67
+
68
+ Two conventions have to match before any comparison to the literature means anything:
69
+ the model must include **cleavage**, and a reversible cleavage–ligation pair must be
70
+ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — not
71
+ `mean_catalysed_per_molecule` — whenever a number is placed beside a published *f*.
72
+
73
+ ## What's implemented
74
+
75
+ | | |
76
+ |---|---|
77
+ | `max_raf` | maximal RAF, by fixpoint (Hordijk & Steel 2004) |
78
+ | `max_raf_strict` | maximal RAF whose catalysts must be non-food products |
79
+ | `catrenet_strictly_autocatalytic` | CatReNet's similarly-named filter, for interop |
80
+ | `sample_irrraf` | one irreducible RAF, by randomised shrinking (Steel, Hordijk & Smith 2012) |
81
+ | `irrraf_census` | how many *distinct* irreducible cores a network carries |
82
+ | `exploitability` | share of RAF products contributing no catalysis back |
83
+ | `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
84
+ | `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
85
+ | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
86
+ | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
87
+
88
+ Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
89
+ is subtly wrong produces plausible numbers rather than errors.
90
+
91
+ ## Notes on irreducible cores
92
+
93
+ There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
94
+ finding the smallest is NP-hard (Steel, Hordijk & Smith 2012). `sample_irrraf` returns
95
+ *one*, chosen by the random order it walks; `irrraf_census` samples repeatedly and
96
+ reports how many distinct ones it saw. That count is always a **lower bound**, never
97
+ an upper one.
98
+
99
+ `is_food_catalysed` exists because a core whose every reaction has a food catalyst
100
+ satisfies the letter of the RAF definition while being in no sense self-referential —
101
+ it runs wherever the food runs. Split those out before reading a count of cores as a
102
+ count of anything biological.
103
+
104
+ ## Install
105
+
106
+ ```bash
107
+ pip install rafkit
108
+ ```
109
+
110
+ Development:
111
+
112
+ ```bash
113
+ pip install -e ".[test]"
114
+ pytest -q
115
+ ```
116
+
117
+ Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
118
+ [docs/RELEASING.md](docs/RELEASING.md).
119
+
120
+ ## References
121
+
122
+ - Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
123
+ systems," *J. Theor. Biol.* 227, 451 (2004).
124
+ - Steel, Hordijk & Smith, "Minimal autocatalytic networks," *J. Theor. Biol.* 332, 96
125
+ (2013); arXiv:1212.4450.
126
+ - Hordijk & Steel, "Autocatalytic sets extended: dynamics, inhibition, and a
127
+ generalization," *J. Syst. Chem.* 3, 5 (2012); arXiv:1206.1017.
128
+ - Huson, Xavier & Steel, "CatReNet: interactive analysis of (auto-)catalytic reaction
129
+ networks," *Bioinformatics* 40(8), btae515 (2024).
130
+ - Serra & Villani, "Template-Based Catalysis and the Emergence of Collectively
131
+ Autocatalytic Systems," *Entropy* 28(2), 184 (2026).
132
+
133
+ ## License
134
+
135
+ MIT. CatReNet is GPL v3 and is **not** a dependency — this library interoperates with
136
+ it only through files, and contains no code derived from it.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "rafkit"
3
+ version = "0.1.0"
4
+ description = "Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "James P. Galasyn" },
10
+ { name = "Claude Théodore" },
11
+ ]
12
+ keywords = [
13
+ "autocatalytic-sets", "RAF", "origin-of-life", "chemical-reaction-networks",
14
+ "systems-chemistry", "binary-polymer-model", "catalysis", "abiogenesis",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Scientific/Engineering :: Chemistry",
25
+ "Topic :: Scientific/Engineering :: Artificial Life",
26
+ ]
27
+ dependencies = ["numpy>=1.24"]
28
+
29
+ [project.optional-dependencies]
30
+ test = [
31
+ "pytest>=8",
32
+ "pytest-cov>=4",
33
+ "pytest-xdist>=3", # parallel test execution (pytest -n auto)
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/jimgalasyn/rafkit"
38
+ Issues = "https://github.com/jimgalasyn/rafkit/issues"
39
+
40
+ [build-system]
41
+ requires = ["setuptools>=68"]
42
+ build-backend = "setuptools.build_meta"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
rafkit-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)