rafkit 0.2.0__tar.gz → 0.4.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 (26) hide show
  1. {rafkit-0.2.0/src/rafkit.egg-info → rafkit-0.4.0}/PKG-INFO +54 -3
  2. {rafkit-0.2.0 → rafkit-0.4.0}/README.md +53 -2
  3. {rafkit-0.2.0 → rafkit-0.4.0}/pyproject.toml +1 -1
  4. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/__init__.py +6 -1
  5. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/crs.py +25 -8
  6. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/gillespie.py +10 -0
  7. rafkit-0.4.0/src/rafkit/inhibition.py +114 -0
  8. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/network.py +12 -0
  9. rafkit-0.4.0/src/rafkit/pnml.py +139 -0
  10. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/raf.py +10 -1
  11. {rafkit-0.2.0 → rafkit-0.4.0/src/rafkit.egg-info}/PKG-INFO +54 -3
  12. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit.egg-info/SOURCES.txt +4 -0
  13. rafkit-0.4.0/tests/test_inhibition.py +239 -0
  14. rafkit-0.4.0/tests/test_pnml.py +124 -0
  15. {rafkit-0.2.0 → rafkit-0.4.0}/LICENSE +0 -0
  16. {rafkit-0.2.0 → rafkit-0.4.0}/setup.cfg +0 -0
  17. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/binary_polymer.py +0 -0
  18. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit/catalysis.py +0 -0
  19. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit.egg-info/dependency_links.txt +0 -0
  20. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit.egg-info/requires.txt +0 -0
  21. {rafkit-0.2.0 → rafkit-0.4.0}/src/rafkit.egg-info/top_level.txt +0 -0
  22. {rafkit-0.2.0 → rafkit-0.4.0}/tests/test_crs.py +0 -0
  23. {rafkit-0.2.0 → rafkit-0.4.0}/tests/test_gillespie.py +0 -0
  24. {rafkit-0.2.0 → rafkit-0.4.0}/tests/test_published_examples.py +0 -0
  25. {rafkit-0.2.0 → rafkit-0.4.0}/tests/test_raf.py +0 -0
  26. {rafkit-0.2.0 → rafkit-0.4.0}/tests/test_seeding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.2.0
3
+ Version: 0.4.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
@@ -115,11 +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 |
120
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
119
121
 
120
122
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
121
123
  is subtly wrong produces plausible numbers rather than errors.
122
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
+
123
145
  ## Catalysis is a relation, not a list
124
146
 
125
147
  `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
@@ -141,8 +163,37 @@ last two rows.
141
163
  Constructors still accept a plain iterable of molecules and normalise it, so simple
142
164
  systems stay simple to write.
143
165
 
144
- **Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
145
- molecule can *prevent* a reaction.
166
+ ## Inhibition
167
+
168
+ A molecule can prevent a reaction. `max_urafs` returns the **uninhibited RAFs** of
169
+ Hordijk & Steel (2012), and returns a *collection* rather than one set, because
170
+ inhibition destroys the monotonicity that makes a maximal RAF unique — there is no
171
+ "the" maximal u-RAF.
172
+
173
+ ```
174
+ Food: a, b
175
+ r1 : a + b [a] {d} => c # inhibited by d
176
+ r2 : a + b [b] {c} => d # inhibited by c
177
+ ```
178
+
179
+ Two maximal u-RAFs, `{r1}` and `{r2}`: each is an RAF whose support avoids its own
180
+ inhibitor, and their union is an RAF that fails the uninhibited condition.
181
+
182
+ `simulate` respects inhibition too — an inhibited reaction has propensity zero, so a
183
+ running network can **lose** a subRAF, not merely gain one. See
184
+ `examples/inhibition_dissolution.py`.
185
+
186
+ The set-theoretic tools need no special handling: `sample_irrraf`, `irrraf_census`,
187
+ `core_raf` and `catalytically_reachable` all take a reaction set, and passing a u-RAF
188
+ is correct because the uninhibited property is inherited downward — every sub-RAF of a
189
+ u-RAF is a u-RAF.
190
+
191
+ Deciding whether a u-RAF exists is NP-complete, but the problem is fixed-parameter
192
+ tractable in *k*, the number of inhibition classes — and **k is a property of how you
193
+ encode inhibition, not of the chemistry.** `classes_from_inhibitors` groups by
194
+ inhibiting *molecule*, so *k* is the number of distinct inhibitors rather than the
195
+ number of inhibited reactions, which is the difference between `2^k` being feasible
196
+ and not.
146
197
 
147
198
  ## Notes on irreducible cores
148
199
 
@@ -87,11 +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 |
92
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
91
93
 
92
94
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
93
95
  is subtly wrong produces plausible numbers rather than errors.
94
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
+
95
117
  ## Catalysis is a relation, not a list
96
118
 
97
119
  `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
@@ -113,8 +135,37 @@ last two rows.
113
135
  Constructors still accept a plain iterable of molecules and normalise it, so simple
114
136
  systems stay simple to write.
115
137
 
116
- **Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
117
- molecule can *prevent* a reaction.
138
+ ## Inhibition
139
+
140
+ A molecule can prevent a reaction. `max_urafs` returns the **uninhibited RAFs** of
141
+ Hordijk & Steel (2012), and returns a *collection* rather than one set, because
142
+ inhibition destroys the monotonicity that makes a maximal RAF unique — there is no
143
+ "the" maximal u-RAF.
144
+
145
+ ```
146
+ Food: a, b
147
+ r1 : a + b [a] {d} => c # inhibited by d
148
+ r2 : a + b [b] {c} => d # inhibited by c
149
+ ```
150
+
151
+ Two maximal u-RAFs, `{r1}` and `{r2}`: each is an RAF whose support avoids its own
152
+ inhibitor, and their union is an RAF that fails the uninhibited condition.
153
+
154
+ `simulate` respects inhibition too — an inhibited reaction has propensity zero, so a
155
+ running network can **lose** a subRAF, not merely gain one. See
156
+ `examples/inhibition_dissolution.py`.
157
+
158
+ The set-theoretic tools need no special handling: `sample_irrraf`, `irrraf_census`,
159
+ `core_raf` and `catalytically_reachable` all take a reaction set, and passing a u-RAF
160
+ is correct because the uninhibited property is inherited downward — every sub-RAF of a
161
+ u-RAF is a u-RAF.
162
+
163
+ Deciding whether a u-RAF exists is NP-complete, but the problem is fixed-parameter
164
+ tractable in *k*, the number of inhibition classes — and **k is a property of how you
165
+ encode inhibition, not of the chemistry.** `classes_from_inhibitors` groups by
166
+ inhibiting *molecule*, so *k* is the number of distinct inhibitors rather than the
167
+ number of inhibited reactions, which is the difference between `2^k` being feasible
168
+ and not.
118
169
 
119
170
  ## Notes on irreducible cores
120
171
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rafkit"
3
- version = "0.2.0"
3
+ version = "0.4.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" }
@@ -16,14 +16,17 @@ from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
16
16
  from rafkit.catalysis import catalysing_molecules, is_catalysed, normalise
17
17
  from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
18
18
  from rafkit.gillespie import Trajectory, propensities, simulate
19
+ from rafkit.inhibition import (classes_from_inhibitors, is_uninhibited, is_uraf,
20
+ max_urafs, support)
19
21
  from rafkit.network import ReactionNetwork
22
+ from rafkit.pnml import to_pnml, write_pnml
20
23
  from rafkit.raf import (
21
24
  RafResult, catrenet_strictly_autocatalytic, core_raf, exploitability,
22
25
  has_unique_irraf, irrraf_census, is_food_catalysed, max_raf, max_raf_strict,
23
26
  sample_irrraf,
24
27
  )
25
28
 
26
- __version__ = "0.2.0"
29
+ __version__ = "0.4.0"
27
30
 
28
31
  __all__ = [
29
32
  "BinaryPolymerNetwork", "binary_polymer", "ReactionNetwork",
@@ -32,5 +35,7 @@ __all__ = [
32
35
  "core_raf", "has_unique_irraf",
33
36
  "is_catalysed", "catalysing_molecules", "normalise",
34
37
  "parse_crs", "read_crs", "to_crs", "write_crs",
38
+ "to_pnml", "write_pnml",
35
39
  "simulate", "propensities", "Trajectory",
40
+ "max_urafs", "is_uraf", "is_uninhibited", "support", "classes_from_inhibitors",
36
41
  ]
@@ -20,7 +20,10 @@ Catalysts are alternatives, any one of which suffices. A braced group is a
20
20
  **conjunctive** requirement -- `[{a,d}, e]` means *a and d together*, or *e* -- which
21
21
  is the notation Huson, Xavier & Steel (2024) use. Two edge cases carry meaning and are
22
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
23
+ while `[{}]` means it **may proceed uncatalysed**.
24
+
25
+ A brace group **after** the catalyst bracket lists **inhibitors**, space- or
26
+ comma-separated, following CatReNet: `r1 : a + b [c] {d e} -> x`. A reversible reaction is read as **two** reactions, forward and
24
27
  reverse, sharing a catalyst set -- which is the reading its own generator uses.
25
28
 
26
29
  `X + X -> Y` is written `X ... -> Y`, with the repeated reactant collapsed. Since
@@ -76,44 +79,54 @@ def parse_crs(text: str) -> ReactionNetwork:
76
79
  continue
77
80
  name, body = m.group("name"), m.group("body")
78
81
 
82
+ inhib: list[str] = []
79
83
  cats: list[list[str]] = []
80
84
  if "[" in body:
81
85
  pre, rest = body.split("[", 1)
82
86
  inside, post = rest.split("]", 1)
83
87
  cats = _parse_catalysts(inside)
84
88
  body = pre + " " + post
89
+ # A brace group AFTER the catalysts is the inhibitor list (CatReNet).
90
+ m_inh = re.search(r"\{([^}]*)\}", body)
91
+ if m_inh:
92
+ inhib = [t for t in re.split(r"[,\s]+", m_inh.group(1)) if t]
93
+ body = body[:m_inh.start()] + " " + body[m_inh.end():]
85
94
 
86
95
  arrow = _ARROW.search(body)
87
96
  if not arrow:
88
97
  continue
89
98
  lhs, rhs = body[:arrow.start()], body[arrow.end():]
90
- parsed.append((name, _split_list(lhs), _split_list(rhs), cats,
99
+ parsed.append((name, _split_list(lhs), _split_list(rhs), cats, inhib,
91
100
  arrow.group(1) in ("<->", "<=>")))
92
101
 
93
102
  # Stable molecule indexing: food first, then order of appearance.
94
103
  index: dict[str, int] = {}
95
104
  for n in food_names:
96
105
  index.setdefault(n, len(index))
97
- for _, lhs, rhs, cats, _ in parsed:
98
- for n in (*lhs, *rhs, *(x for g in cats for x in g)):
106
+ for _, lhs, rhs, cats, inhib, _ in parsed:
107
+ for n in (*lhs, *rhs, *(x for g in cats for x in g), *inhib):
99
108
  index.setdefault(n, len(index))
100
109
 
101
- pairs, catalysts, names = [], [], []
102
- for name, lhs, rhs, cats, reversible in parsed:
110
+ pairs, catalysts, names, inhibitors = [], [], [], []
111
+ for name, lhs, rhs, cats, inhib, reversible in parsed:
103
112
  cat = frozenset(frozenset(index[c] for c in g) for g in cats)
113
+ inh = frozenset(index[x] for x in inhib)
104
114
  fwd = (tuple(index[x] for x in lhs), tuple(index[x] for x in rhs))
105
115
  pairs.append(fwd); catalysts.append(cat); names.append(name)
116
+ inhibitors.append(inh)
106
117
  if reversible:
107
118
  pairs.append((fwd[1], fwd[0]))
108
119
  catalysts.append(cat)
109
120
  names.append(f"{name}_rev")
121
+ inhibitors.append(inh)
110
122
 
111
123
  molecules = tuple(sorted(index, key=index.get))
112
124
  return ReactionNetwork(molecules=molecules,
113
125
  food=frozenset(index[n] for n in food_names),
114
126
  reaction_pairs=tuple(pairs),
115
127
  catalysts=tuple(catalysts),
116
- names=tuple(names))
128
+ names=tuple(names),
129
+ inhibitors=tuple(inhibitors))
117
130
 
118
131
 
119
132
  def read_crs(path: str | Path) -> ReactionNetwork:
@@ -140,7 +153,11 @@ def to_crs(net, comment: str = "") -> str:
140
153
  lhs = " + ".join(dict.fromkeys(name(x) for x in net.reactants(r)))
141
154
  rhs = " + ".join(dict.fromkeys(name(x) for x in net.products(r)))
142
155
  cats = _format_catalysts(net, r)
143
- out.append(f"{names[r]} : {lhs} [{cats}] => {rhs}")
156
+ inh = getattr(net, "inhibitors", ())
157
+ inh_s = ""
158
+ if inh and inh[r]:
159
+ inh_s = " {" + " ".join(sorted(name(x) for x in inh[r])) + "}"
160
+ out.append(f"{names[r]} : {lhs} [{cats}]{inh_s} => {rhs}")
144
161
  out.append("")
145
162
  return "\n".join(out)
146
163
 
@@ -15,6 +15,8 @@ reduction factor**, which is the mechanism under test.
15
15
  Conventions, all inherited from the reference rather than chosen here:
16
16
 
17
17
  * a reaction whose catalyst is absent still proceeds, at ``1 / uncatalysed_factor``;
18
+ * a reaction with an **inhibitor present does not proceed at all** -- inhibition is a
19
+ block, not a slowdown, and it is independent of catalysis;
18
20
  * food molecules are replenished when they fall below ``food_floor``;
19
21
  * a ligation ``a + b -> ab`` with ``a == b`` takes the pair count ``n(n-1)/2``, not ``n^2``.
20
22
  """
@@ -88,6 +90,11 @@ def propensities(net, counts: np.ndarray, *,
88
90
  runs at ``1 / uncatalysed_factor`` of it. That difference is the whole mechanism:
89
91
  it makes seeding rare but not impossible.
90
92
 
93
+ **Inhibition is absolute**: if any molecule inhibiting a reaction is present, its
94
+ propensity is zero regardless of catalysis. This is what lets a running network
95
+ *lose* a subRAF rather than only gain one -- the effect Hordijk, Naylor, Krasnogor
96
+ & Fellermann (2018) report as toxic elements causing loss of autocatalytic subsets.
97
+
91
98
  `reactions` restricts which reactions may fire. **This is a fidelity requirement,
92
99
  not a convenience.** Hordijk & Steel study "the molecular flow on this maximal RAF";
93
100
  simulating the entire generated network instead lets any reaction fire uncatalysed,
@@ -98,10 +105,13 @@ def propensities(net, counts: np.ndarray, *,
98
105
  out = np.zeros(net.n_reactions)
99
106
  allowed = range(net.n_reactions) if reactions is None else reactions
100
107
  present = frozenset(np.flatnonzero(counts).tolist())
108
+ inhibitors = getattr(net, "inhibitors", ())
101
109
  for r in allowed:
102
110
  combos = _pair_count(counts, net.reactants(r))
103
111
  if combos <= 0:
104
112
  continue
113
+ if inhibitors and (inhibitors[r] & present):
114
+ continue # inhibited: blocked outright
105
115
  catalysed = is_catalysed(net.catalysts[r], present)
106
116
  out[r] = combos if catalysed else combos / uncatalysed_factor
107
117
  return out
@@ -0,0 +1,114 @@
1
+ """Uninhibited RAFs, where a molecule can prevent a reaction.
2
+
3
+ Hordijk & Steel (2012), Part II. Inhibition is given as ``k`` pairs ``(X_i, R_i)``:
4
+ every molecule in ``X_i`` inhibits every reaction in ``R_i``. A set ``R'`` is an
5
+ **uninhibited RAF** (u-RAF) when
6
+
7
+ * **(u-1)** ``R'`` is an RAF, and
8
+ * **(u-2)** ``R' ∩ R_i != empty`` implies ``supp(R') ∩ X_i = empty``,
9
+
10
+ where ``supp(R')`` is every molecule appearing as a reactant or product in ``R'``.
11
+
12
+ **Inhibition breaks the structure the rest of this library rests on.** Adding a reaction
13
+ can now *disable* another, so the maximal-RAF operator is no longer monotone -- and
14
+ monotonicity is what gives a *unique* maximum (Huson, Xavier & Steel 2024, lemma 3.1).
15
+ There is therefore no "the" maximal u-RAF: `max_urafs` returns a **collection**, and
16
+ that difference is in the signature deliberately rather than in a footnote. Deciding
17
+ whether a u-RAF exists at all is NP-complete.
18
+
19
+ What rescues it is that the problem is fixed-parameter tractable in ``k``, by their
20
+ theorem 1: the maximal u-RAFs are exactly the non-empty sets ``s(R_J ∩ R^J)`` as ``J``
21
+ ranges over subsets of ``[k]``. So the cost is ``2^k`` calls to the ordinary maximal-RAF
22
+ algorithm, and **``k`` is a property of how inhibition is encoded, not of the
23
+ chemistry**. One class per inhibited reaction makes ``2^k`` hopeless immediately;
24
+ `classes_from_inhibitors` groups by inhibiting *molecule* instead, which is what the
25
+ paper means by considering "types" of molecules that inhibit "types" of reactions.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from itertools import combinations
30
+
31
+ from rafkit.raf import _refine
32
+
33
+ Inhibition = tuple[tuple[frozenset[int], frozenset[int]], ...]
34
+
35
+
36
+ def support(net, reactions) -> frozenset[int]:
37
+ """Every molecule that is a reactant or product of some reaction in the set.
38
+
39
+ Catalysts are deliberately excluded: the paper's ``supp`` is over reactants and
40
+ products only, and including catalysts would make (u-2) strictly harder to satisfy.
41
+ """
42
+ out: set[int] = set()
43
+ for r in reactions:
44
+ out.update(net.reactants(r))
45
+ out.update(net.products(r))
46
+ return frozenset(out)
47
+
48
+
49
+ def classes_from_inhibitors(net) -> Inhibition:
50
+ """Build the ``(X_i, R_i)`` classes from a network's per-reaction inhibitors.
51
+
52
+ Grouped by inhibiting **molecule**, so ``k`` is the number of distinct inhibitors
53
+ rather than the number of inhibited reactions. That choice is the difference
54
+ between a feasible ``2^k`` and an impossible one, and it costs nothing: the pair
55
+ ``({x}, {reactions x inhibits})`` expresses exactly the same relation.
56
+ """
57
+ by_molecule: dict[int, set[int]] = {}
58
+ for r, inhibitors in enumerate(getattr(net, "inhibitors", ()) or ()):
59
+ for x in inhibitors:
60
+ by_molecule.setdefault(x, set()).add(r)
61
+ return tuple((frozenset({x}), frozenset(rs))
62
+ for x, rs in sorted(by_molecule.items()))
63
+
64
+
65
+ def is_uninhibited(net, reactions, inhibition: Inhibition) -> bool:
66
+ """Condition (u-2): nothing the set makes or uses inhibits anything the set does."""
67
+ rs = frozenset(reactions)
68
+ supp = support(net, rs)
69
+ return all(not (rs & R_i) or not (supp & X_i) for X_i, R_i in inhibition)
70
+
71
+
72
+ def is_uraf(net, reactions, inhibition: Inhibition) -> bool:
73
+ """Both conditions: an RAF that inhibits none of its own reactions."""
74
+ rs = frozenset(reactions)
75
+ return bool(rs) and _refine(net, rs) == rs and is_uninhibited(net, rs, inhibition)
76
+
77
+
78
+ def max_urafs(net, inhibition: Inhibition | None = None,
79
+ reactions=None) -> tuple[frozenset[int], ...]:
80
+ """All maximal uninhibited RAFs, by Hordijk & Steel (2012) theorem 1.
81
+
82
+ Returns a tuple because there is generally more than one and none of them is
83
+ canonical -- see the module docstring. Empty tuple means no u-RAF exists.
84
+
85
+ Cost is ``2^k`` maximal-RAF computations, with ``k = len(inhibition)``.
86
+ """
87
+ if inhibition is None:
88
+ inhibition = classes_from_inhibitors(net)
89
+ allowed = frozenset(range(net.n_reactions) if reactions is None else reactions)
90
+ if not inhibition:
91
+ maximal = _refine(net, allowed)
92
+ return (maximal,) if maximal else ()
93
+
94
+ k = len(inhibition)
95
+ found: set[frozenset[int]] = set()
96
+ for size in range(k + 1):
97
+ for J in combinations(range(k), size):
98
+ Jset = set(J)
99
+ # R_J: reactions untouched by every class NOT in J.
100
+ R_J = frozenset(
101
+ r for r in allowed
102
+ if all(not (support(net, {r}) & inhibition[j][0])
103
+ for j in range(k) if j not in Jset))
104
+ # R^J: reactions inhibited by no class IN J.
105
+ R_super = frozenset(
106
+ r for r in allowed if all(r not in inhibition[j][1] for j in Jset))
107
+ candidate = _refine(net, R_J & R_super)
108
+ if candidate:
109
+ found.add(candidate)
110
+
111
+ # Different J can yield nested results; only the maximal ones are u-RAFs by (iii).
112
+ return tuple(sorted((s for s in found
113
+ if not any(s < t for t in found)),
114
+ key=lambda s: (-len(s), sorted(s))))
@@ -43,6 +43,12 @@ class ReactionNetwork:
43
43
  reaction_pairs: tuple[tuple[tuple[int, ...], tuple[int, ...]], ...]
44
44
  catalysts: tuple[frozenset[int], ...]
45
45
  names: tuple[str, ...] = ()
46
+ inhibitors: tuple[frozenset[int], ...] = ()
47
+ """Per reaction, the molecules that inhibit it (CatReNet's model and CRS form).
48
+
49
+ `rafkit.inhibition.classes_from_inhibitors` converts this into the (X_i, R_i)
50
+ classes the algorithm needs, grouping by molecule to keep k small.
51
+ """
46
52
 
47
53
  def __post_init__(self):
48
54
  object.__setattr__(self, "catalysts",
@@ -51,6 +57,12 @@ class ReactionNetwork:
51
57
  raise ValueError(
52
58
  f"{len(self.catalysts)} catalyst sets for "
53
59
  f"{len(self.reaction_pairs)} reactions")
60
+ if not self.inhibitors:
61
+ object.__setattr__(self, "inhibitors",
62
+ (frozenset(),) * len(self.reaction_pairs))
63
+ else:
64
+ object.__setattr__(self, "inhibitors",
65
+ tuple(frozenset(i) for i in self.inhibitors))
54
66
  if not self.names:
55
67
  object.__setattr__(
56
68
  self, "names", tuple(f"r{i + 1}" for i in range(len(self.reaction_pairs))))
@@ -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))
@@ -218,15 +218,24 @@ def sample_irrraf(net: BinaryPolymerNetwork, reactions: frozenset[int],
218
218
  return current
219
219
 
220
220
 
221
- def irrraf_census(net: BinaryPolymerNetwork, raf: RafResult, n_samples: int,
221
+ def irrraf_census(net: BinaryPolymerNetwork, raf, n_samples: int,
222
222
  rng, strict: bool = False) -> dict:
223
223
  """Sample irreducible RAFs and report how many distinct ones turn up.
224
224
 
225
+ `raf` may be a `RafResult` or a bare set of reactions -- pass one of the u-RAFs
226
+ from `rafkit.inhibition.max_urafs` to take a census under inhibition. That is
227
+ correct without further conditions because the uninhibited property is inherited
228
+ downward: every sub-RAF of a u-RAF is itself a u-RAF (Hordijk & Steel 2012), so
229
+ every core sampled from one is uninhibited too.
230
+
225
231
  The count is the quantity of interest: it upper-bounds the number of
226
232
  distinguishable lineages the chemistry can carry, so a census of 1 means there
227
233
  is nothing to inherit and no ecology is possible regardless of the dynamics
228
234
  later placed on top.
229
235
  """
236
+ if not isinstance(raf, RafResult):
237
+ raf = RafResult(reactions=frozenset(raf),
238
+ closure=_closure(net, frozenset(raf)), n_rounds=0)
230
239
  if raf.is_empty:
231
240
  return {"n_samples": 0, "n_distinct": 0, "sizes": [], "mean_size": float("nan"),
232
241
  "mean_jaccard": float("nan"), "min_jaccard": float("nan"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.2.0
3
+ Version: 0.4.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
@@ -115,11 +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 |
120
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
119
121
 
120
122
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
121
123
  is subtly wrong produces plausible numbers rather than errors.
122
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
+
123
145
  ## Catalysis is a relation, not a list
124
146
 
125
147
  `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
@@ -141,8 +163,37 @@ last two rows.
141
163
  Constructors still accept a plain iterable of molecules and normalise it, so simple
142
164
  systems stay simple to write.
143
165
 
144
- **Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
145
- molecule can *prevent* a reaction.
166
+ ## Inhibition
167
+
168
+ A molecule can prevent a reaction. `max_urafs` returns the **uninhibited RAFs** of
169
+ Hordijk & Steel (2012), and returns a *collection* rather than one set, because
170
+ inhibition destroys the monotonicity that makes a maximal RAF unique — there is no
171
+ "the" maximal u-RAF.
172
+
173
+ ```
174
+ Food: a, b
175
+ r1 : a + b [a] {d} => c # inhibited by d
176
+ r2 : a + b [b] {c} => d # inhibited by c
177
+ ```
178
+
179
+ Two maximal u-RAFs, `{r1}` and `{r2}`: each is an RAF whose support avoids its own
180
+ inhibitor, and their union is an RAF that fails the uninhibited condition.
181
+
182
+ `simulate` respects inhibition too — an inhibited reaction has propensity zero, so a
183
+ running network can **lose** a subRAF, not merely gain one. See
184
+ `examples/inhibition_dissolution.py`.
185
+
186
+ The set-theoretic tools need no special handling: `sample_irrraf`, `irrraf_census`,
187
+ `core_raf` and `catalytically_reachable` all take a reaction set, and passing a u-RAF
188
+ is correct because the uninhibited property is inherited downward — every sub-RAF of a
189
+ u-RAF is a u-RAF.
190
+
191
+ Deciding whether a u-RAF exists is NP-complete, but the problem is fixed-parameter
192
+ tractable in *k*, the number of inhibition classes — and **k is a property of how you
193
+ encode inhibition, not of the chemistry.** `classes_from_inhibitors` groups by
194
+ inhibiting *molecule*, so *k* is the number of distinct inhibitors rather than the
195
+ number of inhibited reactions, which is the difference between `2^k` being feasible
196
+ and not.
146
197
 
147
198
  ## Notes on irreducible cores
148
199
 
@@ -6,7 +6,9 @@ src/rafkit/binary_polymer.py
6
6
  src/rafkit/catalysis.py
7
7
  src/rafkit/crs.py
8
8
  src/rafkit/gillespie.py
9
+ src/rafkit/inhibition.py
9
10
  src/rafkit/network.py
11
+ src/rafkit/pnml.py
10
12
  src/rafkit/raf.py
11
13
  src/rafkit.egg-info/PKG-INFO
12
14
  src/rafkit.egg-info/SOURCES.txt
@@ -15,6 +17,8 @@ src/rafkit.egg-info/requires.txt
15
17
  src/rafkit.egg-info/top_level.txt
16
18
  tests/test_crs.py
17
19
  tests/test_gillespie.py
20
+ tests/test_inhibition.py
21
+ tests/test_pnml.py
18
22
  tests/test_published_examples.py
19
23
  tests/test_raf.py
20
24
  tests/test_seeding.py
@@ -0,0 +1,239 @@
1
+ """Uninhibited RAFs — Hordijk & Steel (2012), Part II.
2
+
3
+ The load-bearing test here is `test_matches_brute_force`: for networks small enough to
4
+ enumerate every subset, the fixed-parameter algorithm is checked against a direct
5
+ search that shares no code with it. Everything else in this file is a property from the
6
+ paper, asserted rather than assumed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from itertools import chain, combinations
11
+
12
+ import numpy as np
13
+ import pytest
14
+
15
+ from rafkit import binary_polymer, max_raf, parse_crs
16
+ from rafkit.inhibition import (classes_from_inhibitors, is_uninhibited, is_uraf,
17
+ max_urafs, support)
18
+ from rafkit.raf import _refine
19
+
20
+
21
+ def _powerset(items):
22
+ items = list(items)
23
+ return chain.from_iterable(combinations(items, k) for k in range(len(items) + 1))
24
+
25
+
26
+ def _brute_force_max_urafs(net, inhibition):
27
+ """Every maximal u-RAF, by direct enumeration. Shares no code with max_urafs."""
28
+ urafs = [frozenset(s) for s in _powerset(range(net.n_reactions))
29
+ if s and is_uraf(net, s, inhibition)]
30
+ return {s for s in urafs if not any(s < t for t in urafs)}
31
+
32
+
33
+ class TestDefinition:
34
+ def test_support_is_reactants_and_products_only(self):
35
+ net = parse_crs("Food: a, b\nr1 : a + b [z] => c\n")
36
+ names = {net.molecules[m] for m in support(net, {0})}
37
+ assert names == {"a", "b", "c"} # z is a catalyst, not in the support
38
+
39
+ def test_inhibition_by_something_absent_from_the_support_is_harmless(self):
40
+ # z inhibits r1, but z is neither reactant nor product of r1, so (u-2) holds.
41
+ net = parse_crs("Food: a, b\nr1 : a + b [a] {z} => c\n")
42
+ assert is_uninhibited(net, {0}, classes_from_inhibitors(net))
43
+ assert is_uraf(net, {0}, classes_from_inhibitors(net))
44
+
45
+ def test_a_set_that_inhibits_its_own_reaction_is_not_a_uraf(self):
46
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
47
+ c = net.molecules.index("c")
48
+ inh = ((frozenset({c}), frozenset({0})),)
49
+ assert _refine(net, {0}) == {0} # it IS an RAF
50
+ assert not is_uraf(net, {0}, inh) # but not uninhibited
51
+
52
+ def test_raf_is_unaffected_by_inhibition(self):
53
+ """The paper defines RAF without reference to inhibition; (u-2) is separate.
54
+ CatReNet instead filters inhibited reactions inside its maxRaf, so its result
55
+ differs from the definition here whenever inhibitors are present."""
56
+ net = parse_crs("Food: a, b\nr1 : a + b [a] => c\nr2 : a + c [a] => d\n")
57
+ assert len(max_raf(net).reactions) == 2
58
+
59
+
60
+ class TestTheorem1:
61
+ def test_a_subset_of_a_uraf_that_is_a_raf_is_a_uraf(self):
62
+ """Stated in the paper: (u-2) is inherited downward."""
63
+ net = parse_crs(
64
+ "Food: a, b\nr1 : a + b [a] => c\nr2 : a + c [a] => d\nr3 : a + d [a] => e\n")
65
+ e = net.molecules.index("e")
66
+ inh = ((frozenset({e}), frozenset({2})),)
67
+ whole = max_urafs(net, inh)
68
+ assert whole
69
+ for u in whole:
70
+ for s in _powerset(u):
71
+ s = frozenset(s)
72
+ if s and _refine(net, s) == s:
73
+ assert is_uraf(net, s, inh)
74
+
75
+ # Seeds probed to give a maximal RAF of 4-12 reactions with >=2 produced
76
+ # molecules, so exhaustive enumeration is feasible and the constraint bites.
77
+ @pytest.mark.parametrize("seed", [1, 5, 7, 12, 13, 15, 16])
78
+ def test_matches_brute_force(self, seed):
79
+ """The fixed-parameter algorithm against direct enumeration of every subset.
80
+
81
+ The two computations share no code: one walks subsets of [k] and calls the
82
+ maximal-RAF fixpoint, the other tests every subset of the RAF directly.
83
+ """
84
+ net = binary_polymer(max_len=4, food_len=2, p=0.01,
85
+ rng=np.random.default_rng(seed), cleavage=True)
86
+ raf = sorted(max_raf(net).reactions)
87
+ assert 3 <= len(raf) <= 12, "seed no longer yields an enumerable RAF"
88
+
89
+ # Inhibit reactions by molecules the set actually makes, so (u-2) can fail.
90
+ produced = sorted(support(net, raf) - net.food)
91
+ assert len(produced) >= 2
92
+ inh = tuple((frozenset({produced[i]}), frozenset({raf[i]}))
93
+ for i in range(2))
94
+
95
+ assert set(max_urafs(net, inh, reactions=raf)) == \
96
+ _brute_force_max_urafs_on(net, raf, inh)
97
+
98
+
99
+ def _brute_force_max_urafs_on(net, allowed, inhibition):
100
+ urafs = [frozenset(s) for s in _powerset(allowed)
101
+ if s and is_uraf(net, s, inhibition)]
102
+ return {s for s in urafs if not any(s < t for t in urafs)}
103
+
104
+
105
+ class TestCollectionSemantics:
106
+ def test_there_can_be_more_than_one_maximal_uraf(self):
107
+ """The structural break: no unique maximum, so the API returns a collection.
108
+
109
+ Two independent always-on reactions, each inhibited by the other's product.
110
+ Neither can coexist with the other, and neither is canonical.
111
+ """
112
+ net = parse_crs(
113
+ "Food: a, b\nr1 : a + b [a] => c\nr2 : a + b [b] => d\n")
114
+ c, d = net.molecules.index("c"), net.molecules.index("d")
115
+ inh = ((frozenset({c}), frozenset({1})), (frozenset({d}), frozenset({0})))
116
+ got = {frozenset(net.names[r] for r in u) for u in max_urafs(net, inh)}
117
+ assert got == {frozenset({"r1"}), frozenset({"r2"})}
118
+
119
+ def test_no_inhibition_gives_back_the_maximal_raf(self):
120
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
121
+ assert max_urafs(net, ()) == (max_raf(net).reactions,)
122
+
123
+ def test_no_uraf_returns_an_empty_collection(self):
124
+ net = parse_crs("Food: a, b\nr1 : a + b [c] => c\n")
125
+ c = net.molecules.index("c")
126
+ assert max_urafs(net, ((frozenset({c}), frozenset({0})),)) == ()
127
+
128
+
129
+ class TestClassEncoding:
130
+ def test_grouping_is_by_inhibiting_molecule_not_by_reaction(self):
131
+ """k is the cost, and it is a property of the encoding. Grouping by molecule
132
+ keeps k at the number of distinct inhibitors rather than of inhibited
133
+ reactions, which is the difference between 2^k feasible and not."""
134
+ net = parse_crs(
135
+ "Food: a, b\nr1 : a + b [a] {z} => c\nr2 : a + b [b] {z} => d\n")
136
+ classes = classes_from_inhibitors(net)
137
+ assert len(classes) == 1 # one inhibitor, not two reactions
138
+ (X, R), = classes
139
+ assert {net.molecules[x] for x in X} == {"z"}
140
+ assert R == frozenset({0, 1})
141
+
142
+
143
+ class TestDivergenceFromCatReNet:
144
+ """CatReNet computes something different once inhibitors are present.
145
+
146
+ Its `maxRaf` filters inhibited reactions *during* the RAF computation, where
147
+ Hordijk & Steel (2012) define an RAF without reference to inhibition and add (u-2)
148
+ as a separate condition on u-RAFs. On the network below CatReNet's `maxRaf` and
149
+ `uRaf` both return nothing, while two maximal u-RAFs exist by the definition.
150
+
151
+ Verified by hand rather than asserted: `{r1}` is an RAF, its support is {a,b,c},
152
+ and the only class inhibiting r1 is {d} — so (u-2) holds. Likewise `{r2}`. Their
153
+ union is an RAF but fails (u-2), which is why neither can be extended.
154
+ """
155
+
156
+ NET = "Food: a, b\nr1 : a + b [a] {d} => c\nr2 : a + b [b] {c} => d\n"
157
+
158
+ def test_two_maximal_urafs_exist_where_catrenet_reports_none(self):
159
+ net = parse_crs(self.NET)
160
+ got = {frozenset(net.names[r] for r in u) for u in max_urafs(net)}
161
+ assert got == {frozenset({"r1"}), frozenset({"r2"})}
162
+
163
+ def test_each_is_a_raf_satisfying_u2_but_their_union_is_not(self):
164
+ net = parse_crs(self.NET)
165
+ cls = classes_from_inhibitors(net)
166
+ assert is_uraf(net, {0}, cls) and is_uraf(net, {1}, cls)
167
+ assert _refine(net, {0, 1}) == {0, 1} # the union IS an RAF
168
+ assert not is_uninhibited(net, {0, 1}, cls) # but violates (u-2)
169
+
170
+
171
+ class TestDynamics:
172
+ """Inhibition in the simulator, which is what lets a network *lose* a subRAF."""
173
+
174
+ def test_an_inhibited_reaction_has_zero_propensity(self):
175
+ from rafkit.gillespie import propensities
176
+ net = parse_crs("Food: a, b\nr1 : a + b [a] {z} => c\n")
177
+ counts = np.zeros(net.n_molecules, dtype=np.int64)
178
+ for m in net.food:
179
+ counts[m] = 5
180
+ assert propensities(net, counts)[0] > 0 # z absent: runs
181
+ counts[net.molecules.index("z")] = 1
182
+ assert propensities(net, counts)[0] == 0.0 # z present: blocked outright
183
+
184
+ def test_inhibition_blocks_rather_than_slows(self):
185
+ """Distinct from the uncatalysed case, which merely runs at a reduced rate."""
186
+ from rafkit.gillespie import propensities
187
+ net = parse_crs("Food: a, b\nr1 : a + b [q] {z} => c\n")
188
+ counts = np.zeros(net.n_molecules, dtype=np.int64)
189
+ for m in net.food:
190
+ counts[m] = 5
191
+ uncatalysed = propensities(net, counts)[0]
192
+ assert uncatalysed > 0 # no catalyst, still proceeds
193
+ counts[net.molecules.index("z")] = 1
194
+ assert propensities(net, counts)[0] == 0.0
195
+
196
+ def test_a_subraf_stops_producing_once_its_inhibitor_appears(self):
197
+ """Dissolution. r2 runs as soon as c exists; r3 is self-catalysed so its
198
+ product e arrives only after a rare uncatalysed event, and blocks r2."""
199
+ from rafkit import simulate
200
+ net = parse_crs(
201
+ "Food: a, b\nr1 : a + b [a] => c\nr2 : a + c [c] {e} => d\n"
202
+ "r3 : a + b [e] => e\n")
203
+ tr = simulate(net, n_events=4000, rng=np.random.default_rng(2), sample_every=10)
204
+ t_e = tr.first_seen("e")
205
+ assert t_e is not None, "the inhibitor never appeared; test is vacuous"
206
+
207
+ d = tr.of("d").astype(float)
208
+ i = int(np.searchsorted(tr.times, t_e))
209
+ assert d[i] > 0, "d never accumulated before the block; test is vacuous"
210
+ assert d[-1] == d[i], "d kept growing after its reaction was inhibited"
211
+
212
+
213
+ class TestDownstreamUnderInhibition:
214
+ """The set-theoretic tools need no change: pass a u-RAF and they stay correct,
215
+ because the uninhibited property is inherited downward."""
216
+
217
+ def _setup(self, seed=7):
218
+ net = binary_polymer(max_len=4, food_len=2, p=0.01,
219
+ rng=np.random.default_rng(seed), cleavage=True)
220
+ raf = sorted(max_raf(net).reactions)
221
+ produced = sorted(support(net, raf) - net.food)
222
+ inh = tuple((frozenset({produced[i]}), frozenset({raf[i]})) for i in range(2))
223
+ return net, raf, inh
224
+
225
+ def test_cores_sampled_from_a_uraf_are_themselves_urafs(self):
226
+ from rafkit import sample_irrraf
227
+ net, raf, inh = self._setup()
228
+ urafs = max_urafs(net, inh, reactions=raf)
229
+ assert urafs
230
+ for u in urafs:
231
+ for i in range(10):
232
+ assert is_uraf(net, sample_irrraf(net, u, np.random.default_rng(i)), inh)
233
+
234
+ def test_census_accepts_a_bare_reaction_set(self):
235
+ from rafkit import irrraf_census
236
+ net, raf, inh = self._setup()
237
+ u = max_urafs(net, inh, reactions=raf)[0]
238
+ census = irrraf_census(net, u, n_samples=5, rng=np.random.default_rng(0))
239
+ assert census["n_distinct"] >= 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