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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.1.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
  [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
34
34
  [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
35
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](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
  [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
6
6
  [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rafkit"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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, and Kauffman's binary polymer model as a generator.
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, exploitability, irrraf_census,
19
- is_food_catalysed, max_raf, max_raf_strict, sample_irrraf,
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.1.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. A reversible reaction is read as **two** reactions, forward and
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 = _split_list(inside)
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 = ",".join(sorted(name(c) for c in net.catalysts[r]))
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)) and (net.catalysts[r] & have)
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] and net.catalysts[r] & (raf.closure - {m})
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)) and (net.catalysts[r] & pool)
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] & net.food for r in core)
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
- pool = raf.closure - net.food
300
- return frozenset(r for r in raf.reactions if net.catalysts[r] & pool)
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.1.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
  [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
34
34
  [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
35
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](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/test_raf.py
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