rafkit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rafkit
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -116,6 +116,7 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
116
116
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
117
117
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
118
118
  | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
119
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
119
120
 
120
121
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
121
122
  is subtly wrong produces plausible numbers rather than errors.
@@ -141,8 +142,37 @@ last two rows.
141
142
  Constructors still accept a plain iterable of molecules and normalise it, so simple
142
143
  systems stay simple to write.
143
144
 
144
- **Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
145
- molecule can *prevent* a reaction.
145
+ ## Inhibition
146
+
147
+ A molecule can prevent a reaction. `max_urafs` returns the **uninhibited RAFs** of
148
+ Hordijk & Steel (2012), and returns a *collection* rather than one set, because
149
+ inhibition destroys the monotonicity that makes a maximal RAF unique — there is no
150
+ "the" maximal u-RAF.
151
+
152
+ ```
153
+ Food: a, b
154
+ r1 : a + b [a] {d} => c # inhibited by d
155
+ r2 : a + b [b] {c} => d # inhibited by c
156
+ ```
157
+
158
+ Two maximal u-RAFs, `{r1}` and `{r2}`: each is an RAF whose support avoids its own
159
+ inhibitor, and their union is an RAF that fails the uninhibited condition.
160
+
161
+ `simulate` respects inhibition too — an inhibited reaction has propensity zero, so a
162
+ running network can **lose** a subRAF, not merely gain one. See
163
+ `examples/inhibition_dissolution.py`.
164
+
165
+ The set-theoretic tools need no special handling: `sample_irrraf`, `irrraf_census`,
166
+ `core_raf` and `catalytically_reachable` all take a reaction set, and passing a u-RAF
167
+ is correct because the uninhibited property is inherited downward — every sub-RAF of a
168
+ u-RAF is a u-RAF.
169
+
170
+ Deciding whether a u-RAF exists is NP-complete, but the problem is fixed-parameter
171
+ tractable in *k*, the number of inhibition classes — and **k is a property of how you
172
+ encode inhibition, not of the chemistry.** `classes_from_inhibitors` groups by
173
+ inhibiting *molecule*, so *k* is the number of distinct inhibitors rather than the
174
+ number of inhibited reactions, which is the difference between `2^k` being feasible
175
+ and not.
146
176
 
147
177
  ## Notes on irreducible cores
148
178
 
@@ -88,6 +88,7 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
88
88
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
89
89
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
90
90
  | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
91
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
91
92
 
92
93
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
93
94
  is subtly wrong produces plausible numbers rather than errors.
@@ -113,8 +114,37 @@ last two rows.
113
114
  Constructors still accept a plain iterable of molecules and normalise it, so simple
114
115
  systems stay simple to write.
115
116
 
116
- **Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
117
- molecule can *prevent* a reaction.
117
+ ## Inhibition
118
+
119
+ A molecule can prevent a reaction. `max_urafs` returns the **uninhibited RAFs** of
120
+ Hordijk & Steel (2012), and returns a *collection* rather than one set, because
121
+ inhibition destroys the monotonicity that makes a maximal RAF unique — there is no
122
+ "the" maximal u-RAF.
123
+
124
+ ```
125
+ Food: a, b
126
+ r1 : a + b [a] {d} => c # inhibited by d
127
+ r2 : a + b [b] {c} => d # inhibited by c
128
+ ```
129
+
130
+ Two maximal u-RAFs, `{r1}` and `{r2}`: each is an RAF whose support avoids its own
131
+ inhibitor, and their union is an RAF that fails the uninhibited condition.
132
+
133
+ `simulate` respects inhibition too — an inhibited reaction has propensity zero, so a
134
+ running network can **lose** a subRAF, not merely gain one. See
135
+ `examples/inhibition_dissolution.py`.
136
+
137
+ The set-theoretic tools need no special handling: `sample_irrraf`, `irrraf_census`,
138
+ `core_raf` and `catalytically_reachable` all take a reaction set, and passing a u-RAF
139
+ is correct because the uninhibited property is inherited downward — every sub-RAF of a
140
+ u-RAF is a u-RAF.
141
+
142
+ Deciding whether a u-RAF exists is NP-complete, but the problem is fixed-parameter
143
+ tractable in *k*, the number of inhibition classes — and **k is a property of how you
144
+ encode inhibition, not of the chemistry.** `classes_from_inhibitors` groups by
145
+ inhibiting *molecule*, so *k* is the number of distinct inhibitors rather than the
146
+ number of inhibited reactions, which is the difference between `2^k` being feasible
147
+ and not.
118
148
 
119
149
  ## Notes on irreducible cores
120
150
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rafkit"
3
- version = "0.2.0"
3
+ version = "0.3.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,6 +16,8 @@ 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
20
22
  from rafkit.raf import (
21
23
  RafResult, catrenet_strictly_autocatalytic, core_raf, exploitability,
@@ -23,7 +25,7 @@ from rafkit.raf import (
23
25
  sample_irrraf,
24
26
  )
25
27
 
26
- __version__ = "0.2.0"
28
+ __version__ = "0.3.0"
27
29
 
28
30
  __all__ = [
29
31
  "BinaryPolymerNetwork", "binary_polymer", "ReactionNetwork",
@@ -33,4 +35,5 @@ __all__ = [
33
35
  "is_catalysed", "catalysing_molecules", "normalise",
34
36
  "parse_crs", "read_crs", "to_crs", "write_crs",
35
37
  "simulate", "propensities", "Trajectory",
38
+ "max_urafs", "is_uraf", "is_uninhibited", "support", "classes_from_inhibitors",
36
39
  ]
@@ -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))))
@@ -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.3.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
@@ -116,6 +116,7 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
116
116
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
117
117
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
118
118
  | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
119
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
119
120
 
120
121
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
121
122
  is subtly wrong produces plausible numbers rather than errors.
@@ -141,8 +142,37 @@ last two rows.
141
142
  Constructors still accept a plain iterable of molecules and normalise it, so simple
142
143
  systems stay simple to write.
143
144
 
144
- **Still not representable:** inhibition (Hordijk & Steel 2012, Part II), where a
145
- molecule can *prevent* a reaction.
145
+ ## Inhibition
146
+
147
+ A molecule can prevent a reaction. `max_urafs` returns the **uninhibited RAFs** of
148
+ Hordijk & Steel (2012), and returns a *collection* rather than one set, because
149
+ inhibition destroys the monotonicity that makes a maximal RAF unique — there is no
150
+ "the" maximal u-RAF.
151
+
152
+ ```
153
+ Food: a, b
154
+ r1 : a + b [a] {d} => c # inhibited by d
155
+ r2 : a + b [b] {c} => d # inhibited by c
156
+ ```
157
+
158
+ Two maximal u-RAFs, `{r1}` and `{r2}`: each is an RAF whose support avoids its own
159
+ inhibitor, and their union is an RAF that fails the uninhibited condition.
160
+
161
+ `simulate` respects inhibition too — an inhibited reaction has propensity zero, so a
162
+ running network can **lose** a subRAF, not merely gain one. See
163
+ `examples/inhibition_dissolution.py`.
164
+
165
+ The set-theoretic tools need no special handling: `sample_irrraf`, `irrraf_census`,
166
+ `core_raf` and `catalytically_reachable` all take a reaction set, and passing a u-RAF
167
+ is correct because the uninhibited property is inherited downward — every sub-RAF of a
168
+ u-RAF is a u-RAF.
169
+
170
+ Deciding whether a u-RAF exists is NP-complete, but the problem is fixed-parameter
171
+ tractable in *k*, the number of inhibition classes — and **k is a property of how you
172
+ encode inhibition, not of the chemistry.** `classes_from_inhibitors` groups by
173
+ inhibiting *molecule*, so *k* is the number of distinct inhibitors rather than the
174
+ number of inhibited reactions, which is the difference between `2^k` being feasible
175
+ and not.
146
176
 
147
177
  ## Notes on irreducible cores
148
178
 
@@ -6,6 +6,7 @@ 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
10
11
  src/rafkit/raf.py
11
12
  src/rafkit.egg-info/PKG-INFO
@@ -15,6 +16,7 @@ src/rafkit.egg-info/requires.txt
15
16
  src/rafkit.egg-info/top_level.txt
16
17
  tests/test_crs.py
17
18
  tests/test_gillespie.py
19
+ tests/test_inhibition.py
18
20
  tests/test_published_examples.py
19
21
  tests/test_raf.py
20
22
  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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes