rafkit 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {rafkit-0.3.0/src/rafkit.egg-info → rafkit-0.5.0}/PKG-INFO +24 -3
  2. {rafkit-0.3.0 → rafkit-0.5.0}/README.md +23 -2
  3. {rafkit-0.3.0 → rafkit-0.5.0}/pyproject.toml +1 -1
  4. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/__init__.py +10 -2
  5. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/binary_polymer.py +72 -3
  6. rafkit-0.5.0/src/rafkit/complementary_polymer.py +115 -0
  7. rafkit-0.5.0/src/rafkit/firing_disk.py +139 -0
  8. rafkit-0.5.0/src/rafkit/pnml.py +139 -0
  9. {rafkit-0.3.0 → rafkit-0.5.0/src/rafkit.egg-info}/PKG-INFO +24 -3
  10. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit.egg-info/SOURCES.txt +6 -0
  11. rafkit-0.5.0/tests/test_complementary_polymer.py +115 -0
  12. rafkit-0.5.0/tests/test_firing_disk.py +87 -0
  13. {rafkit-0.3.0 → rafkit-0.5.0}/tests/test_inhibition.py +59 -0
  14. rafkit-0.5.0/tests/test_pnml.py +124 -0
  15. {rafkit-0.3.0 → rafkit-0.5.0}/LICENSE +0 -0
  16. {rafkit-0.3.0 → rafkit-0.5.0}/setup.cfg +0 -0
  17. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/catalysis.py +0 -0
  18. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/crs.py +0 -0
  19. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/gillespie.py +0 -0
  20. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/inhibition.py +0 -0
  21. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/network.py +0 -0
  22. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit/raf.py +0 -0
  23. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit.egg-info/dependency_links.txt +0 -0
  24. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit.egg-info/requires.txt +0 -0
  25. {rafkit-0.3.0 → rafkit-0.5.0}/src/rafkit.egg-info/top_level.txt +0 -0
  26. {rafkit-0.3.0 → rafkit-0.5.0}/tests/test_crs.py +0 -0
  27. {rafkit-0.3.0 → rafkit-0.5.0}/tests/test_gillespie.py +0 -0
  28. {rafkit-0.3.0 → rafkit-0.5.0}/tests/test_published_examples.py +0 -0
  29. {rafkit-0.3.0 → rafkit-0.5.0}/tests/test_raf.py +0 -0
  30. {rafkit-0.3.0 → rafkit-0.5.0}/tests/test_seeding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models.
5
5
  Author: James P. Galasyn, Claude Théodore
6
6
  License: MIT
@@ -30,8 +30,8 @@ Dynamic: license-file
30
30
 
31
31
  [![CI](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
32
32
  [![codecov](https://codecov.io/gh/JimGalasyn/rafkit/branch/main/graph/badge.svg)](https://codecov.io/gh/JimGalasyn/rafkit)
33
- [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
34
- [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
33
+ [![PyPI](https://img.shields.io/pypi/v/rafkit.svg?cacheSeconds=3600)](https://pypi.org/project/rafkit/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg?cacheSeconds=3600)](https://pypi.org/project/rafkit/)
35
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
36
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](https://doi.org/10.5281/zenodo.21954795)
37
37
 
@@ -115,12 +115,33 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
115
115
  | `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
116
116
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
117
117
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
118
+ | `to_pnml` / `write_pnml` | PNML export (ISO/IEC 15909-2) for the Petri net ecosystem |
118
119
  | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
119
120
  | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
120
121
 
121
122
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
122
123
  is subtly wrong produces plausible numbers rather than errors.
123
124
 
125
+ ## A reaction network is a Petri net
126
+
127
+ Species are places, reactions are transitions, molecule counts are tokens. `write_pnml`
128
+ exports to PNML (ISO/IEC 15909-2), so these networks open in Petri net editors, model
129
+ checkers, and the unfolding tools that compute the causal structure of a run.
130
+
131
+ Three things need care, and each is explicit rather than silent:
132
+
133
+ - **Catalysis becomes a self-loop.** P/T nets have no read arc, so a catalyst is a pair
134
+ of arcs, consuming the token and returning it.
135
+ - **Alternative catalyst sets become separate transitions**, named `r1`, `r1#2`, …, since
136
+ a transition's preset is a conjunction and cannot express "either set".
137
+ - **Food gets source transitions**, because RAF food is inexhaustible and no initial
138
+ marking expresses that — a marking of *n* deadlocks after *n* uses.
139
+
140
+ Inhibition has no `ptnet` representation and is written as a `toolspecific` annotation,
141
+ with a warning in the file: a reader that ignores it gets a *different system*.
142
+ Reactions requiring a catalyst that nothing provides are omitted and counted, since
143
+ emitting them unconstrained would make them freely fireable — the opposite of the intent.
144
+
124
145
  ## Catalysis is a relation, not a list
125
146
 
126
147
  `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
@@ -2,8 +2,8 @@
2
2
 
3
3
  [![CI](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
4
4
  [![codecov](https://codecov.io/gh/JimGalasyn/rafkit/branch/main/graph/badge.svg)](https://codecov.io/gh/JimGalasyn/rafkit)
5
- [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
6
- [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
5
+ [![PyPI](https://img.shields.io/pypi/v/rafkit.svg?cacheSeconds=3600)](https://pypi.org/project/rafkit/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg?cacheSeconds=3600)](https://pypi.org/project/rafkit/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
8
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](https://doi.org/10.5281/zenodo.21954795)
9
9
 
@@ -87,12 +87,33 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
87
87
  | `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
88
88
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
89
89
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
90
+ | `to_pnml` / `write_pnml` | PNML export (ISO/IEC 15909-2) for the Petri net ecosystem |
90
91
  | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
91
92
  | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
92
93
 
93
94
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
94
95
  is subtly wrong produces plausible numbers rather than errors.
95
96
 
97
+ ## A reaction network is a Petri net
98
+
99
+ Species are places, reactions are transitions, molecule counts are tokens. `write_pnml`
100
+ exports to PNML (ISO/IEC 15909-2), so these networks open in Petri net editors, model
101
+ checkers, and the unfolding tools that compute the causal structure of a run.
102
+
103
+ Three things need care, and each is explicit rather than silent:
104
+
105
+ - **Catalysis becomes a self-loop.** P/T nets have no read arc, so a catalyst is a pair
106
+ of arcs, consuming the token and returning it.
107
+ - **Alternative catalyst sets become separate transitions**, named `r1`, `r1#2`, …, since
108
+ a transition's preset is a conjunction and cannot express "either set".
109
+ - **Food gets source transitions**, because RAF food is inexhaustible and no initial
110
+ marking expresses that — a marking of *n* deadlocks after *n* uses.
111
+
112
+ Inhibition has no `ptnet` representation and is written as a `toolspecific` annotation,
113
+ with a warning in the file: a reader that ignores it gets a *different system*.
114
+ Reactions requiring a catalyst that nothing provides are omitted and counted, since
115
+ emitting them unconstrained would make them freely fireable — the opposite of the intent.
116
+
96
117
  ## Catalysis is a relation, not a list
97
118
 
98
119
  `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rafkit"
3
- version = "0.3.0"
3
+ version = "0.5.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" }
@@ -13,27 +13,35 @@ See the README for the calibration against Steel, Hordijk & Smith (2012) and for
13
13
  CatReNet interoperability.
14
14
  """
15
15
  from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
16
+ from rafkit.complementary_polymer import (complement,
17
+ complementary_polymer)
18
+ from rafkit.firing_disk import firing_disk_polymer
16
19
  from rafkit.catalysis import catalysing_molecules, is_catalysed, normalise
17
20
  from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
18
21
  from rafkit.gillespie import Trajectory, propensities, simulate
19
22
  from rafkit.inhibition import (classes_from_inhibitors, is_uninhibited, is_uraf,
20
23
  max_urafs, support)
21
24
  from rafkit.network import ReactionNetwork
25
+ from rafkit.pnml import to_pnml, write_pnml
22
26
  from rafkit.raf import (
23
27
  RafResult, catrenet_strictly_autocatalytic, core_raf, exploitability,
24
28
  has_unique_irraf, irrraf_census, is_food_catalysed, max_raf, max_raf_strict,
25
29
  sample_irrraf,
26
30
  )
27
31
 
28
- __version__ = "0.3.0"
32
+ __version__ = "0.5.0"
29
33
 
30
34
  __all__ = [
31
- "BinaryPolymerNetwork", "binary_polymer", "ReactionNetwork",
35
+ "BinaryPolymerNetwork", "binary_polymer",
36
+ "complementary_polymer",
37
+ "firing_disk_polymer",
38
+ "complement", "ReactionNetwork",
32
39
  "RafResult", "max_raf", "max_raf_strict", "sample_irrraf", "irrraf_census",
33
40
  "exploitability", "is_food_catalysed", "catrenet_strictly_autocatalytic",
34
41
  "core_raf", "has_unique_irraf",
35
42
  "is_catalysed", "catalysing_molecules", "normalise",
36
43
  "parse_crs", "read_crs", "to_crs", "write_crs",
44
+ "to_pnml", "write_pnml",
37
45
  "simulate", "propensities", "Trajectory",
38
46
  "max_urafs", "is_uraf", "is_uninhibited", "support", "classes_from_inhibitors",
39
47
  ]
@@ -66,6 +66,7 @@ class BinaryPolymerNetwork:
66
66
  max_len: int
67
67
  food_len: int
68
68
  directions: tuple[int, ...] = ()
69
+ inhibitors: tuple[frozenset[int], ...] = ()
69
70
 
70
71
  def __post_init__(self):
71
72
  object.__setattr__(self, "catalysts",
@@ -76,6 +77,16 @@ class BinaryPolymerNetwork:
76
77
  raise ValueError(
77
78
  f"directions has {len(self.directions)} entries for "
78
79
  f"{len(self.reactions)} reactions")
80
+ if not self.inhibitors:
81
+ object.__setattr__(self, "inhibitors",
82
+ (frozenset(),) * len(self.reactions))
83
+ elif len(self.inhibitors) != len(self.reactions):
84
+ raise ValueError(
85
+ f"inhibitors has {len(self.inhibitors)} entries for "
86
+ f"{len(self.reactions)} reactions")
87
+ else:
88
+ object.__setattr__(self, "inhibitors",
89
+ tuple(frozenset(i) for i in self.inhibitors))
79
90
 
80
91
  def reactants(self, r: int) -> tuple[int, ...]:
81
92
  """Molecules consumed by reaction `r`, in its stored direction."""
@@ -105,7 +116,28 @@ class BinaryPolymerNetwork:
105
116
  if not self.molecules:
106
117
  return 0.0
107
118
  n_pairs = self.n_reactions - self.n_cleavages
108
- return sum(len(c) for c in self.catalysts[:n_pairs]) / len(self.molecules)
119
+ # Counted over the reversible PAIR: a cleavage-ligation pair is one reaction, so
120
+ # take the union of the two directions' catalysts. Under `paired_catalysis` the
121
+ # halves are identical and this is exactly the old count; it differs only where
122
+ # the directions were drawn separately -- as in C-BPM, where a catalyst acts on
123
+ # one direction only and counting the ligation half alone would miss every
124
+ # cleavage catalyst.
125
+ total = 0
126
+ for i in range(n_pairs):
127
+ both = self.catalysts[i]
128
+ if i + n_pairs < self.n_reactions:
129
+ both = both | self.catalysts[i + n_pairs]
130
+ total += len(both)
131
+ return total / len(self.molecules)
132
+
133
+ @property
134
+ def n_inhibiting_molecules(self) -> int:
135
+ """`k` in Hordijk & Steel's encoding — distinct molecules that inhibit.
136
+
137
+ This is the exponent in `max_urafs`'s `2**k` cost, not a chemistry parameter,
138
+ which is why `binary_polymer` lets you cap it directly.
139
+ """
140
+ return len({x for inh in self.inhibitors for x in inh})
109
141
 
110
142
  @property
111
143
  def n_molecules(self) -> int:
@@ -136,7 +168,9 @@ def _strings(max_len: int) -> Iterator[str]:
136
168
  def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
137
169
  rng: np.random.Generator | None = None,
138
170
  cleavage: bool = False,
139
- paired_catalysis: bool = True) -> BinaryPolymerNetwork:
171
+ paired_catalysis: bool = True,
172
+ q: float = 0.0,
173
+ n_inhibitors: int | None = None) -> BinaryPolymerNetwork:
140
174
  """Generate one binary-polymer network with catalysis at probability `p`.
141
175
 
142
176
  `cleavage=True` adds the reverse `ab -> a + b` of every ligation, doubling the
@@ -147,6 +181,20 @@ def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
147
181
  reaction of Steel, Hordijk & Smith (2012), and the convention their f = p|R| is
148
182
  measured in. Set False to draw the two directions independently; that is a
149
183
  different chemistry and its f is not comparable to theirs.
184
+
185
+ `q` is the inhibition probability, the mirror of `p`: each (eligible molecule,
186
+ reaction) pair becomes an inhibition edge with probability `q` (Hordijk & Steel
187
+ 2012, Part II). `q=0`, the default, leaves the network uninhibited and every
188
+ existing result unchanged.
189
+
190
+ `n_inhibitors` caps how many **distinct molecules** may inhibit, drawn uniformly.
191
+ That cap is `k` in the (X_i, R_i) encoding, and `max_urafs` costs `2**k` maximal-RAF
192
+ computations -- so it is the difference between a feasible u-RAF census and an
193
+ impossible one. Leaving it `None` makes every molecule eligible, which is faithful
194
+ to the model but puts `max_urafs` out of reach on any network of interesting size;
195
+ `is_uraf` and `is_uninhibited` stay cheap either way. Inhibition is drawn on the
196
+ ligation half and **shared with the reverse** under `paired_catalysis`, exactly as
197
+ catalysis is, so the reversible reaction remains one unit.
150
198
  """
151
199
  if max_len < 2:
152
200
  raise ValueError(f"max_len must be at least 2, got {max_len}")
@@ -154,6 +202,10 @@ def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
154
202
  raise ValueError(f"food_len must be in [0, max_len), got {food_len}")
155
203
  if not 0.0 <= p <= 1.0:
156
204
  raise ValueError(f"p is a probability, got {p}")
205
+ if not 0.0 <= q <= 1.0:
206
+ raise ValueError(f"q is a probability, got {q}")
207
+ if n_inhibitors is not None and n_inhibitors < 0:
208
+ raise ValueError(f"n_inhibitors must be non-negative, got {n_inhibitors}")
157
209
  rng = rng or np.random.default_rng()
158
210
 
159
211
  molecules = tuple(_strings(max_len))
@@ -184,6 +236,23 @@ def binary_polymer(max_len: int = 8, food_len: int = 2, p: float = 1e-3,
184
236
  )
185
237
  # Paired: the cleavage half re-uses its ligation's catalysts rather than redrawing.
186
238
  catalysts = drawn + drawn if (cleavage and paired_catalysis) else drawn
239
+
240
+ # Inhibition, drawn the same sparse way and over the same paired unit as catalysis.
241
+ # Eligibility is restricted FIRST so that k is exactly n_inhibitors, rather than
242
+ # whatever a q-dependent draw happens to produce.
243
+ if q > 0.0:
244
+ eligible = (np.arange(n_mol) if n_inhibitors is None else
245
+ rng.choice(n_mol, size=min(n_inhibitors, n_mol), replace=False))
246
+ i_counts = rng.binomial(len(eligible), q, size=n_draw)
247
+ i_drawn = tuple(
248
+ frozenset(rng.choice(eligible, size=int(k), replace=False).tolist()) if k
249
+ else frozenset()
250
+ for k in i_counts
251
+ )
252
+ inhibitors = (i_drawn + i_drawn if (cleavage and paired_catalysis) else i_drawn)
253
+ else:
254
+ inhibitors = ()
187
255
  return BinaryPolymerNetwork(molecules=molecules, food=food, reactions=reactions,
188
256
  catalysts=catalysts, p=p, max_len=max_len,
189
- food_len=food_len, directions=directions)
257
+ food_len=food_len, directions=directions,
258
+ inhibitors=inhibitors)
@@ -0,0 +1,115 @@
1
+ """E5 — Serra & Villani's C-BPM, where catalysis follows STRUCTURE rather than a coin flip.
2
+
3
+ Reproduced from Serra & Villani, *Entropy* 28(2), 184 (2026), §2.2, rather than invented:
4
+ an in-house structured-catalysis ensemble would be a worse version of a published one.
5
+
6
+ **The reaction set is identical to the K-BPM's.** Cleavage splits a polymer, condensation
7
+ joins two, and the condensation product ``R-M1M2-R'`` is just the concatenation of the two
8
+ reactants. What differs is *which catalyst catalyses which reaction*, and that single change
9
+ is the whole point of the model:
10
+
11
+ * a species is a catalyst with probability ``p_cat``, and a catalyst is a **cleavage**
12
+ catalyst with probability ``p_cleave``, otherwise a **condensation** catalyst;
13
+ * a catalyst carries an **active site** — a substring of itself, of length ``Lambda`` drawn
14
+ uniformly from ``[site_min, site_max]`` — plus a cut/suture position inside that site;
15
+ * it acts on whatever is **complementary** to that site (binary complement, 0<->1).
16
+
17
+ The consequence, and the reason this ensemble exists: a K-catalyst's targets are
18
+ independent draws, while a **C-catalyst's targets all share one template**, so they are
19
+ structurally correlated. Serra & Villani measure the signature of that as a much higher and
20
+ much more irregular reactions-per-catalyst distribution (~400 against ~20).
21
+
22
+ Only polymers at least as long as the active site can be catalysts, which falls out of the
23
+ construction rather than being imposed.
24
+
25
+ ⚠ **This implements their "total chemistry" mode** — every species up to ``max_len`` exists
26
+ and catalysis is assigned over that set. Their alternative **firing-disk** mode grows the
27
+ species set outward from a small seed and is a genuinely different construction, not a
28
+ parameter of this one; it is not implemented here.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import numpy as np
33
+
34
+ from rafkit.binary_polymer import BinaryPolymerNetwork, _strings
35
+
36
+ _FLIP = str.maketrans("01", "10")
37
+
38
+
39
+ def complement(s: str) -> str:
40
+ """Binary complement — the paper's "an A must correspond to a B, and vice versa"."""
41
+ return s.translate(_FLIP)
42
+
43
+
44
+ def complementary_polymer(max_len: int = 8, food_len: int = 2, p_cat: float = 0.05,
45
+ p_cleave: float = 0.5, site_min: int = 3, site_max: int = 4,
46
+ rng: np.random.Generator | None = None) -> BinaryPolymerNetwork:
47
+ """Generate one C-chemistry: catalysis by active-site complementarity.
48
+
49
+ Defaults are the paper's Figure 3 ensemble (``p_cat=0.05``, ``p_cleave=0.5``, active
50
+ sites uniform on 3..4). Returns the same `BinaryPolymerNetwork` a K-chemistry does, so
51
+ every downstream consumer -- `max_raf`, the protocell, the u-RAF layer -- is unchanged.
52
+ """
53
+ if max_len < 2:
54
+ raise ValueError(f"max_len must be at least 2, got {max_len}")
55
+ if not 0 <= food_len < max_len:
56
+ raise ValueError(f"food_len must be in [0, max_len), got {food_len}")
57
+ for name, v in (("p_cat", p_cat), ("p_cleave", p_cleave)):
58
+ if not 0.0 <= v <= 1.0:
59
+ raise ValueError(f"{name} is a probability, got {v}")
60
+ if not 1 <= site_min <= site_max:
61
+ raise ValueError(f"need 1 <= site_min <= site_max, got {site_min}, {site_max}")
62
+ rng = rng or np.random.default_rng()
63
+
64
+ molecules = tuple(_strings(max_len))
65
+ index = {m: i for i, m in enumerate(molecules)}
66
+ food = frozenset(i for i, m in enumerate(molecules) if len(m) <= food_len)
67
+
68
+ ligations = tuple(
69
+ (index[a], index[b], index[a + b])
70
+ for a in molecules for b in molecules if len(a) + len(b) <= max_len
71
+ )
72
+ reactions = ligations + ligations
73
+ directions = (1,) * len(ligations) + (-1,) * len(ligations)
74
+ n_pairs = len(ligations)
75
+ lig_at = {(a, b): i for i, (a, b, _) in enumerate(ligations)}
76
+ # cleavage of `ab` at offset k is the reverse of the ligation a + b -> ab
77
+ cleave_at = {(index[m], k): n_pairs + lig_at[(index[m[:k]], index[m[k:]])]
78
+ for m in molecules for k in range(1, len(m))}
79
+
80
+ by_prefix: dict[str, list[str]] = {}
81
+ by_suffix: dict[str, list[str]] = {}
82
+ for m in molecules:
83
+ for k in range(1, len(m) + 1):
84
+ by_prefix.setdefault(m[:k], []).append(m)
85
+ by_suffix.setdefault(m[-k:], []).append(m)
86
+
87
+ catalysts: list[set[int]] = [set() for _ in reactions]
88
+ for x, mol in enumerate(molecules):
89
+ if len(mol) < site_min or rng.random() >= p_cat:
90
+ continue
91
+ width = int(rng.integers(site_min, min(site_max, len(mol)) + 1))
92
+ start = int(rng.integers(0, len(mol) - width + 1))
93
+ site = mol[start:start + width]
94
+ cut = int(rng.integers(1, width)) # split point inside the active site
95
+ if rng.random() < p_cleave:
96
+ # cleave any polymer carrying a segment complementary to the whole site
97
+ target = complement(site)
98
+ for m in molecules:
99
+ pos = m.find(target)
100
+ while pos != -1:
101
+ catalysts[cleave_at[(index[m], pos + cut)]].add(x)
102
+ pos = m.find(target, pos + 1)
103
+ else:
104
+ # join a molecule ENDING complementary to site[:cut] to one STARTING
105
+ # complementary to site[cut:]
106
+ left, right = complement(site[:cut]), complement(site[cut:])
107
+ for a in by_suffix.get(left, ()):
108
+ for b in by_prefix.get(right, ()):
109
+ if len(a) + len(b) <= max_len:
110
+ catalysts[lig_at[(index[a], index[b])]].add(x)
111
+
112
+ return BinaryPolymerNetwork(
113
+ molecules=molecules, food=food, reactions=reactions,
114
+ catalysts=tuple(frozenset(c) for c in catalysts),
115
+ p=p_cat, max_len=max_len, food_len=food_len, directions=directions)
@@ -0,0 +1,139 @@
1
+ """Serra & Villani's FIRING-DISK construction — a chemistry grown, not enumerated.
2
+
3
+ Reproduced from *Entropy* 28(2), 184 (2026), §2.2. The contrast with every other generator
4
+ here is the point:
5
+
6
+ * `binary_polymer` and `complementary_polymer` **enumerate** every string up to `max_len`
7
+ and sprinkle catalysis over the result. A species exists because it is short enough.
8
+ * the firing disk **grows** outward from a small seed. A species exists only if some
9
+ reaction in the network actually **makes** it.
10
+
11
+ So a firing-disk chemistry is closed under its own production by construction, where an
12
+ enumerated one is full of species nothing can reach. That difference plausibly matters a
13
+ great deal for protocell viability, because unreachable species dilute the material without
14
+ contributing to it -- which is why this exists.
15
+
16
+ The loop is theirs: seed a disk of short polymers, designate some as cleavage or
17
+ condensation catalysts, find every reaction the current catalysts enable among the current
18
+ species, run them, and give each **newly generated** species a chance `p_cat` of being a
19
+ catalyst itself -- iterating "until there are no more new reactions or new species to add,
20
+ or some termination condition is met."
21
+
22
+ ⚠ **Food is taken to be the firing disk.** The paper does not say what plays the role of
23
+ food when such a chemistry is embedded in a protocell, and the disk is the only externally
24
+ given set, so that is the reading used here -- an assumption, not a quotation.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import numpy as np
29
+
30
+ from rafkit.binary_polymer import BinaryPolymerNetwork, _strings
31
+ from rafkit.complementary_polymer import complement
32
+
33
+
34
+ def _site(mol, site_min, site_max, rng):
35
+ width = int(rng.integers(site_min, min(site_max, len(mol)) + 1))
36
+ start = int(rng.integers(0, len(mol) - width + 1))
37
+ site = mol[start:start + width]
38
+ return site, int(rng.integers(1, width))
39
+
40
+
41
+ def firing_disk_polymer(disk_size: int = 24, disk_len: int = 4, max_len: int = 10,
42
+ p_cat: float = 0.05, p_cleave: float = 0.5,
43
+ n_cleave_cat: int = 2, n_cond_cat: int = 2,
44
+ site_min: int = 3, site_max: int = 4,
45
+ max_species: int = 4000, max_rounds: int = 200,
46
+ rng: np.random.Generator | None = None) -> BinaryPolymerNetwork:
47
+ """Grow one C-chemistry outward from a firing disk.
48
+
49
+ Defaults follow the paper's Figure 3 ensemble where stated: 24 initial species of
50
+ length <= 4, ``Lmax = 10``, ``p_cat = 0.05``, ``p_cleave = 0.5``, active sites uniform
51
+ on 3..4. ``n_cleave_cat`` / ``n_cond_cat`` are their ``NCLini`` / ``NCDini``, whose
52
+ values Figure 3 does not state.
53
+
54
+ ``max_species`` and ``max_rounds`` are the "termination condition" their text leaves
55
+ open; both are reported through the returned network's size rather than raising, but a
56
+ run that hits ``max_species`` is a **truncated** chemistry and should be treated as
57
+ such.
58
+ """
59
+ if not 1 <= site_min <= site_max:
60
+ raise ValueError(f"need 1 <= site_min <= site_max, got {site_min}, {site_max}")
61
+ for name, v in (("p_cat", p_cat), ("p_cleave", p_cleave)):
62
+ if not 0.0 <= v <= 1.0:
63
+ raise ValueError(f"{name} is a probability, got {v}")
64
+ rng = rng or np.random.default_rng()
65
+
66
+ pool = [m for m in _strings(disk_len) if len(m) >= 1]
67
+ disk = list(rng.choice(pool, size=min(disk_size, len(pool)), replace=False))
68
+ species: set[str] = set(disk)
69
+
70
+ # catalyst -> (site, cut, is_cleaver); seeded per NCLini / NCDini, then grown by p_cat
71
+ cats: dict[str, tuple[str, int, bool]] = {}
72
+ eligible = [m for m in disk if len(m) >= site_min]
73
+ rng.shuffle(eligible)
74
+ for m in eligible[:n_cleave_cat]:
75
+ s, c = _site(m, site_min, site_max, rng); cats[m] = (s, c, True)
76
+ for m in eligible[n_cleave_cat:n_cleave_cat + n_cond_cat]:
77
+ s, c = _site(m, site_min, site_max, rng); cats[m] = (s, c, False)
78
+
79
+ # split -> set of catalysts, for each direction; a "split" is the pair (a, b) of ab
80
+ lig: dict[tuple[str, str], set[str]] = {}
81
+ cle: dict[tuple[str, str], set[str]] = {}
82
+
83
+ for _ in range(max_rounds):
84
+ fresh: set[str] = set()
85
+ by_pre: dict[str, list[str]] = {}
86
+ by_suf: dict[str, list[str]] = {}
87
+ for m in species:
88
+ for k in range(1, len(m) + 1):
89
+ by_pre.setdefault(m[:k], []).append(m)
90
+ by_suf.setdefault(m[-k:], []).append(m)
91
+ for cat, (site, cut, is_cleaver) in list(cats.items()):
92
+ if is_cleaver:
93
+ target = complement(site)
94
+ for m in list(species):
95
+ pos = m.find(target)
96
+ while pos != -1:
97
+ k = pos + cut
98
+ if 0 < k < len(m):
99
+ a, b = m[:k], m[k:]
100
+ cle.setdefault((a, b), set()).add(cat)
101
+ fresh.update({a, b} - species)
102
+ pos = m.find(target, pos + 1)
103
+ else:
104
+ left, right = complement(site[:cut]), complement(site[cut:])
105
+ for a in by_suf.get(left, ()):
106
+ for b in by_pre.get(right, ()):
107
+ if len(a) + len(b) <= max_len:
108
+ lig.setdefault((a, b), set()).add(cat)
109
+ if a + b not in species:
110
+ fresh.add(a + b)
111
+ if not fresh:
112
+ break
113
+ for m in sorted(fresh):
114
+ if len(species) >= max_species:
115
+ break
116
+ species.add(m)
117
+ if len(m) >= site_min and rng.random() < p_cat:
118
+ s, c = _site(m, site_min, site_max, rng)
119
+ cats[m] = (s, c, rng.random() < p_cleave)
120
+
121
+ # Emit in the paired layout the rest of the library expects: every split that appears
122
+ # in either direction becomes one ligation and one cleavage, each carrying only the
123
+ # catalysts actually assigned to that direction.
124
+ splits = sorted(set(lig) | set(cle))
125
+ splits = [(a, b) for a, b in splits if a in species and b in species
126
+ and a + b in species]
127
+ molecules = tuple(sorted(species, key=lambda m: (len(m), m)))
128
+ index = {m: i for i, m in enumerate(molecules)}
129
+ ligations = tuple((index[a], index[b], index[a + b]) for a, b in splits)
130
+ reactions = ligations + ligations
131
+ directions = (1,) * len(ligations) + (-1,) * len(ligations)
132
+ catalysts = tuple(frozenset(index[c] for c in lig.get(s, ()) if c in index)
133
+ for s in splits) + \
134
+ tuple(frozenset(index[c] for c in cle.get(s, ()) if c in index)
135
+ for s in splits)
136
+ food = frozenset(index[m] for m in disk if m in index)
137
+ return BinaryPolymerNetwork(
138
+ molecules=molecules, food=food, reactions=reactions, catalysts=catalysts,
139
+ p=p_cat, max_len=max_len, food_len=disk_len, directions=directions)
@@ -0,0 +1,139 @@
1
+ """Export to PNML, the ISO/IEC 15909-2 Petri net interchange format.
2
+
3
+ A catalytic reaction network *is* a Petri net: species are places, reactions are
4
+ transitions, molecule counts are tokens. Exporting makes these networks readable by the
5
+ Petri net ecosystem -- editors, model checkers, and the unfolding tools that compute the
6
+ causal DAG of a run.
7
+
8
+ Three things in the RAF model have no direct Place/Transition equivalent, and each is
9
+ handled explicitly rather than silently:
10
+
11
+ **Catalysis becomes a self-loop.** P/T nets have no read arc, so a catalyst is written
12
+ as a pair of arcs, place -> transition -> place: the token is consumed and immediately
13
+ returned, which is behaviourally what a catalyst does.
14
+
15
+ **Alternative catalyst sets become separate transitions.** A transition's preset is a
16
+ conjunction, so it cannot express "either of these sets". A reaction with `k` catalyst
17
+ sets is emitted as `k` transitions sharing reactants and products, each with self-loops
18
+ for one set. They are named ``r1``, ``r1#2``, ``r1#3`` … so the grouping survives.
19
+
20
+ **Food becomes a source transition.** RAF food is inexhaustible, which no initial
21
+ marking expresses: a marking of *n* deadlocks after *n* uses. Each food place therefore
22
+ gets a source transition with an empty preset, which can always fire.
23
+
24
+ Not expressible, and reported rather than dropped:
25
+
26
+ * **Inhibition** has no standard P/T representation. Inhibitor arcs exist in extended
27
+ formalisms but not in the ``ptnet`` grammar, so they are written as a
28
+ ``<toolspecific>`` annotation. A tool that ignores it will read a net **without** the
29
+ inhibition, which is a *different system* -- so `to_pnml` says so in a comment.
30
+ * **χ = ∅** ("must be catalysed, and nothing does") is a RAF-theoretic condition, not a
31
+ Petri net one. Such reactions can never fire in any RAF, and emitting them as
32
+ unconstrained transitions would make them freely fireable -- the opposite. They are
33
+ omitted, and counted in the header comment.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import xml.etree.ElementTree as ET
38
+ from pathlib import Path
39
+
40
+ PNML_NS = "http://www.pnml.org/version-2009/grammar/pnml"
41
+ PTNET = "http://www.pnml.org/version-2009/grammar/ptnet"
42
+
43
+
44
+ def _el(parent, tag, **attrs):
45
+ return ET.SubElement(parent, tag, {k: str(v) for k, v in attrs.items()})
46
+
47
+
48
+ def _named(parent, text):
49
+ name = _el(parent, "name")
50
+ _el(name, "text").text = text
51
+ return name
52
+
53
+
54
+ def to_pnml(net, net_id: str = "rafkit", food_sources: bool = True) -> str:
55
+ """Serialise a network to PNML text.
56
+
57
+ `food_sources` adds a source transition per food place so that food is
58
+ inexhaustible, matching RAF semantics. Turn it off to get a net whose food is
59
+ limited to its initial marking -- which is a different system, and will deadlock.
60
+ """
61
+ inhibitors = getattr(net, "inhibitors", ()) or ((),) * net.n_reactions
62
+ names = getattr(net, "names", None) or [f"r{i + 1}" for i in range(net.n_reactions)]
63
+
64
+ root = ET.Element("pnml", {"xmlns": PNML_NS})
65
+ pnet = _el(root, "net", id=net_id, type=PTNET)
66
+ _named(pnet, net_id)
67
+ page = _el(pnet, "page", id="page1")
68
+
69
+ for m, mol in enumerate(net.molecules):
70
+ place = _el(page, "place", id=f"p{m}")
71
+ _named(place, mol)
72
+ marking = _el(place, "initialMarking")
73
+ _el(marking, "text").text = "1" if m in net.food else "0"
74
+
75
+ arc_id = 0
76
+
77
+ def arc(src, dst):
78
+ nonlocal arc_id
79
+ arc_id += 1
80
+ a = _el(page, "arc", id=f"a{arc_id}", source=src, target=dst)
81
+ insc = _el(a, "inscription")
82
+ _el(insc, "text").text = "1"
83
+
84
+ skipped = 0
85
+ for r in range(net.n_reactions):
86
+ chi = net.catalysts[r]
87
+ if not chi:
88
+ skipped += 1 # must be catalysed, nothing does: cannot ever fire
89
+ continue
90
+ for k, catalyst_set in enumerate(sorted(chi, key=lambda u: sorted(u))):
91
+ tid = f"t{r}" if k == 0 else f"t{r}_{k}"
92
+ label = names[r] if k == 0 else f"{names[r]}#{k + 1}"
93
+ trans = _el(page, "transition", id=tid)
94
+ _named(trans, label)
95
+ if inhibitors[r]:
96
+ ts = _el(trans, "toolspecific", tool="rafkit", version="1")
97
+ _el(ts, "inhibitors").text = " ".join(
98
+ sorted(net.molecules[x] for x in inhibitors[r]))
99
+ for x in net.reactants(r):
100
+ arc(f"p{x}", tid)
101
+ for x in net.products(r):
102
+ arc(tid, f"p{x}")
103
+ for c in catalyst_set: # read arc, as a self-loop
104
+ arc(f"p{c}", tid)
105
+ arc(tid, f"p{c}")
106
+
107
+ if food_sources:
108
+ for m in sorted(net.food):
109
+ tid = f"src{m}"
110
+ trans = _el(page, "transition", id=tid)
111
+ _named(trans, f"source:{net.molecules[m]}")
112
+ arc(tid, f"p{m}")
113
+
114
+ ET.indent(root, space=" ")
115
+ body = ET.tostring(root, encoding="unicode")
116
+ notes = [f"Generated by rafkit. {net.n_molecules} species, "
117
+ f"{net.n_reactions} reactions."]
118
+ if skipped:
119
+ notes.append(f"{skipped} reaction(s) omitted: they require a catalyst and "
120
+ f"nothing catalyses them, so they can never fire.")
121
+ if any(inhibitors):
122
+ notes.append("Inhibition is recorded in a toolspecific element only; the "
123
+ "ptnet grammar has no inhibitor arc. A tool that ignores it "
124
+ "reads a DIFFERENT system, without the inhibition.")
125
+ if food_sources:
126
+ notes.append("Food places have source transitions, so food is inexhaustible.")
127
+ # A comment may not contain "--" anywhere, nor end with "-". Sanitising here
128
+ # rather than trusting the call sites: this text is prepended as raw XML, so it
129
+ # bypasses ElementTree's escaping entirely, and an unescaped double hyphen makes
130
+ # the whole document unparseable. Found by an independent PNML reader, not by us.
131
+ safe = [n.replace("--", "\u2014").rstrip("-") for n in notes]
132
+ header = "<?xml version='1.0' encoding='UTF-8'?>\n<!--\n " + \
133
+ "\n ".join(safe) + "\n-->\n"
134
+ return header + body + "\n"
135
+
136
+
137
+ def write_pnml(net, path: str | Path, **kwargs) -> None:
138
+ """Write a network to a PNML file."""
139
+ Path(path).write_text(to_pnml(net, **kwargs))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Autocatalytic (RAF) sets in catalytic reaction networks: maximal RAFs, irreducible cores, and Kauffman binary polymer models.
5
5
  Author: James P. Galasyn, Claude Théodore
6
6
  License: MIT
@@ -30,8 +30,8 @@ Dynamic: license-file
30
30
 
31
31
  [![CI](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JimGalasyn/rafkit/actions/workflows/ci.yml)
32
32
  [![codecov](https://codecov.io/gh/JimGalasyn/rafkit/branch/main/graph/badge.svg)](https://codecov.io/gh/JimGalasyn/rafkit)
33
- [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
34
- [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
33
+ [![PyPI](https://img.shields.io/pypi/v/rafkit.svg?cacheSeconds=3600)](https://pypi.org/project/rafkit/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg?cacheSeconds=3600)](https://pypi.org/project/rafkit/)
35
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
36
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](https://doi.org/10.5281/zenodo.21954795)
37
37
 
@@ -115,12 +115,33 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
115
115
  | `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
116
116
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
117
117
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
118
+ | `to_pnml` / `write_pnml` | PNML export (ISO/IEC 15909-2) for the Petri net ecosystem |
118
119
  | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
119
120
  | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
120
121
 
121
122
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
122
123
  is subtly wrong produces plausible numbers rather than errors.
123
124
 
125
+ ## A reaction network is a Petri net
126
+
127
+ Species are places, reactions are transitions, molecule counts are tokens. `write_pnml`
128
+ exports to PNML (ISO/IEC 15909-2), so these networks open in Petri net editors, model
129
+ checkers, and the unfolding tools that compute the causal structure of a run.
130
+
131
+ Three things need care, and each is explicit rather than silent:
132
+
133
+ - **Catalysis becomes a self-loop.** P/T nets have no read arc, so a catalyst is a pair
134
+ of arcs, consuming the token and returning it.
135
+ - **Alternative catalyst sets become separate transitions**, named `r1`, `r1#2`, …, since
136
+ a transition's preset is a conjunction and cannot express "either set".
137
+ - **Food gets source transitions**, because RAF food is inexhaustible and no initial
138
+ marking expresses that — a marking of *n* deadlocks after *n* uses.
139
+
140
+ Inhibition has no `ptnet` representation and is written as a `toolspecific` annotation,
141
+ with a warning in the file: a reader that ignores it gets a *different system*.
142
+ Reactions requiring a catalyst that nothing provides are omitted and counted, since
143
+ emitting them unconstrained would make them freely fireable — the opposite of the intent.
144
+
124
145
  ## Catalysis is a relation, not a list
125
146
 
126
147
  `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
@@ -4,19 +4,25 @@ pyproject.toml
4
4
  src/rafkit/__init__.py
5
5
  src/rafkit/binary_polymer.py
6
6
  src/rafkit/catalysis.py
7
+ src/rafkit/complementary_polymer.py
7
8
  src/rafkit/crs.py
9
+ src/rafkit/firing_disk.py
8
10
  src/rafkit/gillespie.py
9
11
  src/rafkit/inhibition.py
10
12
  src/rafkit/network.py
13
+ src/rafkit/pnml.py
11
14
  src/rafkit/raf.py
12
15
  src/rafkit.egg-info/PKG-INFO
13
16
  src/rafkit.egg-info/SOURCES.txt
14
17
  src/rafkit.egg-info/dependency_links.txt
15
18
  src/rafkit.egg-info/requires.txt
16
19
  src/rafkit.egg-info/top_level.txt
20
+ tests/test_complementary_polymer.py
17
21
  tests/test_crs.py
22
+ tests/test_firing_disk.py
18
23
  tests/test_gillespie.py
19
24
  tests/test_inhibition.py
25
+ tests/test_pnml.py
20
26
  tests/test_published_examples.py
21
27
  tests/test_raf.py
22
28
  tests/test_seeding.py
@@ -0,0 +1,115 @@
1
+ """C-BPM — Serra & Villani, *Entropy* 28(2), 184 (2026), §2.2.
2
+
3
+ The load-bearing test is `test_every_catalysed_reaction_has_a_matching_site`: it re-derives
4
+ the complementarity rule from the catalyst's own string, independently of the code that
5
+ assigned it. Everything else is a property from the paper.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from rafkit import binary_polymer, complement, complementary_polymer, max_raf
13
+
14
+
15
+ def _catalysts(net):
16
+ """Flatten the catalyst relation: each entry is a set of conjunctive GROUPS."""
17
+ return {x for entry in net.catalysts for group in entry for x in group}
18
+
19
+
20
+ def _edges(net):
21
+ return sum(len(group) for entry in net.catalysts for group in entry)
22
+
23
+
24
+ def _sites(mol, site_min, site_max):
25
+ for w in range(site_min, min(site_max, len(mol)) + 1):
26
+ for i in range(len(mol) - w + 1):
27
+ yield mol[i:i + w]
28
+
29
+
30
+ class TestComplement:
31
+ def test_flips_bits_and_is_an_involution(self):
32
+ assert complement("100110") == "011001" # the paper's own example
33
+ assert complement(complement("10110")) == "10110"
34
+
35
+
36
+ class TestConstruction:
37
+ def test_reaction_set_matches_the_k_model(self):
38
+ """C-BPM changes WHICH catalyst acts, not what reactions exist."""
39
+ c = complementary_polymer(max_len=6, rng=np.random.default_rng(0))
40
+ k = binary_polymer(max_len=6, cleavage=True, rng=np.random.default_rng(0))
41
+ assert c.molecules == k.molecules
42
+ assert c.reactions == k.reactions
43
+ assert c.directions == k.directions
44
+
45
+ def test_no_catalysis_when_p_cat_zero(self):
46
+ c = complementary_polymer(max_len=6, p_cat=0.0, rng=np.random.default_rng(0))
47
+ assert all(not s for s in c.catalysts)
48
+ assert c.catalysis_level == 0.0
49
+
50
+ def test_only_polymers_at_least_as_long_as_a_site_catalyse(self):
51
+ """Falls out of the construction; the paper states it as a consequence."""
52
+ c = complementary_polymer(max_len=7, p_cat=1.0, site_min=4, site_max=4,
53
+ rng=np.random.default_rng(1))
54
+ for x in _catalysts(c):
55
+ assert len(c.molecules[x]) >= 4
56
+
57
+ def test_deterministic_for_a_seed(self):
58
+ a = complementary_polymer(max_len=6, rng=np.random.default_rng(5))
59
+ b = complementary_polymer(max_len=6, rng=np.random.default_rng(5))
60
+ assert a.catalysts == b.catalysts
61
+
62
+ def test_every_catalysed_reaction_has_a_matching_site(self):
63
+ """Re-derive the rule from the catalyst's string, not from the assigning code.
64
+
65
+ For a condensation the catalyst must hold a site whose first part complements the
66
+ first reactant's SUFFIX and whose second part complements the second's PREFIX; for
67
+ a cleavage, a site complementary to a segment spanning the cut in the substrate.
68
+ """
69
+ smin, smax = 3, 4
70
+ c = complementary_polymer(max_len=7, p_cat=0.3, site_min=smin, site_max=smax,
71
+ rng=np.random.default_rng(3))
72
+ checked = 0
73
+ for r, cats in enumerate(c.catalysts):
74
+ for group in cats:
75
+ for x in group:
76
+ cat = c.molecules[x]
77
+ a, b, ab = (c.molecules[i] for i in c.reactions[r])
78
+ if c.directions[r] > 0: # condensation a + b -> ab
79
+ ok = any(a.endswith(complement(s[:k])) and
80
+ b.startswith(complement(s[k:]))
81
+ for s in _sites(cat, smin, smax)
82
+ for k in range(1, len(s)))
83
+ else: # cleavage ab -> a + b
84
+ cut = len(a)
85
+ ok = any(complement(s) in
86
+ {ab[cut - k: cut - k + len(s)] for k in range(1, len(s))}
87
+ for s in _sites(cat, smin, smax))
88
+ assert ok, f"{cat!r} catalyses {a!r}+{b!r}->{ab!r} with no matching site"
89
+ checked += 1
90
+ assert checked > 100, f"only {checked} assignments exercised"
91
+
92
+
93
+ class TestPaperSignature:
94
+ def test_far_fewer_catalysts_each_doing_far_more(self):
95
+ """Their headline contrast: ~400 reactions per C-catalyst against ~20 for K."""
96
+ c = complementary_polymer(max_len=8, p_cat=0.05, rng=np.random.default_rng(0))
97
+ k = binary_polymer(max_len=8, p=0.003, cleavage=True, rng=np.random.default_rng(0))
98
+ per = lambda n: _edges(n) / max(len(_catalysts(n)), 1)
99
+ assert per(c) > 5 * per(k)
100
+ assert len(_catalysts(c)) < len(_catalysts(k)) / 5
101
+
102
+ def test_hosts_a_raf(self):
103
+ c = complementary_polymer(max_len=8, p_cat=0.05, rng=np.random.default_rng(0))
104
+ assert len(max_raf(c).reactions) > 0
105
+
106
+
107
+ class TestValidation:
108
+ @pytest.mark.parametrize("kw", [dict(p_cat=1.5), dict(p_cleave=-0.1)])
109
+ def test_probabilities_checked(self, kw):
110
+ with pytest.raises(ValueError, match="is a probability"):
111
+ complementary_polymer(max_len=5, **kw)
112
+
113
+ def test_site_bounds_checked(self):
114
+ with pytest.raises(ValueError, match="site_min <= site_max"):
115
+ complementary_polymer(max_len=5, site_min=4, site_max=3)
@@ -0,0 +1,87 @@
1
+ """The firing-disk construction — Serra & Villani, *Entropy* 28(2), 184 (2026), §2.2.
2
+
3
+ The load-bearing property is `test_every_species_is_produced_or_seed`: a grown chemistry
4
+ is closed under its own production, which is exactly what distinguishes it from an
5
+ enumerated one and is the reason it exists here.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from rafkit import binary_polymer, firing_disk_polymer, max_raf
13
+
14
+
15
+ class TestGrowth:
16
+ def test_every_species_is_produced_or_seed(self):
17
+ """No unreachable species: each molecule is food, or some reaction makes it."""
18
+ net = firing_disk_polymer(rng=np.random.default_rng(0))
19
+ made = {m for r in range(net.n_reactions) for m in net.products(r)}
20
+ for x in range(net.n_molecules):
21
+ assert x in net.food or x in made, f"{net.molecules[x]!r} unreachable"
22
+
23
+ def test_disk_is_the_food_set(self):
24
+ net = firing_disk_polymer(disk_size=16, disk_len=4,
25
+ rng=np.random.default_rng(1))
26
+ assert len(net.food) == 16
27
+ assert all(len(net.molecules[x]) <= 4 for x in net.food)
28
+
29
+ def test_respects_max_len(self):
30
+ net = firing_disk_polymer(max_len=6, rng=np.random.default_rng(2))
31
+ assert all(len(m) <= 6 for m in net.molecules)
32
+
33
+ def test_no_seed_catalysts_means_no_growth(self):
34
+ """Nothing can fire, so the chemistry is the disk and there are no reactions."""
35
+ net = firing_disk_polymer(n_cleave_cat=0, n_cond_cat=0, p_cat=0.0,
36
+ disk_size=20, rng=np.random.default_rng(3))
37
+ assert net.n_reactions == 0
38
+ assert net.n_molecules == 20
39
+
40
+ def test_deterministic_for_a_seed(self):
41
+ a = firing_disk_polymer(max_len=8, rng=np.random.default_rng(7))
42
+ b = firing_disk_polymer(max_len=8, rng=np.random.default_rng(7))
43
+ assert a.molecules == b.molecules and a.catalysts == b.catalysts
44
+
45
+ def test_reactions_are_consistent_splits(self):
46
+ net = firing_disk_polymer(max_len=8, rng=np.random.default_rng(4))
47
+ for a, b, ab in net.reactions:
48
+ assert net.molecules[a] + net.molecules[b] == net.molecules[ab]
49
+
50
+
51
+ class TestPaperEnsemble:
52
+ """Figure 3's ensemble: ~2000 species, ~40,000 reactions, ~100 catalysts."""
53
+
54
+ @pytest.mark.parametrize("seed", range(3))
55
+ def test_reaches_the_published_scale(self, seed):
56
+ """Across seeds, not just the lucky one -- these are ensemble claims."""
57
+ net = firing_disk_polymer(rng=np.random.default_rng(seed)) # paper defaults
58
+ assert 1500 <= net.n_molecules <= 2100
59
+ assert 20_000 <= net.n_reactions <= 45_000
60
+ n_cat = len({x for e in net.catalysts for g in e for x in g})
61
+ assert 40 <= n_cat <= 200, n_cat
62
+
63
+ @pytest.mark.parametrize("seed", range(3))
64
+ def test_raf_is_nearly_the_whole_chemistry(self, seed):
65
+ """Their large C-chemistries host 'a RAF often as large as the entire chemistry'."""
66
+ net = firing_disk_polymer(rng=np.random.default_rng(seed))
67
+ assert len(max_raf(net).reactions) > 0.8 * net.n_reactions
68
+
69
+ @pytest.mark.parametrize("seed", range(3))
70
+ def test_far_fewer_catalysts_than_a_k_chemistry_of_the_same_size(self, seed):
71
+ """Figure 3b's contrast: a C-catalyst drives many more reactions than a K one."""
72
+ c = firing_disk_polymer(rng=np.random.default_rng(seed))
73
+ k = binary_polymer(max_len=8, food_len=2, p=0.003, cleavage=True,
74
+ rng=np.random.default_rng(seed))
75
+ per = lambda n: (sum(len(g) for e in n.catalysts for g in e)
76
+ / max(len({x for e in n.catalysts for g in e for x in g}), 1))
77
+ assert per(c) > 5 * per(k)
78
+
79
+
80
+ class TestValidation:
81
+ def test_probabilities_checked(self):
82
+ with pytest.raises(ValueError, match="is a probability"):
83
+ firing_disk_polymer(p_cat=2.0)
84
+
85
+ def test_site_bounds_checked(self):
86
+ with pytest.raises(ValueError, match="site_min <= site_max"):
87
+ firing_disk_polymer(site_min=5, site_max=3)
@@ -237,3 +237,62 @@ class TestDownstreamUnderInhibition:
237
237
  u = max_urafs(net, inh, reactions=raf)[0]
238
238
  census = irrraf_census(net, u, n_samples=5, rng=np.random.default_rng(0))
239
239
  assert census["n_distinct"] >= 1
240
+
241
+
242
+ # --- generated inhibition: binary_polymer's q / n_inhibitors -----------------------
243
+
244
+ class TestGeneratedInhibition:
245
+ """`binary_polymer(q=..., n_inhibitors=...)` -- Hordijk & Steel (2012) Part II."""
246
+
247
+ def test_q_zero_leaves_network_uninhibited(self):
248
+ net = binary_polymer(max_len=5, food_len=2, p=0.01,
249
+ rng=np.random.default_rng(0), cleavage=True)
250
+ assert all(not i for i in net.inhibitors)
251
+ assert net.n_inhibiting_molecules == 0
252
+ assert classes_from_inhibitors(net) == ()
253
+
254
+ def test_q_does_not_disturb_catalysis(self):
255
+ """Adding inhibition must not change the chemistry it is added to.
256
+
257
+ Every result recorded before inhibition existed was measured at q=0; if the
258
+ catalysis draw shifted, those numbers would silently stop reproducing.
259
+ """
260
+ kw = dict(max_len=5, food_len=2, p=0.01, cleavage=True)
261
+ a = binary_polymer(rng=np.random.default_rng(3), **kw)
262
+ b = binary_polymer(rng=np.random.default_rng(3), q=0.05, n_inhibitors=4, **kw)
263
+ assert a.catalysts == b.catalysts
264
+ assert a.reactions == b.reactions
265
+
266
+ def test_n_inhibitors_caps_k_exactly(self):
267
+ """`k` is the exponent in max_urafs' 2**k, so the cap must be exact, not a mean."""
268
+ for cap in (1, 3, 8):
269
+ net = binary_polymer(max_len=5, food_len=2, p=0.01, q=0.3,
270
+ n_inhibitors=cap, rng=np.random.default_rng(1),
271
+ cleavage=True)
272
+ assert net.n_inhibiting_molecules <= cap
273
+ assert len(classes_from_inhibitors(net)) == net.n_inhibiting_molecules
274
+
275
+ def test_paired_directions_share_inhibitors(self):
276
+ """A reversible cleavage-ligation pair is ONE unit, for inhibition as for catalysis."""
277
+ net = binary_polymer(max_len=5, food_len=2, p=0.01, q=0.1, n_inhibitors=5,
278
+ rng=np.random.default_rng(2), cleavage=True,
279
+ paired_catalysis=True)
280
+ half = net.n_reactions // 2
281
+ assert net.inhibitors[:half] == net.inhibitors[half:]
282
+
283
+ def test_inhibition_can_only_shrink_the_raf(self):
284
+ """u-RAFs are RAFs, so no u-RAF may exceed the uninhibited maximal RAF."""
285
+ kw = dict(max_len=5, food_len=2, p=0.02, cleavage=True)
286
+ base = binary_polymer(rng=np.random.default_rng(5), **kw)
287
+ inh = binary_polymer(rng=np.random.default_rng(5), q=0.02, n_inhibitors=6, **kw)
288
+ ceiling = len(max_raf(base).reactions)
289
+ for u in max_urafs(inh):
290
+ assert len(u) <= ceiling
291
+
292
+ def test_q_rejects_non_probability(self):
293
+ with pytest.raises(ValueError, match="q is a probability"):
294
+ binary_polymer(max_len=4, q=1.5)
295
+
296
+ def test_negative_n_inhibitors_rejected(self):
297
+ with pytest.raises(ValueError, match="n_inhibitors must be non-negative"):
298
+ binary_polymer(max_len=4, q=0.1, n_inhibitors=-1)
@@ -0,0 +1,124 @@
1
+ """PNML export.
2
+
3
+ A catalytic reaction network is a Petri net, and exporting one makes it readable by
4
+ that ecosystem. Three parts of the RAF model have no direct Place/Transition
5
+ equivalent, and the tests that matter are the ones checking each is handled explicitly:
6
+ catalysts become self-loops, alternative catalyst sets become separate transitions, and
7
+ food gets source transitions so it cannot run out.
8
+
9
+ Validated during development against **pm4py**, an independent PNML reader, which is
10
+ how the double-hyphen bug below was found. pm4py is AGPL and is deliberately not a
11
+ dependency; these tests use the standard library only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import xml.etree.ElementTree as ET
16
+
17
+ import pytest
18
+
19
+ from rafkit import parse_crs
20
+ from rafkit.pnml import to_pnml, write_pnml
21
+
22
+ NS = {"p": "http://www.pnml.org/version-2009/grammar/pnml"}
23
+
24
+
25
+ def _parse(net, **kw):
26
+ return ET.fromstring(to_pnml(net, **kw))
27
+
28
+
29
+ def _ids(root, tag):
30
+ return [e.get("id") for e in root.iterfind(f".//p:{tag}", NS)]
31
+
32
+
33
+ def _labels(root, tag):
34
+ out = []
35
+ for e in root.iterfind(f".//p:{tag}", NS):
36
+ t = e.find("p:name/p:text", NS)
37
+ out.append(t.text if t is not None else None)
38
+ return out
39
+
40
+
41
+ class TestWellFormedness:
42
+ def test_output_parses(self):
43
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
44
+ assert _parse(net).tag.endswith("pnml")
45
+
46
+ def test_header_comment_has_no_double_hyphen(self):
47
+ """Regression. The header is prepended as raw XML, so it bypasses
48
+ ElementTree's escaping entirely, and `--` inside a comment makes the whole
49
+ document unparseable. An independent PNML reader caught this; the export
50
+ itself was silent."""
51
+ net = parse_crs("Food: a, b\nr1 : a + b [c] {z} => c\nr2 : a [] => q\n")
52
+ text = to_pnml(net)
53
+ header = text[:text.index("<pnml")]
54
+ assert "--" not in header.replace("<!--", "").replace("-->", "")
55
+ ET.fromstring(text) # would raise if it were not well-formed
56
+
57
+ def test_every_arc_endpoint_exists(self):
58
+ net = parse_crs("Food: a, b\nr1 : a + b [{c,d},e] => c\nr2 : a + c [c] => d\n")
59
+ root = _parse(net)
60
+ nodes = set(_ids(root, "place")) | set(_ids(root, "transition"))
61
+ for arc in root.iterfind(".//p:arc", NS):
62
+ assert arc.get("source") in nodes
63
+ assert arc.get("target") in nodes
64
+
65
+
66
+ class TestMapping:
67
+ def test_places_are_molecules(self):
68
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
69
+ assert set(_labels(_parse(net), "place")) == set(net.molecules)
70
+
71
+ def test_alternative_catalyst_sets_become_separate_transitions(self):
72
+ """A transition's preset is a conjunction, so it cannot express "either set"."""
73
+ net = parse_crs("Food: a, b\nr1 : a + b [{c,d},e] => c\n")
74
+ labels = [l for l in _labels(_parse(net), "transition")
75
+ if not l.startswith("source:")]
76
+ assert sorted(labels) == ["r1", "r1#2"]
77
+
78
+ def test_a_catalyst_becomes_a_self_loop(self):
79
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
80
+ root = _parse(net)
81
+ place = {l: i for i, l in zip(_ids(root, "place"), _labels(root, "place"))}
82
+ arcs = {(a.get("source"), a.get("target"))
83
+ for a in root.iterfind(".//p:arc", NS)}
84
+ # c catalyses r1: both directions must be present.
85
+ assert (place["c"], "t0") in arcs and ("t0", place["c"]) in arcs
86
+
87
+ def test_food_gets_source_transitions_so_it_cannot_run_out(self):
88
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
89
+ labels = _labels(_parse(net), "transition")
90
+ assert "source:a" in labels and "source:b" in labels
91
+
92
+ def test_food_sources_can_be_turned_off(self):
93
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
94
+ labels = _labels(_parse(net, food_sources=False), "transition")
95
+ assert not any(l.startswith("source:") for l in labels)
96
+
97
+
98
+ class TestWhatCannotBeExpressed:
99
+ def test_reactions_that_can_never_fire_are_omitted_and_counted(self):
100
+ """chi = empty means "must be catalysed, and nothing does". Emitting it as an
101
+ unconstrained transition would make it freely fireable, the opposite of the
102
+ intent, so it is dropped and the header says how many."""
103
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\nr2 : a [] => q\n")
104
+ text = to_pnml(net)
105
+ labels = [l for l in _labels(ET.fromstring(text), "transition")
106
+ if not l.startswith("source:")]
107
+ assert labels == ["r1"]
108
+ assert "1 reaction(s) omitted" in text
109
+
110
+ def test_inhibition_is_recorded_and_flagged_as_lossy(self):
111
+ net = parse_crs("Food: a, b\nr1 : a + b [c] {z} => c\n")
112
+ text = to_pnml(net)
113
+ root = ET.fromstring(text)
114
+ ts = root.find(".//p:transition/p:toolspecific", NS)
115
+ assert ts is not None and ts.get("tool") == "rafkit"
116
+ assert ts.find("p:inhibitors", NS).text == "z"
117
+ assert "DIFFERENT system" in text # the warning is not optional
118
+
119
+
120
+ def test_write_pnml_round_trips_through_a_file(tmp_path):
121
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
122
+ path = tmp_path / "net.pnml"
123
+ write_pnml(net, path)
124
+ assert ET.parse(path).getroot().tag.endswith("pnml")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes