rafkit 0.1.0__tar.gz → 0.2.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/src/rafkit.egg-info → rafkit-0.2.0}/PKG-INFO +36 -1
- {rafkit-0.1.0 → rafkit-0.2.0}/README.md +35 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/pyproject.toml +1 -1
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit/__init__.py +11 -4
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit/binary_polymer.py +4 -0
- rafkit-0.2.0/src/rafkit/catalysis.py +78 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit/crs.py +35 -6
- rafkit-0.2.0/src/rafkit/gillespie.py +196 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit/network.py +4 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit/raf.py +42 -8
- {rafkit-0.1.0 → rafkit-0.2.0/src/rafkit.egg-info}/PKG-INFO +36 -1
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit.egg-info/SOURCES.txt +6 -1
- rafkit-0.2.0/tests/test_gillespie.py +112 -0
- rafkit-0.2.0/tests/test_published_examples.py +197 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/tests/test_raf.py +1 -0
- rafkit-0.2.0/tests/test_seeding.py +83 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/LICENSE +0 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/setup.cfg +0 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit.egg-info/dependency_links.txt +0 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit.egg-info/requires.txt +0 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/src/rafkit.egg-info/top_level.txt +0 -0
- {rafkit-0.1.0 → rafkit-0.2.0}/tests/test_crs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rafkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -33,6 +33,7 @@ Dynamic: license-file
|
|
|
33
33
|
[](https://pypi.org/project/rafkit/)
|
|
34
34
|
[](https://pypi.org/project/rafkit/)
|
|
35
35
|
[](LICENSE)
|
|
36
|
+
[](https://doi.org/10.5281/zenodo.21954795)
|
|
36
37
|
|
|
37
38
|
Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
|
|
38
39
|
cores, Kauffman binary polymer models, and interoperability with
|
|
@@ -109,13 +110,40 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
109
110
|
| `irrraf_census` | how many *distinct* irreducible cores a network carries |
|
|
110
111
|
| `exploitability` | share of RAF products contributing no catalysis back |
|
|
111
112
|
| `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
|
|
113
|
+
| `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
|
|
114
|
+
| `catalytically_reachable` | what can be made without any spontaneous reaction |
|
|
112
115
|
| `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
|
|
113
116
|
| `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
|
|
114
117
|
| `read_crs` / `write_crs` | CatReNet's CRS interchange format |
|
|
118
|
+
| `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
|
|
115
119
|
|
|
116
120
|
Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
|
|
117
121
|
is subtly wrong produces plausible numbers rather than errors.
|
|
118
122
|
|
|
123
|
+
## Catalysis is a relation, not a list
|
|
124
|
+
|
|
125
|
+
`catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
|
|
126
|
+
Steel (2024). Any one set being fully present suffices, and each set is a conjunctive
|
|
127
|
+
requirement:
|
|
128
|
+
|
|
129
|
+
| `catalysts[r]` | meaning |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `{{a}, {b}}` | *a* **or** *b* — the simple case, and what a flat list of catalysts meant |
|
|
132
|
+
| `{{a, d}, {e}}` | (*a* **and** *d*) **or** *e* |
|
|
133
|
+
| `{}` | **must** be catalysed, and nothing does: never in a RAF |
|
|
134
|
+
| `{frozenset()}` | **may proceed uncatalysed**; always satisfied |
|
|
135
|
+
|
|
136
|
+
The last two rows are a real distinction rather than a technicality — in the §2.4 system
|
|
137
|
+
of that paper it decides which reactions can join an RAF — and a flat list collapses
|
|
138
|
+
them. In CRS, a braced group is conjunctive: `[{a,d}, e]`, with `[]` and `[{}]` for the
|
|
139
|
+
last two rows.
|
|
140
|
+
|
|
141
|
+
Constructors still accept a plain iterable of molecules and normalise it, so simple
|
|
142
|
+
systems stay simple to write.
|
|
143
|
+
|
|
144
|
+
**Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
|
|
145
|
+
molecule can *prevent* a reaction.
|
|
146
|
+
|
|
119
147
|
## Notes on irreducible cores
|
|
120
148
|
|
|
121
149
|
There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
|
|
@@ -145,6 +173,13 @@ pytest -q
|
|
|
145
173
|
Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
|
|
146
174
|
[docs/RELEASING.md](docs/RELEASING.md).
|
|
147
175
|
|
|
176
|
+
## Citing
|
|
177
|
+
|
|
178
|
+
Cite the concept DOI [10.5281/zenodo.21954795](https://doi.org/10.5281/zenodo.21954795),
|
|
179
|
+
which always resolves to the latest version; `CITATION.cff` also lists the per-version
|
|
180
|
+
DOI. If you use the CatReNet interoperability or the validation fixture, please cite
|
|
181
|
+
CatReNet too.
|
|
182
|
+
|
|
148
183
|
## References
|
|
149
184
|
|
|
150
185
|
- Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
[](https://pypi.org/project/rafkit/)
|
|
6
6
|
[](https://pypi.org/project/rafkit/)
|
|
7
7
|
[](LICENSE)
|
|
8
|
+
[](https://doi.org/10.5281/zenodo.21954795)
|
|
8
9
|
|
|
9
10
|
Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
|
|
10
11
|
cores, Kauffman binary polymer models, and interoperability with
|
|
@@ -81,13 +82,40 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
81
82
|
| `irrraf_census` | how many *distinct* irreducible cores a network carries |
|
|
82
83
|
| `exploitability` | share of RAF products contributing no catalysis back |
|
|
83
84
|
| `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
|
|
85
|
+
| `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
|
|
86
|
+
| `catalytically_reachable` | what can be made without any spontaneous reaction |
|
|
84
87
|
| `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
|
|
85
88
|
| `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
|
|
86
89
|
| `read_crs` / `write_crs` | CatReNet's CRS interchange format |
|
|
90
|
+
| `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
|
|
87
91
|
|
|
88
92
|
Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
|
|
89
93
|
is subtly wrong produces plausible numbers rather than errors.
|
|
90
94
|
|
|
95
|
+
## Catalysis is a relation, not a list
|
|
96
|
+
|
|
97
|
+
`catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
|
|
98
|
+
Steel (2024). Any one set being fully present suffices, and each set is a conjunctive
|
|
99
|
+
requirement:
|
|
100
|
+
|
|
101
|
+
| `catalysts[r]` | meaning |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `{{a}, {b}}` | *a* **or** *b* — the simple case, and what a flat list of catalysts meant |
|
|
104
|
+
| `{{a, d}, {e}}` | (*a* **and** *d*) **or** *e* |
|
|
105
|
+
| `{}` | **must** be catalysed, and nothing does: never in a RAF |
|
|
106
|
+
| `{frozenset()}` | **may proceed uncatalysed**; always satisfied |
|
|
107
|
+
|
|
108
|
+
The last two rows are a real distinction rather than a technicality — in the §2.4 system
|
|
109
|
+
of that paper it decides which reactions can join an RAF — and a flat list collapses
|
|
110
|
+
them. In CRS, a braced group is conjunctive: `[{a,d}, e]`, with `[]` and `[{}]` for the
|
|
111
|
+
last two rows.
|
|
112
|
+
|
|
113
|
+
Constructors still accept a plain iterable of molecules and normalise it, so simple
|
|
114
|
+
systems stay simple to write.
|
|
115
|
+
|
|
116
|
+
**Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
|
|
117
|
+
molecule can *prevent* a reaction.
|
|
118
|
+
|
|
91
119
|
## Notes on irreducible cores
|
|
92
120
|
|
|
93
121
|
There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
|
|
@@ -117,6 +145,13 @@ pytest -q
|
|
|
117
145
|
Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
|
|
118
146
|
[docs/RELEASING.md](docs/RELEASING.md).
|
|
119
147
|
|
|
148
|
+
## Citing
|
|
149
|
+
|
|
150
|
+
Cite the concept DOI [10.5281/zenodo.21954795](https://doi.org/10.5281/zenodo.21954795),
|
|
151
|
+
which always resolves to the latest version; `CITATION.cff` also lists the per-version
|
|
152
|
+
DOI. If you use the CatReNet interoperability or the validation fixture, please cite
|
|
153
|
+
CatReNet too.
|
|
154
|
+
|
|
120
155
|
## References
|
|
121
156
|
|
|
122
157
|
- Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A small, dependency-light implementation of RAF theory (Hordijk & Steel 2004):
|
|
4
4
|
maximal RAFs, the self-referential ("strictly autocatalytic") variant, irreducible
|
|
5
|
-
RAF sampling,
|
|
5
|
+
RAF sampling, Kauffman's binary polymer model as a generator, and stochastic simulation
|
|
6
|
+
of a network so that subRAFs can be watched seeding themselves into existence.
|
|
6
7
|
|
|
7
8
|
Every algorithm here is written from the published papers and carries hand-computed
|
|
8
9
|
known-answer tests, because a RAF algorithm that is subtly wrong produces plausible
|
|
@@ -12,18 +13,24 @@ See the README for the calibration against Steel, Hordijk & Smith (2012) and for
|
|
|
12
13
|
CatReNet interoperability.
|
|
13
14
|
"""
|
|
14
15
|
from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
|
|
16
|
+
from rafkit.catalysis import catalysing_molecules, is_catalysed, normalise
|
|
15
17
|
from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
|
|
18
|
+
from rafkit.gillespie import Trajectory, propensities, simulate
|
|
16
19
|
from rafkit.network import ReactionNetwork
|
|
17
20
|
from rafkit.raf import (
|
|
18
|
-
RafResult, catrenet_strictly_autocatalytic,
|
|
19
|
-
is_food_catalysed, max_raf, max_raf_strict,
|
|
21
|
+
RafResult, catrenet_strictly_autocatalytic, core_raf, exploitability,
|
|
22
|
+
has_unique_irraf, irrraf_census, is_food_catalysed, max_raf, max_raf_strict,
|
|
23
|
+
sample_irrraf,
|
|
20
24
|
)
|
|
21
25
|
|
|
22
|
-
__version__ = "0.
|
|
26
|
+
__version__ = "0.2.0"
|
|
23
27
|
|
|
24
28
|
__all__ = [
|
|
25
29
|
"BinaryPolymerNetwork", "binary_polymer", "ReactionNetwork",
|
|
26
30
|
"RafResult", "max_raf", "max_raf_strict", "sample_irrraf", "irrraf_census",
|
|
27
31
|
"exploitability", "is_food_catalysed", "catrenet_strictly_autocatalytic",
|
|
32
|
+
"core_raf", "has_unique_irraf",
|
|
33
|
+
"is_catalysed", "catalysing_molecules", "normalise",
|
|
28
34
|
"parse_crs", "read_crs", "to_crs", "write_crs",
|
|
35
|
+
"simulate", "propensities", "Trajectory",
|
|
29
36
|
]
|
|
@@ -44,6 +44,8 @@ from typing import Iterator
|
|
|
44
44
|
|
|
45
45
|
import numpy as np
|
|
46
46
|
|
|
47
|
+
from rafkit.catalysis import normalise
|
|
48
|
+
|
|
47
49
|
|
|
48
50
|
@dataclass(frozen=True)
|
|
49
51
|
class BinaryPolymerNetwork:
|
|
@@ -66,6 +68,8 @@ class BinaryPolymerNetwork:
|
|
|
66
68
|
directions: tuple[int, ...] = ()
|
|
67
69
|
|
|
68
70
|
def __post_init__(self):
|
|
71
|
+
object.__setattr__(self, "catalysts",
|
|
72
|
+
tuple(normalise(c) for c in self.catalysts))
|
|
69
73
|
if not self.directions: # default: all ligations
|
|
70
74
|
object.__setattr__(self, "directions", (1,) * len(self.reactions))
|
|
71
75
|
elif len(self.directions) != len(self.reactions):
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The catalysis relation χ, and the one predicate everything else is built on.
|
|
2
|
+
|
|
3
|
+
Huson, Xavier & Steel (2024) treat catalysis as a relation between **sets** of molecules
|
|
4
|
+
and reactions: reaction ``r`` proceeds when *some* catalyst set ``U`` in ``chi(r)`` is
|
|
5
|
+
entirely available. That single structure expresses three things a flat set of catalysts
|
|
6
|
+
cannot:
|
|
7
|
+
|
|
8
|
+
=========================== ============================================================
|
|
9
|
+
``chi(r)`` meaning
|
|
10
|
+
=========================== ============================================================
|
|
11
|
+
``{{a}, {b}}`` *a* **or** *b* -- the simple, disjunctive case
|
|
12
|
+
``{{a, d}, {e}}`` (*a* **and** *d*) **or** *e* -- conjunctive requirements
|
|
13
|
+
``{}`` (no sets at all) **must** be catalysed, and nothing catalyses it: never in a RAF
|
|
14
|
+
``{frozenset()}`` **may proceed uncatalysed**; always satisfied
|
|
15
|
+
=========================== ============================================================
|
|
16
|
+
|
|
17
|
+
The last two are a genuine distinction rather than a technicality -- in their §2.4 system
|
|
18
|
+
it is what separates a reaction that can join an RAF from one that cannot -- and writing
|
|
19
|
+
catalysts as a flat set collapses them.
|
|
20
|
+
|
|
21
|
+
`is_catalysed` handles all four rows without special-casing, because ``any()`` over no
|
|
22
|
+
sets is False and the empty set is a subset of everything.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Iterable
|
|
27
|
+
|
|
28
|
+
CatalystSets = frozenset[frozenset[int]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalise(spec) -> CatalystSets:
|
|
32
|
+
"""Accept either the simple or the general form and return the general one.
|
|
33
|
+
|
|
34
|
+
A flat iterable of molecule indices -- the form used everywhere before conjunctive
|
|
35
|
+
catalysis existed, and still the right one to write by hand for simple systems --
|
|
36
|
+
becomes one singleton set per molecule, which is exactly equivalent.
|
|
37
|
+
"""
|
|
38
|
+
if spec is None:
|
|
39
|
+
return frozenset()
|
|
40
|
+
out = []
|
|
41
|
+
for item in spec:
|
|
42
|
+
if isinstance(item, (frozenset, set, tuple, list)):
|
|
43
|
+
out.append(frozenset(int(x) for x in item))
|
|
44
|
+
else:
|
|
45
|
+
out.append(frozenset({int(item)}))
|
|
46
|
+
return frozenset(out)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_catalysed(chi: CatalystSets, available: Iterable[int]) -> bool:
|
|
50
|
+
"""Whether some catalyst set of a reaction is fully present.
|
|
51
|
+
|
|
52
|
+
Note the two edge cases fall out rather than being handled: with no catalyst sets
|
|
53
|
+
`any()` is False, so the reaction can never run; with the empty set present,
|
|
54
|
+
``frozenset() <= available`` is True, so it always can.
|
|
55
|
+
"""
|
|
56
|
+
avail = available if isinstance(available, (set, frozenset)) else frozenset(available)
|
|
57
|
+
return any(U <= avail for U in chi)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def catalysing_molecules(chi: CatalystSets) -> frozenset[int]:
|
|
61
|
+
"""Every molecule that appears in any catalyst set.
|
|
62
|
+
|
|
63
|
+
The right notion of "x catalyses r" when catalysis is conjunctive: x may be
|
|
64
|
+
necessary without being sufficient, and it still counts as catalysing.
|
|
65
|
+
"""
|
|
66
|
+
return frozenset().union(*chi) if chi else frozenset()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def requires_non_food(chi: CatalystSets, available: Iterable[int], food) -> bool:
|
|
70
|
+
"""Whether some *satisfiable* catalyst set is not contained in the food set.
|
|
71
|
+
|
|
72
|
+
This is the strictly-autocatalytic condition of Huson, Xavier & Steel (2024) §3.1
|
|
73
|
+
stated exactly: ``U subset-of cl(F)`` and ``U not-subset-of F``. Under simple
|
|
74
|
+
catalysis it reduces to "has a catalyst that is a non-food product", which is what
|
|
75
|
+
`max_raf_strict` meant before this module existed.
|
|
76
|
+
"""
|
|
77
|
+
avail = available if isinstance(available, (set, frozenset)) else frozenset(available)
|
|
78
|
+
return any(U <= avail and not U <= food for U in chi)
|
|
@@ -14,7 +14,13 @@ The format is a food line and one line per reaction::
|
|
|
14
14
|
r2 : ab [aa] => abab
|
|
15
15
|
|
|
16
16
|
`[...]` lists catalysts, `<->` marks a reversible reaction and `=>` (or `->`) an
|
|
17
|
-
irreversible one.
|
|
17
|
+
irreversible one.
|
|
18
|
+
|
|
19
|
+
Catalysts are alternatives, any one of which suffices. A braced group is a
|
|
20
|
+
**conjunctive** requirement -- `[{a,d}, e]` means *a and d together*, or *e* -- which
|
|
21
|
+
is the notation Huson, Xavier & Steel (2024) use. Two edge cases carry meaning and are
|
|
22
|
+
not interchangeable: `[]` means the reaction **must** be catalysed and nothing does so,
|
|
23
|
+
while `[{}]` means it **may proceed uncatalysed**. A reversible reaction is read as **two** reactions, forward and
|
|
18
24
|
reverse, sharing a catalyst set -- which is the reading its own generator uses.
|
|
19
25
|
|
|
20
26
|
`X + X -> Y` is written `X ... -> Y`, with the repeated reactant collapsed. Since
|
|
@@ -39,6 +45,20 @@ def _split_list(text: str) -> list[str]:
|
|
|
39
45
|
return [s for s in (t.strip() for t in re.split(r"[,+]", text)) if s]
|
|
40
46
|
|
|
41
47
|
|
|
48
|
+
def _parse_catalysts(text: str) -> list[list[str]]:
|
|
49
|
+
"""Parse a catalyst list into alternative sets, honouring braced conjunctions.
|
|
50
|
+
|
|
51
|
+
Splitting on commas alone is wrong the moment a braced group appears -- `{a,d}`
|
|
52
|
+
would become `{a` and `d}` -- so groups are pulled out first.
|
|
53
|
+
"""
|
|
54
|
+
groups, rest = [], text
|
|
55
|
+
for m in re.finditer(r"\{([^}]*)\}", text):
|
|
56
|
+
groups.append(_split_list(m.group(1))) # may be empty: {} means "uncatalysed"
|
|
57
|
+
rest = re.sub(r"\{[^}]*\}", " ", text)
|
|
58
|
+
groups += [[name] for name in _split_list(rest)]
|
|
59
|
+
return groups
|
|
60
|
+
|
|
61
|
+
|
|
42
62
|
def parse_crs(text: str) -> ReactionNetwork:
|
|
43
63
|
"""Parse CRS text into a `ReactionNetwork`."""
|
|
44
64
|
food_names: list[str] = []
|
|
@@ -56,11 +76,11 @@ def parse_crs(text: str) -> ReactionNetwork:
|
|
|
56
76
|
continue
|
|
57
77
|
name, body = m.group("name"), m.group("body")
|
|
58
78
|
|
|
59
|
-
cats: list[str] = []
|
|
79
|
+
cats: list[list[str]] = []
|
|
60
80
|
if "[" in body:
|
|
61
81
|
pre, rest = body.split("[", 1)
|
|
62
82
|
inside, post = rest.split("]", 1)
|
|
63
|
-
cats =
|
|
83
|
+
cats = _parse_catalysts(inside)
|
|
64
84
|
body = pre + " " + post
|
|
65
85
|
|
|
66
86
|
arrow = _ARROW.search(body)
|
|
@@ -75,12 +95,12 @@ def parse_crs(text: str) -> ReactionNetwork:
|
|
|
75
95
|
for n in food_names:
|
|
76
96
|
index.setdefault(n, len(index))
|
|
77
97
|
for _, lhs, rhs, cats, _ in parsed:
|
|
78
|
-
for n in (*lhs, *rhs, *cats):
|
|
98
|
+
for n in (*lhs, *rhs, *(x for g in cats for x in g)):
|
|
79
99
|
index.setdefault(n, len(index))
|
|
80
100
|
|
|
81
101
|
pairs, catalysts, names = [], [], []
|
|
82
102
|
for name, lhs, rhs, cats, reversible in parsed:
|
|
83
|
-
cat = frozenset(index[c] for c in cats)
|
|
103
|
+
cat = frozenset(frozenset(index[c] for c in g) for g in cats)
|
|
84
104
|
fwd = (tuple(index[x] for x in lhs), tuple(index[x] for x in rhs))
|
|
85
105
|
pairs.append(fwd); catalysts.append(cat); names.append(name)
|
|
86
106
|
if reversible:
|
|
@@ -119,12 +139,21 @@ def to_crs(net, comment: str = "") -> str:
|
|
|
119
139
|
for r in range(net.n_reactions):
|
|
120
140
|
lhs = " + ".join(dict.fromkeys(name(x) for x in net.reactants(r)))
|
|
121
141
|
rhs = " + ".join(dict.fromkeys(name(x) for x in net.products(r)))
|
|
122
|
-
cats =
|
|
142
|
+
cats = _format_catalysts(net, r)
|
|
123
143
|
out.append(f"{names[r]} : {lhs} [{cats}] => {rhs}")
|
|
124
144
|
out.append("")
|
|
125
145
|
return "\n".join(out)
|
|
126
146
|
|
|
127
147
|
|
|
148
|
+
def _format_catalysts(net, r: int) -> str:
|
|
149
|
+
"""Render a reaction's catalyst sets, using braces only where they are needed."""
|
|
150
|
+
parts = []
|
|
151
|
+
for U in sorted(net.catalysts[r], key=lambda u: sorted(u)):
|
|
152
|
+
names = sorted(net.molecules[c] for c in U)
|
|
153
|
+
parts.append(names[0] if len(names) == 1 else "{" + ",".join(names) + "}")
|
|
154
|
+
return ",".join(parts)
|
|
155
|
+
|
|
156
|
+
|
|
128
157
|
def write_crs(net, path: str | Path, comment: str = "") -> None:
|
|
129
158
|
"""Write a network to a CRS file."""
|
|
130
159
|
Path(path).write_text(to_crs(net, comment))
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Stochastic simulation of a catalytic reaction network (Gillespie direct method).
|
|
2
|
+
|
|
3
|
+
The point of this module is the one thing the rest of this library cannot express:
|
|
4
|
+
**a subRAF has to be seeded before it can run.** A reaction catalysed by its own
|
|
5
|
+
product cannot start until that product appears by an *uncatalysed* event, so a maximal
|
|
6
|
+
RAF does not switch on -- it assembles as an order-dependent sequence of rare events.
|
|
7
|
+
That is Hordijk & Steel, "Autocatalytic sets extended: dynamics, inhibition, and a
|
|
8
|
+
generalization" (*J. Syst. Chem.* 3, 5, 2012), and `examples/` reproduces it.
|
|
9
|
+
|
|
10
|
+
Everything here is deliberately small. Rates are mass-action with unit kinetic constants,
|
|
11
|
+
because the published reference assigns no others and inventing them would make the
|
|
12
|
+
reproduction unfalsifiable. The one non-unit constant is the **uncatalysed rate
|
|
13
|
+
reduction factor**, which is the mechanism under test.
|
|
14
|
+
|
|
15
|
+
Conventions, all inherited from the reference rather than chosen here:
|
|
16
|
+
|
|
17
|
+
* a reaction whose catalyst is absent still proceeds, at ``1 / uncatalysed_factor``;
|
|
18
|
+
* food molecules are replenished when they fall below ``food_floor``;
|
|
19
|
+
* a ligation ``a + b -> ab`` with ``a == b`` takes the pair count ``n(n-1)/2``, not ``n^2``.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from rafkit.catalysis import is_catalysed
|
|
28
|
+
|
|
29
|
+
UNCATALYSED_FACTOR = 20.0 # Hordijk & Steel (2012): "a small reduction factor of 20"
|
|
30
|
+
FOOD_FLOOR = 5 # "replenished when they fall below a concentration of five"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Trajectory:
|
|
35
|
+
"""Recorded history of one run."""
|
|
36
|
+
|
|
37
|
+
times: np.ndarray # (n_samples,)
|
|
38
|
+
counts: np.ndarray # (n_samples, n_molecules)
|
|
39
|
+
molecules: tuple[str, ...]
|
|
40
|
+
first_fired: dict[int, float] = field(default_factory=dict)
|
|
41
|
+
"""Reaction index -> time it first fired, in either direction."""
|
|
42
|
+
first_uncatalysed: dict[int, float] = field(default_factory=dict)
|
|
43
|
+
"""Reaction index -> time it first fired *without* its catalyst present.
|
|
44
|
+
|
|
45
|
+
This is the seeding record, and the reason the module exists: a subRAF that is
|
|
46
|
+
catalysed only by its own products appears here before it appears anywhere else.
|
|
47
|
+
"""
|
|
48
|
+
first_appearance: dict[int, float] = field(default_factory=dict)
|
|
49
|
+
"""Molecule index -> time it first existed, at EVENT resolution.
|
|
50
|
+
|
|
51
|
+
Deliberately not read off `counts`, which is sampled every `sample_every` events
|
|
52
|
+
and therefore aliases any species that is produced and consumed between samples.
|
|
53
|
+
An early version of this module inferred appearance order from the samples and
|
|
54
|
+
reported a molecule appearing before its own reactant.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def of(self, name: str) -> np.ndarray:
|
|
58
|
+
"""Count trace for a molecule, by name."""
|
|
59
|
+
return self.counts[:, self.molecules.index(name)]
|
|
60
|
+
|
|
61
|
+
def first_seen(self, name: str) -> float | None:
|
|
62
|
+
"""When a molecule first existed, at event resolution; None if it never did."""
|
|
63
|
+
return self.first_appearance.get(self.molecules.index(name))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _pair_count(counts: np.ndarray, reactants: tuple[int, ...]) -> float:
|
|
67
|
+
"""Number of distinct reactant combinations available.
|
|
68
|
+
|
|
69
|
+
Distinct species multiply; a species reacting with itself takes n(n-1)/2, which is
|
|
70
|
+
the standard mass-action combinatorial factor and not merely n^2 -- a molecule
|
|
71
|
+
cannot react with itself.
|
|
72
|
+
"""
|
|
73
|
+
if len(reactants) == 1:
|
|
74
|
+
return float(counts[reactants[0]])
|
|
75
|
+
a, b = reactants
|
|
76
|
+
if a == b:
|
|
77
|
+
n = counts[a]
|
|
78
|
+
return float(n * (n - 1) / 2)
|
|
79
|
+
return float(counts[a] * counts[b])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def propensities(net, counts: np.ndarray, *,
|
|
83
|
+
uncatalysed_factor: float = UNCATALYSED_FACTOR,
|
|
84
|
+
reactions=None) -> np.ndarray:
|
|
85
|
+
"""Propensity of every reaction under the current counts.
|
|
86
|
+
|
|
87
|
+
A reaction with at least one catalyst present runs at the full rate; one with none
|
|
88
|
+
runs at ``1 / uncatalysed_factor`` of it. That difference is the whole mechanism:
|
|
89
|
+
it makes seeding rare but not impossible.
|
|
90
|
+
|
|
91
|
+
`reactions` restricts which reactions may fire. **This is a fidelity requirement,
|
|
92
|
+
not a convenience.** Hordijk & Steel study "the molecular flow on this maximal RAF";
|
|
93
|
+
simulating the entire generated network instead lets any reaction fire uncatalysed,
|
|
94
|
+
so species arrive by routes outside the set under study and the seeding sequence the
|
|
95
|
+
experiment exists to observe is destroyed. An earlier version of this module did
|
|
96
|
+
exactly that, and produced a molecule before the only reaction that makes it.
|
|
97
|
+
"""
|
|
98
|
+
out = np.zeros(net.n_reactions)
|
|
99
|
+
allowed = range(net.n_reactions) if reactions is None else reactions
|
|
100
|
+
present = frozenset(np.flatnonzero(counts).tolist())
|
|
101
|
+
for r in allowed:
|
|
102
|
+
combos = _pair_count(counts, net.reactants(r))
|
|
103
|
+
if combos <= 0:
|
|
104
|
+
continue
|
|
105
|
+
catalysed = is_catalysed(net.catalysts[r], present)
|
|
106
|
+
out[r] = combos if catalysed else combos / uncatalysed_factor
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def simulate(net, *, n_events: int = 25_000, rng=None,
|
|
111
|
+
uncatalysed_factor: float = UNCATALYSED_FACTOR,
|
|
112
|
+
food_floor: int = FOOD_FLOOR, initial_food: int | None = None,
|
|
113
|
+
sample_every: int = 25, reactions=None) -> Trajectory:
|
|
114
|
+
"""Run the direct method for `n_events` reaction events.
|
|
115
|
+
|
|
116
|
+
Starts from food only, which is the point: everything else has to be made, and the
|
|
117
|
+
parts of the network that catalyse their own production have to be seeded by an
|
|
118
|
+
uncatalysed event first.
|
|
119
|
+
|
|
120
|
+
`reactions` restricts the reaction set -- pass a maximal RAF to reproduce the
|
|
121
|
+
published experiment; see `propensities` for why the default of "everything" is
|
|
122
|
+
the wrong choice for that purpose.
|
|
123
|
+
"""
|
|
124
|
+
rng = rng if rng is not None else np.random.default_rng()
|
|
125
|
+
counts = np.zeros(net.n_molecules, dtype=np.int64)
|
|
126
|
+
food = np.array(sorted(net.food), dtype=int)
|
|
127
|
+
counts[food] = initial_food if initial_food is not None else food_floor
|
|
128
|
+
|
|
129
|
+
times, samples = [0.0], [counts.copy()]
|
|
130
|
+
first_fired: dict[int, float] = {}
|
|
131
|
+
first_uncat: dict[int, float] = {}
|
|
132
|
+
first_seen: dict[int, float] = {int(f): 0.0 for f in food}
|
|
133
|
+
t = 0.0
|
|
134
|
+
|
|
135
|
+
for step in range(n_events):
|
|
136
|
+
a = propensities(net, counts, uncatalysed_factor=uncatalysed_factor,
|
|
137
|
+
reactions=reactions)
|
|
138
|
+
a0 = a.sum()
|
|
139
|
+
if a0 <= 0:
|
|
140
|
+
break # nothing can fire; state is dead
|
|
141
|
+
t += float(rng.exponential(1.0 / a0))
|
|
142
|
+
r = int(rng.choice(net.n_reactions, p=a / a0))
|
|
143
|
+
|
|
144
|
+
if r not in first_fired:
|
|
145
|
+
first_fired[r] = t
|
|
146
|
+
if r not in first_uncat and not is_catalysed(
|
|
147
|
+
net.catalysts[r], frozenset(np.flatnonzero(counts).tolist())):
|
|
148
|
+
first_uncat[r] = t
|
|
149
|
+
|
|
150
|
+
for x in net.reactants(r):
|
|
151
|
+
counts[x] -= 1
|
|
152
|
+
for x in net.products(r):
|
|
153
|
+
counts[x] += 1
|
|
154
|
+
first_seen.setdefault(int(x), t)
|
|
155
|
+
counts[food] = np.maximum(counts[food], food_floor) # replenish
|
|
156
|
+
|
|
157
|
+
if (step + 1) % sample_every == 0:
|
|
158
|
+
times.append(t)
|
|
159
|
+
samples.append(counts.copy())
|
|
160
|
+
|
|
161
|
+
return Trajectory(times=np.array(times), counts=np.array(samples),
|
|
162
|
+
molecules=net.molecules, first_fired=first_fired,
|
|
163
|
+
first_uncatalysed=first_uncat, first_appearance=first_seen)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def catalytically_reachable(net, reactions=None) -> frozenset[int]:
|
|
167
|
+
"""Molecules obtainable using **only catalysed firings** -- no seeding required.
|
|
168
|
+
|
|
169
|
+
Everything outside this set needs at least one uncatalysed (spontaneous) reaction
|
|
170
|
+
before it can exist, which is what makes the assembly of a maximal RAF an
|
|
171
|
+
order-dependent sequence of rare events rather than a switch.
|
|
172
|
+
|
|
173
|
+
It is a **least fixpoint**, and the obvious cheaper definition is wrong: taking only
|
|
174
|
+
the reactions with a *food* catalyst under-counts, because a reaction whose catalyst
|
|
175
|
+
is itself produced by the always-on part becomes catalysed later without ever needing
|
|
176
|
+
a seed. Iterating to a fixpoint is what closes that gap -- checked against simulation
|
|
177
|
+
on twelve networks, where the cheaper version fails on three of them and this does
|
|
178
|
+
not.
|
|
179
|
+
|
|
180
|
+
Static counterpart of `Trajectory.first_uncatalysed`: this predicts *which* molecules
|
|
181
|
+
require a seeding event, the trajectory records *when* one happened.
|
|
182
|
+
"""
|
|
183
|
+
from rafkit.raf import _closure
|
|
184
|
+
|
|
185
|
+
allowed = frozenset(range(net.n_reactions) if reactions is None else reactions)
|
|
186
|
+
avail = frozenset(net.food)
|
|
187
|
+
while True:
|
|
188
|
+
enabled = frozenset(
|
|
189
|
+
r for r in allowed
|
|
190
|
+
if all(x in avail for x in net.reactants(r))
|
|
191
|
+
and is_catalysed(net.catalysts[r], avail)
|
|
192
|
+
)
|
|
193
|
+
nxt = _closure(net, enabled)
|
|
194
|
+
if nxt == avail:
|
|
195
|
+
return avail
|
|
196
|
+
avail = nxt
|
|
@@ -24,6 +24,8 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
from dataclasses import dataclass
|
|
26
26
|
|
|
27
|
+
from rafkit.catalysis import normalise
|
|
28
|
+
|
|
27
29
|
|
|
28
30
|
@dataclass(frozen=True)
|
|
29
31
|
class ReactionNetwork:
|
|
@@ -43,6 +45,8 @@ class ReactionNetwork:
|
|
|
43
45
|
names: tuple[str, ...] = ()
|
|
44
46
|
|
|
45
47
|
def __post_init__(self):
|
|
48
|
+
object.__setattr__(self, "catalysts",
|
|
49
|
+
tuple(normalise(c) for c in self.catalysts))
|
|
46
50
|
if len(self.catalysts) != len(self.reaction_pairs):
|
|
47
51
|
raise ValueError(
|
|
48
52
|
f"{len(self.catalysts)} catalyst sets for "
|
|
@@ -28,6 +28,8 @@ from __future__ import annotations
|
|
|
28
28
|
from dataclasses import dataclass
|
|
29
29
|
|
|
30
30
|
from rafkit.binary_polymer import BinaryPolymerNetwork
|
|
31
|
+
from rafkit.catalysis import (catalysing_molecules, is_catalysed,
|
|
32
|
+
requires_non_food)
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
@dataclass(frozen=True)
|
|
@@ -80,7 +82,8 @@ def max_raf(net: BinaryPolymerNetwork) -> RafResult:
|
|
|
80
82
|
have = _closure(net, current)
|
|
81
83
|
keep = frozenset(
|
|
82
84
|
r for r in current
|
|
83
|
-
if all(x in have for x in net.reactants(r))
|
|
85
|
+
if all(x in have for x in net.reactants(r))
|
|
86
|
+
and is_catalysed(net.catalysts[r], have)
|
|
84
87
|
)
|
|
85
88
|
if keep == current:
|
|
86
89
|
return RafResult(reactions=current, closure=have, n_rounds=rounds)
|
|
@@ -109,7 +112,7 @@ def exploitability(net: BinaryPolymerNetwork, raf: RafResult) -> dict:
|
|
|
109
112
|
reactants_in_raf = set()
|
|
110
113
|
for r in raf.reactions:
|
|
111
114
|
reactants_in_raf.update(net.reactants(r))
|
|
112
|
-
catalyses_in_raf |= (net.catalysts[r] & products)
|
|
115
|
+
catalyses_in_raf |= (catalysing_molecules(net.catalysts[r]) & products)
|
|
113
116
|
|
|
114
117
|
strict = products - catalyses_in_raf
|
|
115
118
|
unused = strict - reactants_in_raf
|
|
@@ -120,7 +123,8 @@ def exploitability(net: BinaryPolymerNetwork, raf: RafResult) -> dict:
|
|
|
120
123
|
for m in strict:
|
|
121
124
|
survives = frozenset(
|
|
122
125
|
r for r in raf.reactions
|
|
123
|
-
if m not in net.reactions[r]
|
|
126
|
+
if m not in net.reactions[r]
|
|
127
|
+
and is_catalysed(net.catalysts[r], raf.closure - {m})
|
|
124
128
|
)
|
|
125
129
|
sub = RafResult(reactions=survives, closure=_closure(net, survives),
|
|
126
130
|
n_rounds=0)
|
|
@@ -151,10 +155,11 @@ def _refine(net: BinaryPolymerNetwork, reactions: frozenset[int],
|
|
|
151
155
|
current = reactions
|
|
152
156
|
while True:
|
|
153
157
|
have = _closure(net, current)
|
|
154
|
-
pool = have - net.food if strict else have
|
|
155
158
|
keep = frozenset(
|
|
156
159
|
r for r in current
|
|
157
|
-
if all(x in have for x in net.reactants(r))
|
|
160
|
+
if all(x in have for x in net.reactants(r))
|
|
161
|
+
and (requires_non_food(net.catalysts[r], have, net.food) if strict
|
|
162
|
+
else is_catalysed(net.catalysts[r], have))
|
|
158
163
|
)
|
|
159
164
|
if keep == current:
|
|
160
165
|
return current
|
|
@@ -271,7 +276,7 @@ def is_food_catalysed(net: BinaryPolymerNetwork, core: frozenset[int]) -> bool:
|
|
|
271
276
|
"""
|
|
272
277
|
if not core:
|
|
273
278
|
return False
|
|
274
|
-
return all(net.catalysts[r]
|
|
279
|
+
return all(is_catalysed(net.catalysts[r], net.food) for r in core)
|
|
275
280
|
|
|
276
281
|
|
|
277
282
|
def catrenet_strictly_autocatalytic(net, raf: RafResult | None = None) -> frozenset[int]:
|
|
@@ -296,5 +301,34 @@ def catrenet_strictly_autocatalytic(net, raf: RafResult | None = None) -> frozen
|
|
|
296
301
|
raf = max_raf(net)
|
|
297
302
|
if raf.is_empty:
|
|
298
303
|
return frozenset()
|
|
299
|
-
|
|
300
|
-
|
|
304
|
+
return frozenset(r for r in raf.reactions
|
|
305
|
+
if requires_non_food(net.catalysts[r], raf.closure, net.food))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def core_raf(net, reactions=None) -> frozenset[int]:
|
|
309
|
+
"""`Core(Q)`: the reactions whose removal collapses the whole set.
|
|
310
|
+
|
|
311
|
+
Defined by Huson, Xavier & Steel (2024) as
|
|
312
|
+
``Core(Q) = {r in R : phi(R \\ {r}) = empty}``. Their result: **this set is an RAF
|
|
313
|
+
if and only if the system has a unique irreducible RAF**, and when it is, it *is*
|
|
314
|
+
that iRAF. `has_unique_irraf` is the usable form of that test.
|
|
315
|
+
"""
|
|
316
|
+
allowed = frozenset(range(net.n_reactions) if reactions is None else reactions)
|
|
317
|
+
return frozenset(r for r in allowed if not _refine(net, allowed - {r}))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def has_unique_irraf(net, reactions=None) -> bool:
|
|
321
|
+
"""Whether the system has exactly one irreducible RAF, in polynomial time.
|
|
322
|
+
|
|
323
|
+
Deciding how *many* iRAFs there are is hard in general -- there may be
|
|
324
|
+
exponentially many, and finding the smallest is NP-hard (Steel, Hordijk & Smith
|
|
325
|
+
2012) -- but the *unique* case is cheap, which is the point of `core_raf`.
|
|
326
|
+
|
|
327
|
+
Returns False when there is no RAF at all: no iRAF is not one iRAF.
|
|
328
|
+
"""
|
|
329
|
+
allowed = frozenset(range(net.n_reactions) if reactions is None else reactions)
|
|
330
|
+
maximal = _refine(net, allowed)
|
|
331
|
+
if not maximal:
|
|
332
|
+
return False
|
|
333
|
+
core = core_raf(net, allowed)
|
|
334
|
+
return bool(core) and _refine(net, core) == core
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rafkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -33,6 +33,7 @@ Dynamic: license-file
|
|
|
33
33
|
[](https://pypi.org/project/rafkit/)
|
|
34
34
|
[](https://pypi.org/project/rafkit/)
|
|
35
35
|
[](LICENSE)
|
|
36
|
+
[](https://doi.org/10.5281/zenodo.21954795)
|
|
36
37
|
|
|
37
38
|
Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
|
|
38
39
|
cores, Kauffman binary polymer models, and interoperability with
|
|
@@ -109,13 +110,40 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
|
|
|
109
110
|
| `irrraf_census` | how many *distinct* irreducible cores a network carries |
|
|
110
111
|
| `exploitability` | share of RAF products contributing no catalysis back |
|
|
111
112
|
| `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
|
|
113
|
+
| `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
|
|
114
|
+
| `catalytically_reachable` | what can be made without any spontaneous reaction |
|
|
112
115
|
| `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
|
|
113
116
|
| `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
|
|
114
117
|
| `read_crs` / `write_crs` | CatReNet's CRS interchange format |
|
|
118
|
+
| `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
|
|
115
119
|
|
|
116
120
|
Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
|
|
117
121
|
is subtly wrong produces plausible numbers rather than errors.
|
|
118
122
|
|
|
123
|
+
## Catalysis is a relation, not a list
|
|
124
|
+
|
|
125
|
+
`catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
|
|
126
|
+
Steel (2024). Any one set being fully present suffices, and each set is a conjunctive
|
|
127
|
+
requirement:
|
|
128
|
+
|
|
129
|
+
| `catalysts[r]` | meaning |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `{{a}, {b}}` | *a* **or** *b* — the simple case, and what a flat list of catalysts meant |
|
|
132
|
+
| `{{a, d}, {e}}` | (*a* **and** *d*) **or** *e* |
|
|
133
|
+
| `{}` | **must** be catalysed, and nothing does: never in a RAF |
|
|
134
|
+
| `{frozenset()}` | **may proceed uncatalysed**; always satisfied |
|
|
135
|
+
|
|
136
|
+
The last two rows are a real distinction rather than a technicality — in the §2.4 system
|
|
137
|
+
of that paper it decides which reactions can join an RAF — and a flat list collapses
|
|
138
|
+
them. In CRS, a braced group is conjunctive: `[{a,d}, e]`, with `[]` and `[{}]` for the
|
|
139
|
+
last two rows.
|
|
140
|
+
|
|
141
|
+
Constructors still accept a plain iterable of molecules and normalise it, so simple
|
|
142
|
+
systems stay simple to write.
|
|
143
|
+
|
|
144
|
+
**Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
|
|
145
|
+
molecule can *prevent* a reaction.
|
|
146
|
+
|
|
119
147
|
## Notes on irreducible cores
|
|
120
148
|
|
|
121
149
|
There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
|
|
@@ -145,6 +173,13 @@ pytest -q
|
|
|
145
173
|
Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
|
|
146
174
|
[docs/RELEASING.md](docs/RELEASING.md).
|
|
147
175
|
|
|
176
|
+
## Citing
|
|
177
|
+
|
|
178
|
+
Cite the concept DOI [10.5281/zenodo.21954795](https://doi.org/10.5281/zenodo.21954795),
|
|
179
|
+
which always resolves to the latest version; `CITATION.cff` also lists the per-version
|
|
180
|
+
DOI. If you use the CatReNet interoperability or the validation fixture, please cite
|
|
181
|
+
CatReNet too.
|
|
182
|
+
|
|
148
183
|
## References
|
|
149
184
|
|
|
150
185
|
- Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
|
|
@@ -3,7 +3,9 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/rafkit/__init__.py
|
|
5
5
|
src/rafkit/binary_polymer.py
|
|
6
|
+
src/rafkit/catalysis.py
|
|
6
7
|
src/rafkit/crs.py
|
|
8
|
+
src/rafkit/gillespie.py
|
|
7
9
|
src/rafkit/network.py
|
|
8
10
|
src/rafkit/raf.py
|
|
9
11
|
src/rafkit.egg-info/PKG-INFO
|
|
@@ -12,4 +14,7 @@ src/rafkit.egg-info/dependency_links.txt
|
|
|
12
14
|
src/rafkit.egg-info/requires.txt
|
|
13
15
|
src/rafkit.egg-info/top_level.txt
|
|
14
16
|
tests/test_crs.py
|
|
15
|
-
tests/
|
|
17
|
+
tests/test_gillespie.py
|
|
18
|
+
tests/test_published_examples.py
|
|
19
|
+
tests/test_raf.py
|
|
20
|
+
tests/test_seeding.py
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Hand-computable correctness tests for the stochastic simulator.
|
|
2
|
+
|
|
3
|
+
A Gillespie implementation that is subtly wrong produces plausible trajectories rather
|
|
4
|
+
than errors, so these check arithmetic that can be worked out on paper, plus the one
|
|
5
|
+
structural invariant that caught two real defects while this module was being written:
|
|
6
|
+
**a molecule can never appear before its reactants.**
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pytest
|
|
12
|
+
from rafkit import binary_polymer, max_raf
|
|
13
|
+
from rafkit.network import ReactionNetwork
|
|
14
|
+
|
|
15
|
+
from rafkit.gillespie import (UNCATALYSED_FACTOR, _pair_count, propensities,
|
|
16
|
+
simulate)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _net(molecules, food, pairs, catalysts):
|
|
20
|
+
return ReactionNetwork(molecules=tuple(molecules), food=frozenset(food),
|
|
21
|
+
reaction_pairs=tuple(pairs),
|
|
22
|
+
catalysts=tuple(frozenset(c) for c in catalysts))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestPairCount:
|
|
26
|
+
def test_distinct_reactants_multiply(self):
|
|
27
|
+
assert _pair_count(np.array([3, 4, 0]), (0, 1)) == 12
|
|
28
|
+
|
|
29
|
+
def test_a_species_cannot_react_with_itself(self):
|
|
30
|
+
# 5 copies give 10 distinct pairs, not 25: n(n-1)/2.
|
|
31
|
+
assert _pair_count(np.array([5, 0, 0]), (0, 0)) == 10
|
|
32
|
+
|
|
33
|
+
def test_one_copy_gives_no_self_pair(self):
|
|
34
|
+
assert _pair_count(np.array([1, 0, 0]), (0, 0)) == 0
|
|
35
|
+
|
|
36
|
+
def test_single_reactant_is_its_own_count(self):
|
|
37
|
+
assert _pair_count(np.array([0, 0, 7]), (2,)) == 7
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestPropensities:
|
|
41
|
+
def test_catalysed_runs_at_full_rate(self):
|
|
42
|
+
n = _net("abc", [0, 1], [((0, 1), (2,))], [{2}])
|
|
43
|
+
counts = np.array([3, 4, 1]) # catalyst c present
|
|
44
|
+
assert propensities(n, counts)[0] == pytest.approx(12.0)
|
|
45
|
+
|
|
46
|
+
def test_uncatalysed_runs_at_the_reduced_rate(self):
|
|
47
|
+
n = _net("abc", [0, 1], [((0, 1), (2,))], [{2}])
|
|
48
|
+
counts = np.array([3, 4, 0]) # catalyst absent
|
|
49
|
+
assert propensities(n, counts)[0] == pytest.approx(12.0 / UNCATALYSED_FACTOR)
|
|
50
|
+
|
|
51
|
+
def test_the_reduction_factor_is_exactly_the_ratio(self):
|
|
52
|
+
n = _net("abc", [0, 1], [((0, 1), (2,))], [{2}])
|
|
53
|
+
with_cat = propensities(n, np.array([3, 4, 1]))[0]
|
|
54
|
+
without = propensities(n, np.array([3, 4, 0]))[0]
|
|
55
|
+
assert with_cat / without == pytest.approx(UNCATALYSED_FACTOR)
|
|
56
|
+
|
|
57
|
+
def test_missing_reactant_gives_zero(self):
|
|
58
|
+
n = _net("abc", [0], [((0, 1), (2,))], [{2}])
|
|
59
|
+
assert propensities(n, np.array([9, 0, 9]))[0] == 0.0
|
|
60
|
+
|
|
61
|
+
def test_restriction_silences_excluded_reactions(self):
|
|
62
|
+
n = _net("abcd", [0, 1], [((0, 1), (2,)), ((0, 1), (3,))], [{2}, {3}])
|
|
63
|
+
a = propensities(n, np.array([3, 4, 1, 1]), reactions=[0])
|
|
64
|
+
assert a[0] > 0 and a[1] == 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestSimulation:
|
|
68
|
+
def test_food_is_never_depleted_below_the_floor(self):
|
|
69
|
+
net = binary_polymer(max_len=4, food_len=2, p=0.02,
|
|
70
|
+
rng=np.random.default_rng(0), cleavage=True)
|
|
71
|
+
tr = simulate(net, n_events=800, rng=np.random.default_rng(1), food_floor=5)
|
|
72
|
+
for f in net.food:
|
|
73
|
+
assert tr.counts[:, f].min() >= 5
|
|
74
|
+
|
|
75
|
+
def test_same_seed_gives_the_same_trajectory(self):
|
|
76
|
+
net = binary_polymer(max_len=4, food_len=2, p=0.02,
|
|
77
|
+
rng=np.random.default_rng(0), cleavage=True)
|
|
78
|
+
kw = dict(n_events=500, reactions=sorted(max_raf(net).reactions))
|
|
79
|
+
a = simulate(net, rng=np.random.default_rng(7), **kw)
|
|
80
|
+
b = simulate(net, rng=np.random.default_rng(7), **kw)
|
|
81
|
+
assert a.first_appearance == b.first_appearance
|
|
82
|
+
assert np.array_equal(a.counts, b.counts)
|
|
83
|
+
|
|
84
|
+
def test_every_molecule_has_a_route_that_predates_it(self):
|
|
85
|
+
"""The invariant that caught three defects while this was written.
|
|
86
|
+
|
|
87
|
+
For each non-food molecule, **at least one** reaction producing it must have all
|
|
88
|
+
its reactants present at or before it appeared. Requiring that of *every*
|
|
89
|
+
producing reaction is the wrong claim -- a species is typically also a cleavage
|
|
90
|
+
product of something larger and later -- and asserting it fails on a correct
|
|
91
|
+
simulator, which is how the first version of this test behaved.
|
|
92
|
+
"""
|
|
93
|
+
net = binary_polymer(max_len=5, food_len=2, p=0.0045,
|
|
94
|
+
rng=np.random.default_rng(540), cleavage=True)
|
|
95
|
+
raf = sorted(max_raf(net).reactions)
|
|
96
|
+
tr = simulate(net, n_events=4000, rng=np.random.default_rng(3), reactions=raf)
|
|
97
|
+
|
|
98
|
+
for mol, t_mol in tr.first_appearance.items():
|
|
99
|
+
if mol in net.food:
|
|
100
|
+
continue # supplied from outside; no route needed
|
|
101
|
+
routes = [r for r in raf if mol in net.products(r)]
|
|
102
|
+
assert routes, f"{net.molecules[mol]} appeared but nothing produces it"
|
|
103
|
+
assert any(
|
|
104
|
+
all((t := tr.first_appearance.get(x)) is not None and t <= t_mol
|
|
105
|
+
for x in net.reactants(r))
|
|
106
|
+
for r in routes
|
|
107
|
+
), f"{net.molecules[mol]} appeared before any route to it was available"
|
|
108
|
+
|
|
109
|
+
def test_a_dead_network_stops_rather_than_spinning(self):
|
|
110
|
+
n = _net("ab", [0], [((0, 1), (1,))], [{1}]) # reactant b never exists
|
|
111
|
+
tr = simulate(n, n_events=100, rng=np.random.default_rng(0))
|
|
112
|
+
assert tr.times[-1] == 0.0
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Worked examples from the literature, with their published answers.
|
|
2
|
+
|
|
3
|
+
Every case here is a system somebody else wrote down and stated the answer for, so a
|
|
4
|
+
failure means this library disagrees with the field rather than with our expectations.
|
|
5
|
+
That is the strongest kind of test available, and cheaper than it looks -- these are all
|
|
6
|
+
five reactions or fewer.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from itertools import combinations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from rafkit import max_raf, parse_crs, sample_irrraf
|
|
16
|
+
from rafkit.raf import _refine, core_raf, has_unique_irraf
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _named(net, reactions):
|
|
20
|
+
return frozenset(net.names[r] for r in reactions)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _all_raf_subsets(net):
|
|
24
|
+
n = net.n_reactions
|
|
25
|
+
return [s for k in range(1, n + 1) for c in combinations(range(n), k)
|
|
26
|
+
if (s := frozenset(c)) and _refine(net, s) == s]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Huson, Xavier & Steel, J. R. Soc. Interface 21(214):20230732 (2024),
|
|
31
|
+
# the illustration of corollary 3.1: "based on Kauffman's binary polymer model
|
|
32
|
+
# with food set F = {0,1,00,01,10,11}".
|
|
33
|
+
KAUFFMAN_BPM = """
|
|
34
|
+
Food: 0, 1, 00, 01, 10, 11
|
|
35
|
+
r1 : 10 + 0 [01100] => 100
|
|
36
|
+
r2 : 01 + 100 [0] => 01100
|
|
37
|
+
r3 : 10 + 1 [0] => 101
|
|
38
|
+
r4 : 11 + 10 [101] => 1110
|
|
39
|
+
r5 : 1110 + 0 [101] => 11100
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestKauffmanBinaryPolymerExample:
|
|
44
|
+
""""This system is itself an RAF and it contains six other RAFs as subsets."""
|
|
45
|
+
|
|
46
|
+
def test_the_whole_system_is_a_raf(self):
|
|
47
|
+
net = parse_crs(KAUFFMAN_BPM)
|
|
48
|
+
assert _named(net, max_raf(net).reactions) == {"r1", "r2", "r3", "r4", "r5"}
|
|
49
|
+
|
|
50
|
+
def test_exactly_the_seven_published_rafs(self):
|
|
51
|
+
net = parse_crs(KAUFFMAN_BPM)
|
|
52
|
+
got = {frozenset(net.names[r] for r in s) for s in _all_raf_subsets(net)}
|
|
53
|
+
assert got == {
|
|
54
|
+
frozenset({"r3"}),
|
|
55
|
+
frozenset({"r1", "r2"}),
|
|
56
|
+
frozenset({"r3", "r4"}),
|
|
57
|
+
frozenset({"r1", "r2", "r3"}),
|
|
58
|
+
frozenset({"r3", "r4", "r5"}),
|
|
59
|
+
frozenset({"r1", "r2", "r3", "r4"}),
|
|
60
|
+
frozenset({"r1", "r2", "r3", "r4", "r5"}),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def test_the_irreducible_ones_are_r1r2_and_r3(self):
|
|
64
|
+
net = parse_crs(KAUFFMAN_BPM)
|
|
65
|
+
cores = {_named(net, sample_irrraf(net, max_raf(net).reactions,
|
|
66
|
+
np.random.default_rng(i)))
|
|
67
|
+
for i in range(50)}
|
|
68
|
+
assert cores == {frozenset({"r1", "r2"}), frozenset({"r3"})}
|
|
69
|
+
|
|
70
|
+
def test_two_irrafs_means_the_core_test_says_not_unique(self):
|
|
71
|
+
net = parse_crs(KAUFFMAN_BPM)
|
|
72
|
+
assert not has_unique_irraf(net)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Same paper, §4.1: three iRAFs, none nested, "each one is a subset of the union
|
|
77
|
+
# of the two others" -- the case that makes extending pairwise results to three fail.
|
|
78
|
+
THREE_IRRAFS = """
|
|
79
|
+
Food: f
|
|
80
|
+
r1 : f [x1] => x2 + x3
|
|
81
|
+
r2 : f [x2] => x1 + x3
|
|
82
|
+
r3 : f [x3] => x1 + x2
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestThreeIrreducibleRafs:
|
|
87
|
+
def test_finds_all_three_published_irrafs(self):
|
|
88
|
+
net = parse_crs(THREE_IRRAFS)
|
|
89
|
+
cores = {_named(net, sample_irrraf(net, max_raf(net).reactions,
|
|
90
|
+
np.random.default_rng(i)))
|
|
91
|
+
for i in range(60)}
|
|
92
|
+
assert cores == {frozenset({"r1", "r2"}), frozenset({"r1", "r3"}),
|
|
93
|
+
frozenset({"r2", "r3"})}
|
|
94
|
+
|
|
95
|
+
def test_no_irraf_is_nested_in_another(self):
|
|
96
|
+
net = parse_crs(THREE_IRRAFS)
|
|
97
|
+
cores = list({sample_irrraf(net, max_raf(net).reactions,
|
|
98
|
+
np.random.default_rng(i)) for i in range(60)})
|
|
99
|
+
for a in cores:
|
|
100
|
+
for b in cores:
|
|
101
|
+
assert a == b or not (a < b)
|
|
102
|
+
|
|
103
|
+
def test_each_is_contained_in_the_union_of_the_other_two(self):
|
|
104
|
+
"""The published subtlety: pairwise non-nesting does not extend to triples."""
|
|
105
|
+
net = parse_crs(THREE_IRRAFS)
|
|
106
|
+
cores = list({sample_irrraf(net, max_raf(net).reactions,
|
|
107
|
+
np.random.default_rng(i)) for i in range(60)})
|
|
108
|
+
assert len(cores) == 3
|
|
109
|
+
for i, c in enumerate(cores):
|
|
110
|
+
others = cores[:i] + cores[i + 1:]
|
|
111
|
+
assert c <= others[0] | others[1]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestCoreRaf:
|
|
115
|
+
def test_core_is_the_iraf_when_it_is_unique(self):
|
|
116
|
+
# Mutually dependent pair: neither reaction survives without the other, so the
|
|
117
|
+
# maximal RAF is already irreducible and Core must equal it.
|
|
118
|
+
net = parse_crs("Food: a, b\nr1 : a + b [d] => c\nr2 : a + c [c] => d\n")
|
|
119
|
+
assert has_unique_irraf(net)
|
|
120
|
+
assert core_raf(net) == max_raf(net).reactions
|
|
121
|
+
|
|
122
|
+
def test_no_raf_is_not_a_unique_iraf(self):
|
|
123
|
+
net = parse_crs("Food: a, b\nr1 : a + b [z] => c\n")
|
|
124
|
+
assert max_raf(net).is_empty
|
|
125
|
+
assert not has_unique_irraf(net)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Huson, Xavier & Steel (2024), Example 3.2 — conjunctive catalysis.
|
|
130
|
+
# r1 : a + b [{a,d}] -> e catalyst set {a,d}: BOTH required
|
|
131
|
+
# r2 : b + c [{a,b}, e] -> d {a,b} together, or e alone
|
|
132
|
+
# r3 : d [{a,b}] -> c
|
|
133
|
+
EXAMPLE_3_2 = """
|
|
134
|
+
Food: a, b, c
|
|
135
|
+
r1 : a + b [{a,d}] => e
|
|
136
|
+
r2 : b + c [{a,b},e] => d
|
|
137
|
+
r3 : d [{a,b}] => c
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestConjunctiveCatalysisExample:
|
|
142
|
+
""""{r2}, {r1,r2}, {r2,r3} and {r1,r2,r3} are RAFs, however {r1,r2} is the only
|
|
143
|
+
strictly autocatalytic RAF."
|
|
144
|
+
|
|
145
|
+
Unrepresentable before conjunctive catalyst sets existed in this library.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def test_exactly_the_four_published_rafs(self):
|
|
149
|
+
net = parse_crs(EXAMPLE_3_2)
|
|
150
|
+
got = {frozenset(net.names[r] for r in s) for s in _all_raf_subsets(net)}
|
|
151
|
+
assert got == {
|
|
152
|
+
frozenset({"r2"}),
|
|
153
|
+
frozenset({"r1", "r2"}),
|
|
154
|
+
frozenset({"r2", "r3"}),
|
|
155
|
+
frozenset({"r1", "r2", "r3"}),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def test_the_only_strictly_autocatalytic_raf_is_r1r2(self):
|
|
159
|
+
from rafkit import max_raf_strict
|
|
160
|
+
net = parse_crs(EXAMPLE_3_2)
|
|
161
|
+
assert _named(net, max_raf_strict(net).reactions) == {"r1", "r2"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Huson, Xavier & Steel (2024) §2.4 — the system Example 3.1 traces the algorithm on.
|
|
166
|
+
# It needs BOTH new features: conjunctive sets, and the distinction between
|
|
167
|
+
# r2 : chi = {{}} "may proceed uncatalysed" and
|
|
168
|
+
# r3 : chi = {} "must be catalysed, and nothing does"
|
|
169
|
+
# which a flat set of catalysts collapses into one.
|
|
170
|
+
SECTION_2_4 = """
|
|
171
|
+
Food: a, b
|
|
172
|
+
r1 : a + a [{c,d},e] => c
|
|
173
|
+
r2 : b + c [{}] => d
|
|
174
|
+
r3 : b + b [] => e
|
|
175
|
+
r4 : a + e [a] => b
|
|
176
|
+
r5 : c + d [d] => g + g
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestUncatalysedDistinctionExample:
|
|
181
|
+
""""This system has {r1, r2, r5} as its maxRAF, and {r1, r2} as its unique iRAF." """
|
|
182
|
+
|
|
183
|
+
def test_the_published_maxraf(self):
|
|
184
|
+
net = parse_crs(SECTION_2_4)
|
|
185
|
+
assert _named(net, max_raf(net).reactions) == {"r1", "r2", "r5"}
|
|
186
|
+
|
|
187
|
+
def test_the_published_unique_iraf(self):
|
|
188
|
+
net = parse_crs(SECTION_2_4)
|
|
189
|
+
assert has_unique_irraf(net)
|
|
190
|
+
assert _named(net, core_raf(net)) == {"r1", "r2"}
|
|
191
|
+
|
|
192
|
+
def test_the_two_empty_catalyst_forms_are_not_interchangeable(self):
|
|
193
|
+
"""r2 (may run uncatalysed) is in the maxRAF; r3 (must be catalysed, nothing
|
|
194
|
+
does) can never be. Collapsing them would put both in or both out."""
|
|
195
|
+
net = parse_crs(SECTION_2_4)
|
|
196
|
+
names = _named(net, max_raf(net).reactions)
|
|
197
|
+
assert "r2" in names and "r3" not in names
|
|
@@ -124,6 +124,7 @@ class TestGenerator:
|
|
|
124
124
|
|
|
125
125
|
def test_p_one_catalyses_everything(self):
|
|
126
126
|
n = binary_polymer(max_len=4, food_len=2, p=1.0, rng=np.random.default_rng(0))
|
|
127
|
+
# p=1.0: every molecule catalyses every reaction, one singleton set each.
|
|
127
128
|
assert all(len(c) == n.n_molecules for c in n.catalysts)
|
|
128
129
|
|
|
129
130
|
def test_seeded_generation_is_reproducible(self):
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Gate 1: a maximal RAF assembles by seeding, it does not switch on.
|
|
2
|
+
|
|
3
|
+
Reproduces the mechanism reported by Hordijk & Steel, "Autocatalytic sets extended:
|
|
4
|
+
dynamics, inhibition, and a generalization" (*J. Syst. Chem.* 3, 5, 2012): parts of a
|
|
5
|
+
maximal RAF catalysed only by their own products cannot start until those products
|
|
6
|
+
appear by a rare **uncatalysed** reaction, so the set comes into existence as an
|
|
7
|
+
order-dependent sequence of events.
|
|
8
|
+
|
|
9
|
+
Their exact network is a random draw we cannot reconstruct, so these run over an
|
|
10
|
+
ensemble of networks generated at **their published parameters** (n=5, t=2, p=0.0045,
|
|
11
|
+
cleavage-ligation) and assert only what is *causally necessary*. Two phenomenological
|
|
12
|
+
features of their figure are deliberately not asserted here:
|
|
13
|
+
|
|
14
|
+
* a species declining once a later subRAF consumes it -- topology-specific, and it does
|
|
15
|
+
not reproduce cleanly on these networks;
|
|
16
|
+
* growth levelling off as cleavage catches up -- it does reproduce, but needs a longer
|
|
17
|
+
run than their 25,000 events, so it lives in `examples/` where run length is free.
|
|
18
|
+
|
|
19
|
+
Both were measured before being dropped, rather than quietly omitted.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
from rafkit import binary_polymer, max_raf, simulate
|
|
27
|
+
from rafkit.catalysis import is_catalysed
|
|
28
|
+
from rafkit.gillespie import catalytically_reachable
|
|
29
|
+
|
|
30
|
+
# Hordijk & Steel (2012) §"A realistic example".
|
|
31
|
+
PUBLISHED = dict(max_len=5, food_len=2, p=0.0045)
|
|
32
|
+
SEEDS = [540, 442, 1591, 1660, 44, 95]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(seed, n_events=20_000):
|
|
36
|
+
net = binary_polymer(**PUBLISHED, rng=np.random.default_rng(seed), cleavage=True)
|
|
37
|
+
raf = sorted(max_raf(net).reactions)
|
|
38
|
+
if not raf:
|
|
39
|
+
pytest.skip(f"seed {seed} generated no RAF")
|
|
40
|
+
return net, raf, simulate(net, n_events=n_events, rng=np.random.default_rng(0),
|
|
41
|
+
reactions=raf)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
45
|
+
def test_food_catalysed_reactions_never_need_seeding(seed):
|
|
46
|
+
"""Criterion 1. Food never depletes, so a reaction with a food catalyst always has
|
|
47
|
+
one: it can run from t=0 and can never fire uncatalysed. Exact, not statistical."""
|
|
48
|
+
net, raf, tr = _run(seed)
|
|
49
|
+
food_catalysed = [r for r in raf if is_catalysed(net.catalysts[r], net.food)]
|
|
50
|
+
assert food_catalysed, "no always-on reactions in this network"
|
|
51
|
+
for r in food_catalysed:
|
|
52
|
+
assert r not in tr.first_uncatalysed, (
|
|
53
|
+
f"reaction {r} has a food catalyst yet fired uncatalysed")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
57
|
+
def test_nothing_beyond_the_catalytic_core_appears_before_a_seeding_event(seed):
|
|
58
|
+
"""Criterion 2, and the mechanism itself: molecules that catalysed firings alone
|
|
59
|
+
cannot reach must wait for a spontaneous reaction."""
|
|
60
|
+
net, raf, tr = _run(seed)
|
|
61
|
+
reachable = catalytically_reachable(net, raf)
|
|
62
|
+
beyond = {m: t for m, t in tr.first_appearance.items() if m not in reachable}
|
|
63
|
+
if not beyond:
|
|
64
|
+
pytest.skip("this network needs no seeding; nothing to test")
|
|
65
|
+
|
|
66
|
+
assert tr.first_uncatalysed, "molecules appeared beyond the core with no seeding event"
|
|
67
|
+
first_seed = min(tr.first_uncatalysed.values())
|
|
68
|
+
earliest, t = min(beyond.items(), key=lambda kv: kv[1])
|
|
69
|
+
assert t >= first_seed, (
|
|
70
|
+
f"{net.molecules[earliest]} appeared at {t:.4f}, before the first seeding "
|
|
71
|
+
f"event at {first_seed:.4f}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_the_static_prediction_is_tighter_than_the_naive_one():
|
|
75
|
+
"""`catalytically_reachable` iterates to a fixpoint. Taking only food-catalysed
|
|
76
|
+
reactions is the tempting cheaper version and it under-counts, because a reaction
|
|
77
|
+
catalysed by something the always-on part makes becomes catalysed without a seed."""
|
|
78
|
+
from rafkit.raf import _closure
|
|
79
|
+
net = binary_polymer(**PUBLISHED, rng=np.random.default_rng(95), cleavage=True)
|
|
80
|
+
raf = sorted(max_raf(net).reactions)
|
|
81
|
+
naive = _closure(net, frozenset(r for r in raf
|
|
82
|
+
if is_catalysed(net.catalysts[r], net.food)))
|
|
83
|
+
assert naive < catalytically_reachable(net, raf)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|