rafkit 0.1.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.1.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
@@ -33,6 +33,7 @@ Dynamic: license-file
33
33
  [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
34
34
  [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
35
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](https://doi.org/10.5281/zenodo.21954795)
36
37
 
37
38
  Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
38
39
  cores, Kauffman binary polymer models, and interoperability with
@@ -109,13 +110,70 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
109
110
  | `irrraf_census` | how many *distinct* irreducible cores a network carries |
110
111
  | `exploitability` | share of RAF products contributing no catalysis back |
111
112
  | `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
113
+ | `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
114
+ | `catalytically_reachable` | what can be made without any spontaneous reaction |
112
115
  | `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
113
116
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
114
117
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
118
+ | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
119
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
115
120
 
116
121
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
117
122
  is subtly wrong produces plausible numbers rather than errors.
118
123
 
124
+ ## Catalysis is a relation, not a list
125
+
126
+ `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
127
+ Steel (2024). Any one set being fully present suffices, and each set is a conjunctive
128
+ requirement:
129
+
130
+ | `catalysts[r]` | meaning |
131
+ |---|---|
132
+ | `{{a}, {b}}` | *a* **or** *b* — the simple case, and what a flat list of catalysts meant |
133
+ | `{{a, d}, {e}}` | (*a* **and** *d*) **or** *e* |
134
+ | `{}` | **must** be catalysed, and nothing does: never in a RAF |
135
+ | `{frozenset()}` | **may proceed uncatalysed**; always satisfied |
136
+
137
+ The last two rows are a real distinction rather than a technicality — in the §2.4 system
138
+ of that paper it decides which reactions can join an RAF — and a flat list collapses
139
+ them. In CRS, a braced group is conjunctive: `[{a,d}, e]`, with `[]` and `[{}]` for the
140
+ last two rows.
141
+
142
+ Constructors still accept a plain iterable of molecules and normalise it, so simple
143
+ systems stay simple to write.
144
+
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.
176
+
119
177
  ## Notes on irreducible cores
120
178
 
121
179
  There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
@@ -145,6 +203,13 @@ pytest -q
145
203
  Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
146
204
  [docs/RELEASING.md](docs/RELEASING.md).
147
205
 
206
+ ## Citing
207
+
208
+ Cite the concept DOI [10.5281/zenodo.21954795](https://doi.org/10.5281/zenodo.21954795),
209
+ which always resolves to the latest version; `CITATION.cff` also lists the per-version
210
+ DOI. If you use the CatReNet interoperability or the validation fixture, please cite
211
+ CatReNet too.
212
+
148
213
  ## References
149
214
 
150
215
  - Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
@@ -5,6 +5,7 @@
5
5
  [![PyPI](https://img.shields.io/pypi/v/rafkit.svg)](https://pypi.org/project/rafkit/)
6
6
  [![Python](https://img.shields.io/pypi/pyversions/rafkit.svg)](https://pypi.org/project/rafkit/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21954795.svg)](https://doi.org/10.5281/zenodo.21954795)
8
9
 
9
10
  Autocatalytic (RAF) sets in catalytic reaction networks — maximal RAFs, irreducible
10
11
  cores, Kauffman binary polymer models, and interoperability with
@@ -81,13 +82,70 @@ counted as **one** catalysed reaction, not two. Use `net.catalysis_level` — no
81
82
  | `irrraf_census` | how many *distinct* irreducible cores a network carries |
82
83
  | `exploitability` | share of RAF products contributing no catalysis back |
83
84
  | `is_food_catalysed` | whether a core runs on food catalysis alone, and so carries no heredity |
85
+ | `core_raf` / `has_unique_irraf` | Huson, Xavier & Steel's polynomial test for a *unique* irreducible RAF |
86
+ | `catalytically_reachable` | what can be made without any spontaneous reaction |
84
87
  | `binary_polymer` | Kauffman binary polymer generator, with optional cleavage |
85
88
  | `ReactionNetwork` | arbitrary catalytic reaction systems, same protocol |
86
89
  | `read_crs` / `write_crs` | CatReNet's CRS interchange format |
90
+ | `simulate` | Gillespie direct method — watch subRAFs seed themselves into existence |
91
+ | `max_urafs` | uninhibited RAFs, when a molecule can prevent a reaction |
87
92
 
88
93
  Every algorithm carries hand-computed known-answer tests, because a RAF algorithm that
89
94
  is subtly wrong produces plausible numbers rather than errors.
90
95
 
96
+ ## Catalysis is a relation, not a list
97
+
98
+ `catalysts[r]` is a set of **alternative catalyst sets**, following Huson, Xavier &
99
+ Steel (2024). Any one set being fully present suffices, and each set is a conjunctive
100
+ requirement:
101
+
102
+ | `catalysts[r]` | meaning |
103
+ |---|---|
104
+ | `{{a}, {b}}` | *a* **or** *b* — the simple case, and what a flat list of catalysts meant |
105
+ | `{{a, d}, {e}}` | (*a* **and** *d*) **or** *e* |
106
+ | `{}` | **must** be catalysed, and nothing does: never in a RAF |
107
+ | `{frozenset()}` | **may proceed uncatalysed**; always satisfied |
108
+
109
+ The last two rows are a real distinction rather than a technicality — in the §2.4 system
110
+ of that paper it decides which reactions can join an RAF — and a flat list collapses
111
+ them. In CRS, a braced group is conjunctive: `[{a,d}, e]`, with `[]` and `[{}]` for the
112
+ last two rows.
113
+
114
+ Constructors still accept a plain iterable of molecules and normalise it, so simple
115
+ systems stay simple to write.
116
+
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.
148
+
91
149
  ## Notes on irreducible cores
92
150
 
93
151
  There may be **exponentially many** irreducible RAFs inside one maximal RAF, and
@@ -117,6 +175,13 @@ pytest -q
117
175
  Releases are documented in [CHANGELOG.md](CHANGELOG.md); the release procedure is
118
176
  [docs/RELEASING.md](docs/RELEASING.md).
119
177
 
178
+ ## Citing
179
+
180
+ Cite the concept DOI [10.5281/zenodo.21954795](https://doi.org/10.5281/zenodo.21954795),
181
+ which always resolves to the latest version; `CITATION.cff` also lists the per-version
182
+ DOI. If you use the CatReNet interoperability or the validation fixture, please cite
183
+ CatReNet too.
184
+
120
185
  ## References
121
186
 
122
187
  - Hordijk & Steel, "Detecting autocatalytic, self-sustaining sets in chemical reaction
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rafkit"
3
- version = "0.1.0"
3
+ version = "0.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" }
@@ -2,7 +2,8 @@
2
2
 
3
3
  A small, dependency-light implementation of RAF theory (Hordijk & Steel 2004):
4
4
  maximal RAFs, the self-referential ("strictly autocatalytic") variant, irreducible
5
- RAF sampling, and Kauffman's binary polymer model as a generator.
5
+ RAF sampling, Kauffman's binary polymer model as a generator, and stochastic simulation
6
+ of a network so that subRAFs can be watched seeding themselves into existence.
6
7
 
7
8
  Every algorithm here is written from the published papers and carries hand-computed
8
9
  known-answer tests, because a RAF algorithm that is subtly wrong produces plausible
@@ -12,18 +13,27 @@ See the README for the calibration against Steel, Hordijk & Smith (2012) and for
12
13
  CatReNet interoperability.
13
14
  """
14
15
  from rafkit.binary_polymer import BinaryPolymerNetwork, binary_polymer
16
+ from rafkit.catalysis import catalysing_molecules, is_catalysed, normalise
15
17
  from rafkit.crs import parse_crs, read_crs, to_crs, write_crs
18
+ from rafkit.gillespie import Trajectory, propensities, simulate
19
+ from rafkit.inhibition import (classes_from_inhibitors, is_uninhibited, is_uraf,
20
+ max_urafs, support)
16
21
  from rafkit.network import ReactionNetwork
17
22
  from rafkit.raf import (
18
- RafResult, catrenet_strictly_autocatalytic, exploitability, irrraf_census,
19
- is_food_catalysed, max_raf, max_raf_strict, sample_irrraf,
23
+ RafResult, catrenet_strictly_autocatalytic, core_raf, exploitability,
24
+ has_unique_irraf, irrraf_census, is_food_catalysed, max_raf, max_raf_strict,
25
+ sample_irrraf,
20
26
  )
21
27
 
22
- __version__ = "0.1.0"
28
+ __version__ = "0.3.0"
23
29
 
24
30
  __all__ = [
25
31
  "BinaryPolymerNetwork", "binary_polymer", "ReactionNetwork",
26
32
  "RafResult", "max_raf", "max_raf_strict", "sample_irrraf", "irrraf_census",
27
33
  "exploitability", "is_food_catalysed", "catrenet_strictly_autocatalytic",
34
+ "core_raf", "has_unique_irraf",
35
+ "is_catalysed", "catalysing_molecules", "normalise",
28
36
  "parse_crs", "read_crs", "to_crs", "write_crs",
37
+ "simulate", "propensities", "Trajectory",
38
+ "max_urafs", "is_uraf", "is_uninhibited", "support", "classes_from_inhibitors",
29
39
  ]
@@ -44,6 +44,8 @@ from typing import Iterator
44
44
 
45
45
  import numpy as np
46
46
 
47
+ from rafkit.catalysis import normalise
48
+
47
49
 
48
50
  @dataclass(frozen=True)
49
51
  class BinaryPolymerNetwork:
@@ -66,6 +68,8 @@ class BinaryPolymerNetwork:
66
68
  directions: tuple[int, ...] = ()
67
69
 
68
70
  def __post_init__(self):
71
+ object.__setattr__(self, "catalysts",
72
+ tuple(normalise(c) for c in self.catalysts))
69
73
  if not self.directions: # default: all ligations
70
74
  object.__setattr__(self, "directions", (1,) * len(self.reactions))
71
75
  elif len(self.directions) != len(self.reactions):
@@ -0,0 +1,78 @@
1
+ """The catalysis relation χ, and the one predicate everything else is built on.
2
+
3
+ Huson, Xavier & Steel (2024) treat catalysis as a relation between **sets** of molecules
4
+ and reactions: reaction ``r`` proceeds when *some* catalyst set ``U`` in ``chi(r)`` is
5
+ entirely available. That single structure expresses three things a flat set of catalysts
6
+ cannot:
7
+
8
+ =========================== ============================================================
9
+ ``chi(r)`` meaning
10
+ =========================== ============================================================
11
+ ``{{a}, {b}}`` *a* **or** *b* -- the simple, disjunctive case
12
+ ``{{a, d}, {e}}`` (*a* **and** *d*) **or** *e* -- conjunctive requirements
13
+ ``{}`` (no sets at all) **must** be catalysed, and nothing catalyses it: never in a RAF
14
+ ``{frozenset()}`` **may proceed uncatalysed**; always satisfied
15
+ =========================== ============================================================
16
+
17
+ The last two are a genuine distinction rather than a technicality -- in their §2.4 system
18
+ it is what separates a reaction that can join an RAF from one that cannot -- and writing
19
+ catalysts as a flat set collapses them.
20
+
21
+ `is_catalysed` handles all four rows without special-casing, because ``any()`` over no
22
+ sets is False and the empty set is a subset of everything.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Iterable
27
+
28
+ CatalystSets = frozenset[frozenset[int]]
29
+
30
+
31
+ def normalise(spec) -> CatalystSets:
32
+ """Accept either the simple or the general form and return the general one.
33
+
34
+ A flat iterable of molecule indices -- the form used everywhere before conjunctive
35
+ catalysis existed, and still the right one to write by hand for simple systems --
36
+ becomes one singleton set per molecule, which is exactly equivalent.
37
+ """
38
+ if spec is None:
39
+ return frozenset()
40
+ out = []
41
+ for item in spec:
42
+ if isinstance(item, (frozenset, set, tuple, list)):
43
+ out.append(frozenset(int(x) for x in item))
44
+ else:
45
+ out.append(frozenset({int(item)}))
46
+ return frozenset(out)
47
+
48
+
49
+ def is_catalysed(chi: CatalystSets, available: Iterable[int]) -> bool:
50
+ """Whether some catalyst set of a reaction is fully present.
51
+
52
+ Note the two edge cases fall out rather than being handled: with no catalyst sets
53
+ `any()` is False, so the reaction can never run; with the empty set present,
54
+ ``frozenset() <= available`` is True, so it always can.
55
+ """
56
+ avail = available if isinstance(available, (set, frozenset)) else frozenset(available)
57
+ return any(U <= avail for U in chi)
58
+
59
+
60
+ def catalysing_molecules(chi: CatalystSets) -> frozenset[int]:
61
+ """Every molecule that appears in any catalyst set.
62
+
63
+ The right notion of "x catalyses r" when catalysis is conjunctive: x may be
64
+ necessary without being sufficient, and it still counts as catalysing.
65
+ """
66
+ return frozenset().union(*chi) if chi else frozenset()
67
+
68
+
69
+ def requires_non_food(chi: CatalystSets, available: Iterable[int], food) -> bool:
70
+ """Whether some *satisfiable* catalyst set is not contained in the food set.
71
+
72
+ This is the strictly-autocatalytic condition of Huson, Xavier & Steel (2024) §3.1
73
+ stated exactly: ``U subset-of cl(F)`` and ``U not-subset-of F``. Under simple
74
+ catalysis it reduces to "has a catalyst that is a non-food product", which is what
75
+ `max_raf_strict` meant before this module existed.
76
+ """
77
+ avail = available if isinstance(available, (set, frozenset)) else frozenset(available)
78
+ return any(U <= avail and not U <= food for U in chi)
@@ -14,7 +14,16 @@ The format is a food line and one line per reaction::
14
14
  r2 : ab [aa] => abab
15
15
 
16
16
  `[...]` lists catalysts, `<->` marks a reversible reaction and `=>` (or `->`) an
17
- irreversible one. A reversible reaction is read as **two** reactions, forward and
17
+ irreversible one.
18
+
19
+ Catalysts are alternatives, any one of which suffices. A braced group is a
20
+ **conjunctive** requirement -- `[{a,d}, e]` means *a and d together*, or *e* -- which
21
+ is the notation Huson, Xavier & Steel (2024) use. Two edge cases carry meaning and are
22
+ not interchangeable: `[]` means the reaction **must** be catalysed and nothing does so,
23
+ while `[{}]` means it **may proceed uncatalysed**.
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
18
27
  reverse, sharing a catalyst set -- which is the reading its own generator uses.
19
28
 
20
29
  `X + X -> Y` is written `X ... -> Y`, with the repeated reactant collapsed. Since
@@ -39,6 +48,20 @@ def _split_list(text: str) -> list[str]:
39
48
  return [s for s in (t.strip() for t in re.split(r"[,+]", text)) if s]
40
49
 
41
50
 
51
+ def _parse_catalysts(text: str) -> list[list[str]]:
52
+ """Parse a catalyst list into alternative sets, honouring braced conjunctions.
53
+
54
+ Splitting on commas alone is wrong the moment a braced group appears -- `{a,d}`
55
+ would become `{a` and `d}` -- so groups are pulled out first.
56
+ """
57
+ groups, rest = [], text
58
+ for m in re.finditer(r"\{([^}]*)\}", text):
59
+ groups.append(_split_list(m.group(1))) # may be empty: {} means "uncatalysed"
60
+ rest = re.sub(r"\{[^}]*\}", " ", text)
61
+ groups += [[name] for name in _split_list(rest)]
62
+ return groups
63
+
64
+
42
65
  def parse_crs(text: str) -> ReactionNetwork:
43
66
  """Parse CRS text into a `ReactionNetwork`."""
44
67
  food_names: list[str] = []
@@ -56,44 +79,54 @@ def parse_crs(text: str) -> ReactionNetwork:
56
79
  continue
57
80
  name, body = m.group("name"), m.group("body")
58
81
 
59
- cats: list[str] = []
82
+ inhib: list[str] = []
83
+ cats: list[list[str]] = []
60
84
  if "[" in body:
61
85
  pre, rest = body.split("[", 1)
62
86
  inside, post = rest.split("]", 1)
63
- cats = _split_list(inside)
87
+ cats = _parse_catalysts(inside)
64
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():]
65
94
 
66
95
  arrow = _ARROW.search(body)
67
96
  if not arrow:
68
97
  continue
69
98
  lhs, rhs = body[:arrow.start()], body[arrow.end():]
70
- parsed.append((name, _split_list(lhs), _split_list(rhs), cats,
99
+ parsed.append((name, _split_list(lhs), _split_list(rhs), cats, inhib,
71
100
  arrow.group(1) in ("<->", "<=>")))
72
101
 
73
102
  # Stable molecule indexing: food first, then order of appearance.
74
103
  index: dict[str, int] = {}
75
104
  for n in food_names:
76
105
  index.setdefault(n, len(index))
77
- for _, lhs, rhs, cats, _ in parsed:
78
- for n in (*lhs, *rhs, *cats):
106
+ for _, lhs, rhs, cats, inhib, _ in parsed:
107
+ for n in (*lhs, *rhs, *(x for g in cats for x in g), *inhib):
79
108
  index.setdefault(n, len(index))
80
109
 
81
- pairs, catalysts, names = [], [], []
82
- for name, lhs, rhs, cats, reversible in parsed:
83
- cat = frozenset(index[c] for c in cats)
110
+ pairs, catalysts, names, inhibitors = [], [], [], []
111
+ for name, lhs, rhs, cats, inhib, reversible in parsed:
112
+ cat = frozenset(frozenset(index[c] for c in g) for g in cats)
113
+ inh = frozenset(index[x] for x in inhib)
84
114
  fwd = (tuple(index[x] for x in lhs), tuple(index[x] for x in rhs))
85
115
  pairs.append(fwd); catalysts.append(cat); names.append(name)
116
+ inhibitors.append(inh)
86
117
  if reversible:
87
118
  pairs.append((fwd[1], fwd[0]))
88
119
  catalysts.append(cat)
89
120
  names.append(f"{name}_rev")
121
+ inhibitors.append(inh)
90
122
 
91
123
  molecules = tuple(sorted(index, key=index.get))
92
124
  return ReactionNetwork(molecules=molecules,
93
125
  food=frozenset(index[n] for n in food_names),
94
126
  reaction_pairs=tuple(pairs),
95
127
  catalysts=tuple(catalysts),
96
- names=tuple(names))
128
+ names=tuple(names),
129
+ inhibitors=tuple(inhibitors))
97
130
 
98
131
 
99
132
  def read_crs(path: str | Path) -> ReactionNetwork:
@@ -119,12 +152,25 @@ def to_crs(net, comment: str = "") -> str:
119
152
  for r in range(net.n_reactions):
120
153
  lhs = " + ".join(dict.fromkeys(name(x) for x in net.reactants(r)))
121
154
  rhs = " + ".join(dict.fromkeys(name(x) for x in net.products(r)))
122
- cats = ",".join(sorted(name(c) for c in net.catalysts[r]))
123
- out.append(f"{names[r]} : {lhs} [{cats}] => {rhs}")
155
+ cats = _format_catalysts(net, r)
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}")
124
161
  out.append("")
125
162
  return "\n".join(out)
126
163
 
127
164
 
165
+ def _format_catalysts(net, r: int) -> str:
166
+ """Render a reaction's catalyst sets, using braces only where they are needed."""
167
+ parts = []
168
+ for U in sorted(net.catalysts[r], key=lambda u: sorted(u)):
169
+ names = sorted(net.molecules[c] for c in U)
170
+ parts.append(names[0] if len(names) == 1 else "{" + ",".join(names) + "}")
171
+ return ",".join(parts)
172
+
173
+
128
174
  def write_crs(net, path: str | Path, comment: str = "") -> None:
129
175
  """Write a network to a CRS file."""
130
176
  Path(path).write_text(to_crs(net, comment))