rafkit 0.4.0__tar.gz → 0.5.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.4.0/src/rafkit.egg-info → rafkit-0.5.0}/PKG-INFO +3 -3
- {rafkit-0.4.0 → rafkit-0.5.0}/README.md +2 -2
- {rafkit-0.4.0 → rafkit-0.5.0}/pyproject.toml +1 -1
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/__init__.py +8 -2
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/binary_polymer.py +72 -3
- rafkit-0.5.0/src/rafkit/complementary_polymer.py +115 -0
- rafkit-0.5.0/src/rafkit/firing_disk.py +139 -0
- {rafkit-0.4.0 → rafkit-0.5.0/src/rafkit.egg-info}/PKG-INFO +3 -3
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit.egg-info/SOURCES.txt +4 -0
- rafkit-0.5.0/tests/test_complementary_polymer.py +115 -0
- rafkit-0.5.0/tests/test_firing_disk.py +87 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_inhibition.py +59 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/LICENSE +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/setup.cfg +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/catalysis.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/crs.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/gillespie.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/inhibition.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/network.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/pnml.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit/raf.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit.egg-info/dependency_links.txt +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit.egg-info/requires.txt +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/src/rafkit.egg-info/top_level.txt +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_crs.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_gillespie.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_pnml.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_published_examples.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_raf.py +0 -0
- {rafkit-0.4.0 → rafkit-0.5.0}/tests/test_seeding.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rafkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models.
|
|
5
5
|
Author: James P. Galasyn, Claude Théodore
|
|
6
6
|
License: MIT
|
|
@@ -30,8 +30,8 @@ Dynamic: license-file
|
|
|
30
30
|
|
|
31
31
|
[](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
|
|
32
32
|
[](https://codecov.io/gh/JimGalasyn/rafkit)
|
|
33
|
-
[](https://pypi.org/project/rafkit/)
|
|
34
|
-
[](https://pypi.org/project/rafkit/)
|
|
33
|
+
[](https://pypi.org/project/rafkit/)
|
|
34
|
+
[](https://pypi.org/project/rafkit/)
|
|
35
35
|
[](LICENSE)
|
|
36
36
|
[](https://doi.org/10.5281/zenodo.21954795)
|
|
37
37
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
|
|
4
4
|
[](https://codecov.io/gh/JimGalasyn/rafkit)
|
|
5
|
-
[](https://pypi.org/project/rafkit/)
|
|
6
|
-
[](https://pypi.org/project/rafkit/)
|
|
5
|
+
[](https://pypi.org/project/rafkit/)
|
|
6
|
+
[](https://pypi.org/project/rafkit/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://doi.org/10.5281/zenodo.21954795)
|
|
9
9
|
|
|
@@ -13,6 +13,9 @@ See the README for the calibration against Steel, Hordijk & Smith (2012) and for
|
|
|
13
13
|
CatReNet interoperability.
|
|
14
14
|
"""
|
|
15
15
|
from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
|
|
16
|
+
from rafkit.complementary_polymer import (complement,
|
|
17
|
+
complementary_polymer)
|
|
18
|
+
from rafkit.firing_disk import firing_disk_polymer
|
|
16
19
|
from rafkit.catalysis import catalysing_molecules, is_catalysed, normalise
|
|
17
20
|
from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
|
|
18
21
|
from rafkit.gillespie import Trajectory, propensities, simulate
|
|
@@ -26,10 +29,13 @@ from rafkit.raf import (
|
|
|
26
29
|
sample_irrraf,
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "0.5.0"
|
|
30
33
|
|
|
31
34
|
__all__ = [
|
|
32
|
-
"BinaryPolymerNetwork", "binary_polymer",
|
|
35
|
+
"BinaryPolymerNetwork", "binary_polymer",
|
|
36
|
+
"complementary_polymer",
|
|
37
|
+
"firing_disk_polymer",
|
|
38
|
+
"complement", "ReactionNetwork",
|
|
33
39
|
"RafResult", "max_raf", "max_raf_strict", "sample_irrraf", "irrraf_census",
|
|
34
40
|
"exploitability", "is_food_catalysed", "catrenet_strictly_autocatalytic",
|
|
35
41
|
"core_raf", "has_unique_irraf",
|
|
@@ -66,6 +66,7 @@ class BinaryPolymerNetwork:
|
|
|
66
66
|
max_len: int
|
|
67
67
|
food_len: int
|
|
68
68
|
directions: tuple[int, ...] = ()
|
|
69
|
+
inhibitors: tuple[frozenset[int], ...] = ()
|
|
69
70
|
|
|
70
71
|
def __post_init__(self):
|
|
71
72
|
object.__setattr__(self, "catalysts",
|
|
@@ -76,6 +77,16 @@ class BinaryPolymerNetwork:
|
|
|
76
77
|
raise ValueError(
|
|
77
78
|
f"directions has {len(self.directions)} entries for "
|
|
78
79
|
f"{len(self.reactions)} reactions")
|
|
80
|
+
if not self.inhibitors:
|
|
81
|
+
object.__setattr__(self, "inhibitors",
|
|
82
|
+
(frozenset(),) * len(self.reactions))
|
|
83
|
+
elif len(self.inhibitors) != len(self.reactions):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"inhibitors has {len(self.inhibitors)} entries for "
|
|
86
|
+
f"{len(self.reactions)} reactions")
|
|
87
|
+
else:
|
|
88
|
+
object.__setattr__(self, "inhibitors",
|
|
89
|
+
tuple(frozenset(i) for i in self.inhibitors))
|
|
79
90
|
|
|
80
91
|
def reactants(self, r: int) -> tuple[int, ...]:
|
|
81
92
|
"""Molecules consumed by reaction `r`, in its stored direction."""
|
|
@@ -105,7 +116,28 @@ class BinaryPolymerNetwork:
|
|
|
105
116
|
if not self.molecules:
|
|
106
117
|
return 0.0
|
|
107
118
|
n_pairs = self.n_reactions - self.n_cleavages
|
|
108
|
-
|
|
119
|
+
# Counted over the reversible PAIR: a cleavage-ligation pair is one reaction, so
|
|
120
|
+
# take the union of the two directions' catalysts. Under `paired_catalysis` the
|
|
121
|
+
# halves are identical and this is exactly the old count; it differs only where
|
|
122
|
+
# the directions were drawn separately -- as in C-BPM, where a catalyst acts on
|
|
123
|
+
# one direction only and counting the ligation half alone would miss every
|
|
124
|
+
# cleavage catalyst.
|
|
125
|
+
total = 0
|
|
126
|
+
for i in range(n_pairs):
|
|
127
|
+
both = self.catalysts[i]
|
|
128
|
+
if i + n_pairs < self.n_reactions:
|
|
129
|
+
both = both | self.catalysts[i + n_pairs]
|
|
130
|
+
total += len(both)
|
|
131
|
+
return total / len(self.molecules)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def n_inhibiting_molecules(self) -> int:
|
|
135
|
+
"""`k` in Hordijk & Steel's encoding — distinct molecules that inhibit.
|
|
136
|
+
|
|
137
|
+
This is the exponent in `max_urafs`'s `2**k` cost, not a chemistry parameter,
|
|
138
|
+
which is why `binary_polymer` lets you cap it directly.
|
|
139
|
+
"""
|
|
140
|
+
return len({x for inh in self.inhibitors for x in inh})
|
|
109
141
|
|
|
110
142
|
@property
|
|
111
143
|
def n_molecules(self) -> int:
|
|
@@ -136,7 +168,9 @@ def _strings(max_len: int) -> Iterator[str]:
|
|
|
136
168
|
def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
|
|
137
169
|
rng: np.random.Generator | None = None,
|
|
138
170
|
cleavage: bool = False,
|
|
139
|
-
paired_catalysis: bool = True
|
|
171
|
+
paired_catalysis: bool = True,
|
|
172
|
+
q: float = 0.0,
|
|
173
|
+
n_inhibitors: int | None = None) -> BinaryPolymerNetwork:
|
|
140
174
|
"""Generate one binary-polymer network with catalysis at probability `p`.
|
|
141
175
|
|
|
142
176
|
`cleavage=True` adds the reverse `ab -> a + b` of every ligation, doubling the
|
|
@@ -147,6 +181,20 @@ def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
|
|
|
147
181
|
reaction of Steel, Hordijk & Smith (2012), and the convention their f = p|R| is
|
|
148
182
|
measured in. Set False to draw the two directions independently; that is a
|
|
149
183
|
different chemistry and its f is not comparable to theirs.
|
|
184
|
+
|
|
185
|
+
`q` is the inhibition probability, the mirror of `p`: each (eligible molecule,
|
|
186
|
+
reaction) pair becomes an inhibition edge with probability `q` (Hordijk & Steel
|
|
187
|
+
2012, Part II). `q=0`, the default, leaves the network uninhibited and every
|
|
188
|
+
existing result unchanged.
|
|
189
|
+
|
|
190
|
+
`n_inhibitors` caps how many **distinct molecules** may inhibit, drawn uniformly.
|
|
191
|
+
That cap is `k` in the (X_i, R_i) encoding, and `max_urafs` costs `2**k` maximal-RAF
|
|
192
|
+
computations -- so it is the difference between a feasible u-RAF census and an
|
|
193
|
+
impossible one. Leaving it `None` makes every molecule eligible, which is faithful
|
|
194
|
+
to the model but puts `max_urafs` out of reach on any network of interesting size;
|
|
195
|
+
`is_uraf` and `is_uninhibited` stay cheap either way. Inhibition is drawn on the
|
|
196
|
+
ligation half and **shared with the reverse** under `paired_catalysis`, exactly as
|
|
197
|
+
catalysis is, so the reversible reaction remains one unit.
|
|
150
198
|
"""
|
|
151
199
|
if max_len < 2:
|
|
152
200
|
raise ValueError(f"max_len must be at least 2, got {max_len}")
|
|
@@ -154,6 +202,10 @@ def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
|
|
|
154
202
|
raise ValueError(f"food_len must be in [0, max_len), got {food_len}")
|
|
155
203
|
if not 0.0 <= p <= 1.0:
|
|
156
204
|
raise ValueError(f"p is a probability, got {p}")
|
|
205
|
+
if not 0.0 <= q <= 1.0:
|
|
206
|
+
raise ValueError(f"q is a probability, got {q}")
|
|
207
|
+
if n_inhibitors is not None and n_inhibitors < 0:
|
|
208
|
+
raise ValueError(f"n_inhibitors must be non-negative, got {n_inhibitors}")
|
|
157
209
|
rng = rng or np.random.default_rng()
|
|
158
210
|
|
|
159
211
|
molecules = tuple(_strings(max_len))
|
|
@@ -184,6 +236,23 @@ def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
|
|
|
184
236
|
)
|
|
185
237
|
# Paired: the cleavage half re-uses its ligation's catalysts rather than redrawing.
|
|
186
238
|
catalysts = drawn + drawn if (cleavage and paired_catalysis) else drawn
|
|
239
|
+
|
|
240
|
+
# Inhibition, drawn the same sparse way and over the same paired unit as catalysis.
|
|
241
|
+
# Eligibility is restricted FIRST so that k is exactly n_inhibitors, rather than
|
|
242
|
+
# whatever a q-dependent draw happens to produce.
|
|
243
|
+
if q > 0.0:
|
|
244
|
+
eligible = (np.arange(n_mol) if n_inhibitors is None else
|
|
245
|
+
rng.choice(n_mol, size=min(n_inhibitors, n_mol), replace=False))
|
|
246
|
+
i_counts = rng.binomial(len(eligible), q, size=n_draw)
|
|
247
|
+
i_drawn = tuple(
|
|
248
|
+
frozenset(rng.choice(eligible, size=int(k), replace=False).tolist()) if k
|
|
249
|
+
else frozenset()
|
|
250
|
+
for k in i_counts
|
|
251
|
+
)
|
|
252
|
+
inhibitors = (i_drawn + i_drawn if (cleavage and paired_catalysis) else i_drawn)
|
|
253
|
+
else:
|
|
254
|
+
inhibitors = ()
|
|
187
255
|
return BinaryPolymerNetwork(molecules=molecules, food=food, reactions=reactions,
|
|
188
256
|
catalysts=catalysts, p=p, max_len=max_len,
|
|
189
|
-
food_len=food_len, directions=directions
|
|
257
|
+
food_len=food_len, directions=directions,
|
|
258
|
+
inhibitors=inhibitors)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""E5 — Serra & Villani's C-BPM, where catalysis follows STRUCTURE rather than a coin flip.
|
|
2
|
+
|
|
3
|
+
Reproduced from Serra & Villani, *Entropy* 28(2), 184 (2026), §2.2, rather than invented:
|
|
4
|
+
an in-house structured-catalysis ensemble would be a worse version of a published one.
|
|
5
|
+
|
|
6
|
+
**The reaction set is identical to the K-BPM's.** Cleavage splits a polymer, condensation
|
|
7
|
+
joins two, and the condensation product ``R-M1M2-R'`` is just the concatenation of the two
|
|
8
|
+
reactants. What differs is *which catalyst catalyses which reaction*, and that single change
|
|
9
|
+
is the whole point of the model:
|
|
10
|
+
|
|
11
|
+
* a species is a catalyst with probability ``p_cat``, and a catalyst is a **cleavage**
|
|
12
|
+
catalyst with probability ``p_cleave``, otherwise a **condensation** catalyst;
|
|
13
|
+
* a catalyst carries an **active site** — a substring of itself, of length ``Lambda`` drawn
|
|
14
|
+
uniformly from ``[site_min, site_max]`` — plus a cut/suture position inside that site;
|
|
15
|
+
* it acts on whatever is **complementary** to that site (binary complement, 0<->1).
|
|
16
|
+
|
|
17
|
+
The consequence, and the reason this ensemble exists: a K-catalyst's targets are
|
|
18
|
+
independent draws, while a **C-catalyst's targets all share one template**, so they are
|
|
19
|
+
structurally correlated. Serra & Villani measure the signature of that as a much higher and
|
|
20
|
+
much more irregular reactions-per-catalyst distribution (~400 against ~20).
|
|
21
|
+
|
|
22
|
+
Only polymers at least as long as the active site can be catalysts, which falls out of the
|
|
23
|
+
construction rather than being imposed.
|
|
24
|
+
|
|
25
|
+
⚠ **This implements their "total chemistry" mode** — every species up to ``max_len`` exists
|
|
26
|
+
and catalysis is assigned over that set. Their alternative **firing-disk** mode grows the
|
|
27
|
+
species set outward from a small seed and is a genuinely different construction, not a
|
|
28
|
+
parameter of this one; it is not implemented here.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from rafkit.binary_polymer import BinaryPolymerNetwork, _strings
|
|
35
|
+
|
|
36
|
+
_FLIP = str.maketrans("01", "10")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def complement(s: str) -> str:
|
|
40
|
+
"""Binary complement — the paper's "an A must correspond to a B, and vice versa"."""
|
|
41
|
+
return s.translate(_FLIP)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def complementary_polymer(max_len: int = 8, food_len: int = 2, p_cat: float = 0.05,
|
|
45
|
+
p_cleave: float = 0.5, site_min: int = 3, site_max: int = 4,
|
|
46
|
+
rng: np.random.Generator | None = None) -> BinaryPolymerNetwork:
|
|
47
|
+
"""Generate one C-chemistry: catalysis by active-site complementarity.
|
|
48
|
+
|
|
49
|
+
Defaults are the paper's Figure 3 ensemble (``p_cat=0.05``, ``p_cleave=0.5``, active
|
|
50
|
+
sites uniform on 3..4). Returns the same `BinaryPolymerNetwork` a K-chemistry does, so
|
|
51
|
+
every downstream consumer -- `max_raf`, the protocell, the u-RAF layer -- is unchanged.
|
|
52
|
+
"""
|
|
53
|
+
if max_len < 2:
|
|
54
|
+
raise ValueError(f"max_len must be at least 2, got {max_len}")
|
|
55
|
+
if not 0 <= food_len < max_len:
|
|
56
|
+
raise ValueError(f"food_len must be in [0, max_len), got {food_len}")
|
|
57
|
+
for name, v in (("p_cat", p_cat), ("p_cleave", p_cleave)):
|
|
58
|
+
if not 0.0 <= v <= 1.0:
|
|
59
|
+
raise ValueError(f"{name} is a probability, got {v}")
|
|
60
|
+
if not 1 <= site_min <= site_max:
|
|
61
|
+
raise ValueError(f"need 1 <= site_min <= site_max, got {site_min}, {site_max}")
|
|
62
|
+
rng = rng or np.random.default_rng()
|
|
63
|
+
|
|
64
|
+
molecules = tuple(_strings(max_len))
|
|
65
|
+
index = {m: i for i, m in enumerate(molecules)}
|
|
66
|
+
food = frozenset(i for i, m in enumerate(molecules) if len(m) <= food_len)
|
|
67
|
+
|
|
68
|
+
ligations = tuple(
|
|
69
|
+
(index[a], index[b], index[a + b])
|
|
70
|
+
for a in molecules for b in molecules if len(a) + len(b) <= max_len
|
|
71
|
+
)
|
|
72
|
+
reactions = ligations + ligations
|
|
73
|
+
directions = (1,) * len(ligations) + (-1,) * len(ligations)
|
|
74
|
+
n_pairs = len(ligations)
|
|
75
|
+
lig_at = {(a, b): i for i, (a, b, _) in enumerate(ligations)}
|
|
76
|
+
# cleavage of `ab` at offset k is the reverse of the ligation a + b -> ab
|
|
77
|
+
cleave_at = {(index[m], k): n_pairs + lig_at[(index[m[:k]], index[m[k:]])]
|
|
78
|
+
for m in molecules for k in range(1, len(m))}
|
|
79
|
+
|
|
80
|
+
by_prefix: dict[str, list[str]] = {}
|
|
81
|
+
by_suffix: dict[str, list[str]] = {}
|
|
82
|
+
for m in molecules:
|
|
83
|
+
for k in range(1, len(m) + 1):
|
|
84
|
+
by_prefix.setdefault(m[:k], []).append(m)
|
|
85
|
+
by_suffix.setdefault(m[-k:], []).append(m)
|
|
86
|
+
|
|
87
|
+
catalysts: list[set[int]] = [set() for _ in reactions]
|
|
88
|
+
for x, mol in enumerate(molecules):
|
|
89
|
+
if len(mol) < site_min or rng.random() >= p_cat:
|
|
90
|
+
continue
|
|
91
|
+
width = int(rng.integers(site_min, min(site_max, len(mol)) + 1))
|
|
92
|
+
start = int(rng.integers(0, len(mol) - width + 1))
|
|
93
|
+
site = mol[start:start + width]
|
|
94
|
+
cut = int(rng.integers(1, width)) # split point inside the active site
|
|
95
|
+
if rng.random() < p_cleave:
|
|
96
|
+
# cleave any polymer carrying a segment complementary to the whole site
|
|
97
|
+
target = complement(site)
|
|
98
|
+
for m in molecules:
|
|
99
|
+
pos = m.find(target)
|
|
100
|
+
while pos != -1:
|
|
101
|
+
catalysts[cleave_at[(index[m], pos + cut)]].add(x)
|
|
102
|
+
pos = m.find(target, pos + 1)
|
|
103
|
+
else:
|
|
104
|
+
# join a molecule ENDING complementary to site[:cut] to one STARTING
|
|
105
|
+
# complementary to site[cut:]
|
|
106
|
+
left, right = complement(site[:cut]), complement(site[cut:])
|
|
107
|
+
for a in by_suffix.get(left, ()):
|
|
108
|
+
for b in by_prefix.get(right, ()):
|
|
109
|
+
if len(a) + len(b) <= max_len:
|
|
110
|
+
catalysts[lig_at[(index[a], index[b])]].add(x)
|
|
111
|
+
|
|
112
|
+
return BinaryPolymerNetwork(
|
|
113
|
+
molecules=molecules, food=food, reactions=reactions,
|
|
114
|
+
catalysts=tuple(frozenset(c) for c in catalysts),
|
|
115
|
+
p=p_cat, max_len=max_len, food_len=food_len, directions=directions)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Serra & Villani's FIRING-DISK construction — a chemistry grown, not enumerated.
|
|
2
|
+
|
|
3
|
+
Reproduced from *Entropy* 28(2), 184 (2026), §2.2. The contrast with every other generator
|
|
4
|
+
here is the point:
|
|
5
|
+
|
|
6
|
+
* `binary_polymer` and `complementary_polymer` **enumerate** every string up to `max_len`
|
|
7
|
+
and sprinkle catalysis over the result. A species exists because it is short enough.
|
|
8
|
+
* the firing disk **grows** outward from a small seed. A species exists only if some
|
|
9
|
+
reaction in the network actually **makes** it.
|
|
10
|
+
|
|
11
|
+
So a firing-disk chemistry is closed under its own production by construction, where an
|
|
12
|
+
enumerated one is full of species nothing can reach. That difference plausibly matters a
|
|
13
|
+
great deal for protocell viability, because unreachable species dilute the material without
|
|
14
|
+
contributing to it -- which is why this exists.
|
|
15
|
+
|
|
16
|
+
The loop is theirs: seed a disk of short polymers, designate some as cleavage or
|
|
17
|
+
condensation catalysts, find every reaction the current catalysts enable among the current
|
|
18
|
+
species, run them, and give each **newly generated** species a chance `p_cat` of being a
|
|
19
|
+
catalyst itself -- iterating "until there are no more new reactions or new species to add,
|
|
20
|
+
or some termination condition is met."
|
|
21
|
+
|
|
22
|
+
⚠ **Food is taken to be the firing disk.** The paper does not say what plays the role of
|
|
23
|
+
food when such a chemistry is embedded in a protocell, and the disk is the only externally
|
|
24
|
+
given set, so that is the reading used here -- an assumption, not a quotation.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from rafkit.binary_polymer import BinaryPolymerNetwork, _strings
|
|
31
|
+
from rafkit.complementary_polymer import complement
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _site(mol, site_min, site_max, rng):
|
|
35
|
+
width = int(rng.integers(site_min, min(site_max, len(mol)) + 1))
|
|
36
|
+
start = int(rng.integers(0, len(mol) - width + 1))
|
|
37
|
+
site = mol[start:start + width]
|
|
38
|
+
return site, int(rng.integers(1, width))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def firing_disk_polymer(disk_size: int = 24, disk_len: int = 4, max_len: int = 10,
|
|
42
|
+
p_cat: float = 0.05, p_cleave: float = 0.5,
|
|
43
|
+
n_cleave_cat: int = 2, n_cond_cat: int = 2,
|
|
44
|
+
site_min: int = 3, site_max: int = 4,
|
|
45
|
+
max_species: int = 4000, max_rounds: int = 200,
|
|
46
|
+
rng: np.random.Generator | None = None) -> BinaryPolymerNetwork:
|
|
47
|
+
"""Grow one C-chemistry outward from a firing disk.
|
|
48
|
+
|
|
49
|
+
Defaults follow the paper's Figure 3 ensemble where stated: 24 initial species of
|
|
50
|
+
length <= 4, ``Lmax = 10``, ``p_cat = 0.05``, ``p_cleave = 0.5``, active sites uniform
|
|
51
|
+
on 3..4. ``n_cleave_cat`` / ``n_cond_cat`` are their ``NCLini`` / ``NCDini``, whose
|
|
52
|
+
values Figure 3 does not state.
|
|
53
|
+
|
|
54
|
+
``max_species`` and ``max_rounds`` are the "termination condition" their text leaves
|
|
55
|
+
open; both are reported through the returned network's size rather than raising, but a
|
|
56
|
+
run that hits ``max_species`` is a **truncated** chemistry and should be treated as
|
|
57
|
+
such.
|
|
58
|
+
"""
|
|
59
|
+
if not 1 <= site_min <= site_max:
|
|
60
|
+
raise ValueError(f"need 1 <= site_min <= site_max, got {site_min}, {site_max}")
|
|
61
|
+
for name, v in (("p_cat", p_cat), ("p_cleave", p_cleave)):
|
|
62
|
+
if not 0.0 <= v <= 1.0:
|
|
63
|
+
raise ValueError(f"{name} is a probability, got {v}")
|
|
64
|
+
rng = rng or np.random.default_rng()
|
|
65
|
+
|
|
66
|
+
pool = [m for m in _strings(disk_len) if len(m) >= 1]
|
|
67
|
+
disk = list(rng.choice(pool, size=min(disk_size, len(pool)), replace=False))
|
|
68
|
+
species: set[str] = set(disk)
|
|
69
|
+
|
|
70
|
+
# catalyst -> (site, cut, is_cleaver); seeded per NCLini / NCDini, then grown by p_cat
|
|
71
|
+
cats: dict[str, tuple[str, int, bool]] = {}
|
|
72
|
+
eligible = [m for m in disk if len(m) >= site_min]
|
|
73
|
+
rng.shuffle(eligible)
|
|
74
|
+
for m in eligible[:n_cleave_cat]:
|
|
75
|
+
s, c = _site(m, site_min, site_max, rng); cats[m] = (s, c, True)
|
|
76
|
+
for m in eligible[n_cleave_cat:n_cleave_cat + n_cond_cat]:
|
|
77
|
+
s, c = _site(m, site_min, site_max, rng); cats[m] = (s, c, False)
|
|
78
|
+
|
|
79
|
+
# split -> set of catalysts, for each direction; a "split" is the pair (a, b) of ab
|
|
80
|
+
lig: dict[tuple[str, str], set[str]] = {}
|
|
81
|
+
cle: dict[tuple[str, str], set[str]] = {}
|
|
82
|
+
|
|
83
|
+
for _ in range(max_rounds):
|
|
84
|
+
fresh: set[str] = set()
|
|
85
|
+
by_pre: dict[str, list[str]] = {}
|
|
86
|
+
by_suf: dict[str, list[str]] = {}
|
|
87
|
+
for m in species:
|
|
88
|
+
for k in range(1, len(m) + 1):
|
|
89
|
+
by_pre.setdefault(m[:k], []).append(m)
|
|
90
|
+
by_suf.setdefault(m[-k:], []).append(m)
|
|
91
|
+
for cat, (site, cut, is_cleaver) in list(cats.items()):
|
|
92
|
+
if is_cleaver:
|
|
93
|
+
target = complement(site)
|
|
94
|
+
for m in list(species):
|
|
95
|
+
pos = m.find(target)
|
|
96
|
+
while pos != -1:
|
|
97
|
+
k = pos + cut
|
|
98
|
+
if 0 < k < len(m):
|
|
99
|
+
a, b = m[:k], m[k:]
|
|
100
|
+
cle.setdefault((a, b), set()).add(cat)
|
|
101
|
+
fresh.update({a, b} - species)
|
|
102
|
+
pos = m.find(target, pos + 1)
|
|
103
|
+
else:
|
|
104
|
+
left, right = complement(site[:cut]), complement(site[cut:])
|
|
105
|
+
for a in by_suf.get(left, ()):
|
|
106
|
+
for b in by_pre.get(right, ()):
|
|
107
|
+
if len(a) + len(b) <= max_len:
|
|
108
|
+
lig.setdefault((a, b), set()).add(cat)
|
|
109
|
+
if a + b not in species:
|
|
110
|
+
fresh.add(a + b)
|
|
111
|
+
if not fresh:
|
|
112
|
+
break
|
|
113
|
+
for m in sorted(fresh):
|
|
114
|
+
if len(species) >= max_species:
|
|
115
|
+
break
|
|
116
|
+
species.add(m)
|
|
117
|
+
if len(m) >= site_min and rng.random() < p_cat:
|
|
118
|
+
s, c = _site(m, site_min, site_max, rng)
|
|
119
|
+
cats[m] = (s, c, rng.random() < p_cleave)
|
|
120
|
+
|
|
121
|
+
# Emit in the paired layout the rest of the library expects: every split that appears
|
|
122
|
+
# in either direction becomes one ligation and one cleavage, each carrying only the
|
|
123
|
+
# catalysts actually assigned to that direction.
|
|
124
|
+
splits = sorted(set(lig) | set(cle))
|
|
125
|
+
splits = [(a, b) for a, b in splits if a in species and b in species
|
|
126
|
+
and a + b in species]
|
|
127
|
+
molecules = tuple(sorted(species, key=lambda m: (len(m), m)))
|
|
128
|
+
index = {m: i for i, m in enumerate(molecules)}
|
|
129
|
+
ligations = tuple((index[a], index[b], index[a + b]) for a, b in splits)
|
|
130
|
+
reactions = ligations + ligations
|
|
131
|
+
directions = (1,) * len(ligations) + (-1,) * len(ligations)
|
|
132
|
+
catalysts = tuple(frozenset(index[c] for c in lig.get(s, ()) if c in index)
|
|
133
|
+
for s in splits) + \
|
|
134
|
+
tuple(frozenset(index[c] for c in cle.get(s, ()) if c in index)
|
|
135
|
+
for s in splits)
|
|
136
|
+
food = frozenset(index[m] for m in disk if m in index)
|
|
137
|
+
return BinaryPolymerNetwork(
|
|
138
|
+
molecules=molecules, food=food, reactions=reactions, catalysts=catalysts,
|
|
139
|
+
p=p_cat, max_len=max_len, food_len=disk_len, directions=directions)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rafkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models.
|
|
5
5
|
Author: James P. Galasyn, Claude Théodore
|
|
6
6
|
License: MIT
|
|
@@ -30,8 +30,8 @@ Dynamic: license-file
|
|
|
30
30
|
|
|
31
31
|
[](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
|
|
32
32
|
[](https://codecov.io/gh/JimGalasyn/rafkit)
|
|
33
|
-
[](https://pypi.org/project/rafkit/)
|
|
34
|
-
[](https://pypi.org/project/rafkit/)
|
|
33
|
+
[](https://pypi.org/project/rafkit/)
|
|
34
|
+
[](https://pypi.org/project/rafkit/)
|
|
35
35
|
[](LICENSE)
|
|
36
36
|
[](https://doi.org/10.5281/zenodo.21954795)
|
|
37
37
|
|
|
@@ -4,7 +4,9 @@ pyproject.toml
|
|
|
4
4
|
src/rafkit/__init__.py
|
|
5
5
|
src/rafkit/binary_polymer.py
|
|
6
6
|
src/rafkit/catalysis.py
|
|
7
|
+
src/rafkit/complementary_polymer.py
|
|
7
8
|
src/rafkit/crs.py
|
|
9
|
+
src/rafkit/firing_disk.py
|
|
8
10
|
src/rafkit/gillespie.py
|
|
9
11
|
src/rafkit/inhibition.py
|
|
10
12
|
src/rafkit/network.py
|
|
@@ -15,7 +17,9 @@ src/rafkit.egg-info/SOURCES.txt
|
|
|
15
17
|
src/rafkit.egg-info/dependency_links.txt
|
|
16
18
|
src/rafkit.egg-info/requires.txt
|
|
17
19
|
src/rafkit.egg-info/top_level.txt
|
|
20
|
+
tests/test_complementary_polymer.py
|
|
18
21
|
tests/test_crs.py
|
|
22
|
+
tests/test_firing_disk.py
|
|
19
23
|
tests/test_gillespie.py
|
|
20
24
|
tests/test_inhibition.py
|
|
21
25
|
tests/test_pnml.py
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""C-BPM — Serra & Villani, *Entropy* 28(2), 184 (2026), §2.2.
|
|
2
|
+
|
|
3
|
+
The load-bearing test is `test_every_catalysed_reaction_has_a_matching_site`: it re-derives
|
|
4
|
+
the complementarity rule from the catalyst's own string, independently of the code that
|
|
5
|
+
assigned it. Everything else is a property from the paper.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from rafkit import binary_polymer, complement, complementary_polymer, max_raf
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _catalysts(net):
|
|
16
|
+
"""Flatten the catalyst relation: each entry is a set of conjunctive GROUPS."""
|
|
17
|
+
return {x for entry in net.catalysts for group in entry for x in group}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _edges(net):
|
|
21
|
+
return sum(len(group) for entry in net.catalysts for group in entry)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sites(mol, site_min, site_max):
|
|
25
|
+
for w in range(site_min, min(site_max, len(mol)) + 1):
|
|
26
|
+
for i in range(len(mol) - w + 1):
|
|
27
|
+
yield mol[i:i + w]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestComplement:
|
|
31
|
+
def test_flips_bits_and_is_an_involution(self):
|
|
32
|
+
assert complement("100110") == "011001" # the paper's own example
|
|
33
|
+
assert complement(complement("10110")) == "10110"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestConstruction:
|
|
37
|
+
def test_reaction_set_matches_the_k_model(self):
|
|
38
|
+
"""C-BPM changes WHICH catalyst acts, not what reactions exist."""
|
|
39
|
+
c = complementary_polymer(max_len=6, rng=np.random.default_rng(0))
|
|
40
|
+
k = binary_polymer(max_len=6, cleavage=True, rng=np.random.default_rng(0))
|
|
41
|
+
assert c.molecules == k.molecules
|
|
42
|
+
assert c.reactions == k.reactions
|
|
43
|
+
assert c.directions == k.directions
|
|
44
|
+
|
|
45
|
+
def test_no_catalysis_when_p_cat_zero(self):
|
|
46
|
+
c = complementary_polymer(max_len=6, p_cat=0.0, rng=np.random.default_rng(0))
|
|
47
|
+
assert all(not s for s in c.catalysts)
|
|
48
|
+
assert c.catalysis_level == 0.0
|
|
49
|
+
|
|
50
|
+
def test_only_polymers_at_least_as_long_as_a_site_catalyse(self):
|
|
51
|
+
"""Falls out of the construction; the paper states it as a consequence."""
|
|
52
|
+
c = complementary_polymer(max_len=7, p_cat=1.0, site_min=4, site_max=4,
|
|
53
|
+
rng=np.random.default_rng(1))
|
|
54
|
+
for x in _catalysts(c):
|
|
55
|
+
assert len(c.molecules[x]) >= 4
|
|
56
|
+
|
|
57
|
+
def test_deterministic_for_a_seed(self):
|
|
58
|
+
a = complementary_polymer(max_len=6, rng=np.random.default_rng(5))
|
|
59
|
+
b = complementary_polymer(max_len=6, rng=np.random.default_rng(5))
|
|
60
|
+
assert a.catalysts == b.catalysts
|
|
61
|
+
|
|
62
|
+
def test_every_catalysed_reaction_has_a_matching_site(self):
|
|
63
|
+
"""Re-derive the rule from the catalyst's string, not from the assigning code.
|
|
64
|
+
|
|
65
|
+
For a condensation the catalyst must hold a site whose first part complements the
|
|
66
|
+
first reactant's SUFFIX and whose second part complements the second's PREFIX; for
|
|
67
|
+
a cleavage, a site complementary to a segment spanning the cut in the substrate.
|
|
68
|
+
"""
|
|
69
|
+
smin, smax = 3, 4
|
|
70
|
+
c = complementary_polymer(max_len=7, p_cat=0.3, site_min=smin, site_max=smax,
|
|
71
|
+
rng=np.random.default_rng(3))
|
|
72
|
+
checked = 0
|
|
73
|
+
for r, cats in enumerate(c.catalysts):
|
|
74
|
+
for group in cats:
|
|
75
|
+
for x in group:
|
|
76
|
+
cat = c.molecules[x]
|
|
77
|
+
a, b, ab = (c.molecules[i] for i in c.reactions[r])
|
|
78
|
+
if c.directions[r] > 0: # condensation a + b -> ab
|
|
79
|
+
ok = any(a.endswith(complement(s[:k])) and
|
|
80
|
+
b.startswith(complement(s[k:]))
|
|
81
|
+
for s in _sites(cat, smin, smax)
|
|
82
|
+
for k in range(1, len(s)))
|
|
83
|
+
else: # cleavage ab -> a + b
|
|
84
|
+
cut = len(a)
|
|
85
|
+
ok = any(complement(s) in
|
|
86
|
+
{ab[cut - k: cut - k + len(s)] for k in range(1, len(s))}
|
|
87
|
+
for s in _sites(cat, smin, smax))
|
|
88
|
+
assert ok, f"{cat!r} catalyses {a!r}+{b!r}->{ab!r} with no matching site"
|
|
89
|
+
checked += 1
|
|
90
|
+
assert checked > 100, f"only {checked} assignments exercised"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestPaperSignature:
|
|
94
|
+
def test_far_fewer_catalysts_each_doing_far_more(self):
|
|
95
|
+
"""Their headline contrast: ~400 reactions per C-catalyst against ~20 for K."""
|
|
96
|
+
c = complementary_polymer(max_len=8, p_cat=0.05, rng=np.random.default_rng(0))
|
|
97
|
+
k = binary_polymer(max_len=8, p=0.003, cleavage=True, rng=np.random.default_rng(0))
|
|
98
|
+
per = lambda n: _edges(n) / max(len(_catalysts(n)), 1)
|
|
99
|
+
assert per(c) > 5 * per(k)
|
|
100
|
+
assert len(_catalysts(c)) < len(_catalysts(k)) / 5
|
|
101
|
+
|
|
102
|
+
def test_hosts_a_raf(self):
|
|
103
|
+
c = complementary_polymer(max_len=8, p_cat=0.05, rng=np.random.default_rng(0))
|
|
104
|
+
assert len(max_raf(c).reactions) > 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestValidation:
|
|
108
|
+
@pytest.mark.parametrize("kw", [dict(p_cat=1.5), dict(p_cleave=-0.1)])
|
|
109
|
+
def test_probabilities_checked(self, kw):
|
|
110
|
+
with pytest.raises(ValueError, match="is a probability"):
|
|
111
|
+
complementary_polymer(max_len=5, **kw)
|
|
112
|
+
|
|
113
|
+
def test_site_bounds_checked(self):
|
|
114
|
+
with pytest.raises(ValueError, match="site_min <= site_max"):
|
|
115
|
+
complementary_polymer(max_len=5, site_min=4, site_max=3)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The firing-disk construction — Serra & Villani, *Entropy* 28(2), 184 (2026), §2.2.
|
|
2
|
+
|
|
3
|
+
The load-bearing property is `test_every_species_is_produced_or_seed`: a grown chemistry
|
|
4
|
+
is closed under its own production, which is exactly what distinguishes it from an
|
|
5
|
+
enumerated one and is the reason it exists here.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from rafkit import binary_polymer, firing_disk_polymer, max_raf
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestGrowth:
|
|
16
|
+
def test_every_species_is_produced_or_seed(self):
|
|
17
|
+
"""No unreachable species: each molecule is food, or some reaction makes it."""
|
|
18
|
+
net = firing_disk_polymer(rng=np.random.default_rng(0))
|
|
19
|
+
made = {m for r in range(net.n_reactions) for m in net.products(r)}
|
|
20
|
+
for x in range(net.n_molecules):
|
|
21
|
+
assert x in net.food or x in made, f"{net.molecules[x]!r} unreachable"
|
|
22
|
+
|
|
23
|
+
def test_disk_is_the_food_set(self):
|
|
24
|
+
net = firing_disk_polymer(disk_size=16, disk_len=4,
|
|
25
|
+
rng=np.random.default_rng(1))
|
|
26
|
+
assert len(net.food) == 16
|
|
27
|
+
assert all(len(net.molecules[x]) <= 4 for x in net.food)
|
|
28
|
+
|
|
29
|
+
def test_respects_max_len(self):
|
|
30
|
+
net = firing_disk_polymer(max_len=6, rng=np.random.default_rng(2))
|
|
31
|
+
assert all(len(m) <= 6 for m in net.molecules)
|
|
32
|
+
|
|
33
|
+
def test_no_seed_catalysts_means_no_growth(self):
|
|
34
|
+
"""Nothing can fire, so the chemistry is the disk and there are no reactions."""
|
|
35
|
+
net = firing_disk_polymer(n_cleave_cat=0, n_cond_cat=0, p_cat=0.0,
|
|
36
|
+
disk_size=20, rng=np.random.default_rng(3))
|
|
37
|
+
assert net.n_reactions == 0
|
|
38
|
+
assert net.n_molecules == 20
|
|
39
|
+
|
|
40
|
+
def test_deterministic_for_a_seed(self):
|
|
41
|
+
a = firing_disk_polymer(max_len=8, rng=np.random.default_rng(7))
|
|
42
|
+
b = firing_disk_polymer(max_len=8, rng=np.random.default_rng(7))
|
|
43
|
+
assert a.molecules == b.molecules and a.catalysts == b.catalysts
|
|
44
|
+
|
|
45
|
+
def test_reactions_are_consistent_splits(self):
|
|
46
|
+
net = firing_disk_polymer(max_len=8, rng=np.random.default_rng(4))
|
|
47
|
+
for a, b, ab in net.reactions:
|
|
48
|
+
assert net.molecules[a] + net.molecules[b] == net.molecules[ab]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestPaperEnsemble:
|
|
52
|
+
"""Figure 3's ensemble: ~2000 species, ~40,000 reactions, ~100 catalysts."""
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("seed", range(3))
|
|
55
|
+
def test_reaches_the_published_scale(self, seed):
|
|
56
|
+
"""Across seeds, not just the lucky one -- these are ensemble claims."""
|
|
57
|
+
net = firing_disk_polymer(rng=np.random.default_rng(seed)) # paper defaults
|
|
58
|
+
assert 1500 <= net.n_molecules <= 2100
|
|
59
|
+
assert 20_000 <= net.n_reactions <= 45_000
|
|
60
|
+
n_cat = len({x for e in net.catalysts for g in e for x in g})
|
|
61
|
+
assert 40 <= n_cat <= 200, n_cat
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize("seed", range(3))
|
|
64
|
+
def test_raf_is_nearly_the_whole_chemistry(self, seed):
|
|
65
|
+
"""Their large C-chemistries host 'a RAF often as large as the entire chemistry'."""
|
|
66
|
+
net = firing_disk_polymer(rng=np.random.default_rng(seed))
|
|
67
|
+
assert len(max_raf(net).reactions) > 0.8 * net.n_reactions
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("seed", range(3))
|
|
70
|
+
def test_far_fewer_catalysts_than_a_k_chemistry_of_the_same_size(self, seed):
|
|
71
|
+
"""Figure 3b's contrast: a C-catalyst drives many more reactions than a K one."""
|
|
72
|
+
c = firing_disk_polymer(rng=np.random.default_rng(seed))
|
|
73
|
+
k = binary_polymer(max_len=8, food_len=2, p=0.003, cleavage=True,
|
|
74
|
+
rng=np.random.default_rng(seed))
|
|
75
|
+
per = lambda n: (sum(len(g) for e in n.catalysts for g in e)
|
|
76
|
+
/ max(len({x for e in n.catalysts for g in e for x in g}), 1))
|
|
77
|
+
assert per(c) > 5 * per(k)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestValidation:
|
|
81
|
+
def test_probabilities_checked(self):
|
|
82
|
+
with pytest.raises(ValueError, match="is a probability"):
|
|
83
|
+
firing_disk_polymer(p_cat=2.0)
|
|
84
|
+
|
|
85
|
+
def test_site_bounds_checked(self):
|
|
86
|
+
with pytest.raises(ValueError, match="site_min <= site_max"):
|
|
87
|
+
firing_disk_polymer(site_min=5, site_max=3)
|
|
@@ -237,3 +237,62 @@ class TestDownstreamUnderInhibition:
|
|
|
237
237
|
u = max_urafs(net, inh, reactions=raf)[0]
|
|
238
238
|
census = irrraf_census(net, u, n_samples=5, rng=np.random.default_rng(0))
|
|
239
239
|
assert census["n_distinct"] >= 1
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- generated inhibition: binary_polymer's q / n_inhibitors -----------------------
|
|
243
|
+
|
|
244
|
+
class TestGeneratedInhibition:
|
|
245
|
+
"""`binary_polymer(q=..., n_inhibitors=...)` -- Hordijk & Steel (2012) Part II."""
|
|
246
|
+
|
|
247
|
+
def test_q_zero_leaves_network_uninhibited(self):
|
|
248
|
+
net = binary_polymer(max_len=5, food_len=2, p=0.01,
|
|
249
|
+
rng=np.random.default_rng(0), cleavage=True)
|
|
250
|
+
assert all(not i for i in net.inhibitors)
|
|
251
|
+
assert net.n_inhibiting_molecules == 0
|
|
252
|
+
assert classes_from_inhibitors(net) == ()
|
|
253
|
+
|
|
254
|
+
def test_q_does_not_disturb_catalysis(self):
|
|
255
|
+
"""Adding inhibition must not change the chemistry it is added to.
|
|
256
|
+
|
|
257
|
+
Every result recorded before inhibition existed was measured at q=0; if the
|
|
258
|
+
catalysis draw shifted, those numbers would silently stop reproducing.
|
|
259
|
+
"""
|
|
260
|
+
kw = dict(max_len=5, food_len=2, p=0.01, cleavage=True)
|
|
261
|
+
a = binary_polymer(rng=np.random.default_rng(3), **kw)
|
|
262
|
+
b = binary_polymer(rng=np.random.default_rng(3), q=0.05, n_inhibitors=4, **kw)
|
|
263
|
+
assert a.catalysts == b.catalysts
|
|
264
|
+
assert a.reactions == b.reactions
|
|
265
|
+
|
|
266
|
+
def test_n_inhibitors_caps_k_exactly(self):
|
|
267
|
+
"""`k` is the exponent in max_urafs' 2**k, so the cap must be exact, not a mean."""
|
|
268
|
+
for cap in (1, 3, 8):
|
|
269
|
+
net = binary_polymer(max_len=5, food_len=2, p=0.01, q=0.3,
|
|
270
|
+
n_inhibitors=cap, rng=np.random.default_rng(1),
|
|
271
|
+
cleavage=True)
|
|
272
|
+
assert net.n_inhibiting_molecules <= cap
|
|
273
|
+
assert len(classes_from_inhibitors(net)) == net.n_inhibiting_molecules
|
|
274
|
+
|
|
275
|
+
def test_paired_directions_share_inhibitors(self):
|
|
276
|
+
"""A reversible cleavage-ligation pair is ONE unit, for inhibition as for catalysis."""
|
|
277
|
+
net = binary_polymer(max_len=5, food_len=2, p=0.01, q=0.1, n_inhibitors=5,
|
|
278
|
+
rng=np.random.default_rng(2), cleavage=True,
|
|
279
|
+
paired_catalysis=True)
|
|
280
|
+
half = net.n_reactions // 2
|
|
281
|
+
assert net.inhibitors[:half] == net.inhibitors[half:]
|
|
282
|
+
|
|
283
|
+
def test_inhibition_can_only_shrink_the_raf(self):
|
|
284
|
+
"""u-RAFs are RAFs, so no u-RAF may exceed the uninhibited maximal RAF."""
|
|
285
|
+
kw = dict(max_len=5, food_len=2, p=0.02, cleavage=True)
|
|
286
|
+
base = binary_polymer(rng=np.random.default_rng(5), **kw)
|
|
287
|
+
inh = binary_polymer(rng=np.random.default_rng(5), q=0.02, n_inhibitors=6, **kw)
|
|
288
|
+
ceiling = len(max_raf(base).reactions)
|
|
289
|
+
for u in max_urafs(inh):
|
|
290
|
+
assert len(u) <= ceiling
|
|
291
|
+
|
|
292
|
+
def test_q_rejects_non_probability(self):
|
|
293
|
+
with pytest.raises(ValueError, match="q is a probability"):
|
|
294
|
+
binary_polymer(max_len=4, q=1.5)
|
|
295
|
+
|
|
296
|
+
def test_negative_n_inhibitors_rejected(self):
|
|
297
|
+
with pytest.raises(ValueError, match="n_inhibitors must be non-negative"):
|
|
298
|
+
binary_polymer(max_len=4, q=0.1, n_inhibitors=-1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|