2core/rag.py — The Reaction Admissibility Graph G = (V, E, nu, k) (Layer 1).
4Host-independent. Implements the labelled multidigraph of
5Ghoniem (2026), *A Generalized Graph-Based Cluster Dynamics Framework*,
10 V : admissible vertex set — cluster identifiers (chi, n, p, c)
11 E : edge set — every admissible elementary reaction, each carrying a
12 stoichiometric tuple nu and an :class:`EdgeClass`
13 nu: the stoichiometric action (fixed per EdgeClass by EDGE_CLASS_SPEC)
14 k : the rate-kernel library — calibration-specific, supplied by a host
16The graph is *host-specific in (V, E)* and *calibration-specific in k*;
17the algorithmic core (the graph walker) is host-independent. This module
18provides only the container and the admissibility logic — it declares no
19populations, no edges and no kernels itself. A host material builds a
20RAG by declaring its populations, adding :class:`Edge` families, and
21registering rate kernels.
23Physically forbidden reactions are excluded at construction time: an edge
24is admissible only if its endpoints are admissible and its stoichiometry
25respects the host conservation laws. Once built, the master equation is
26the host-independent graph-walking accumulator applied edge-by-edge.
28from __future__
import annotations
30from dataclasses
import dataclass, field
31from typing
import Callable, Dict, List, Optional, Tuple
33from .cluster_identifier
import Polarity, Population
34from .edge_classes
import EDGE_CLASS_SPEC, EdgeClass
39 """One admissible reaction-edge *family* in the RAG (paper Section 3.2).
41 An ``Edge`` is parameterised over the size axis: it represents the
42 whole family of elementary reactions of one :class:`EdgeClass` acting
43 on one population (or population pair). The graph walker applies the
44 edge's kernel vectorised over n, so a single ``Edge`` stands for
45 O(N) scalar reactions.
49 edge_class : EdgeClass
50 Which of the ten elementary classes this family belongs to.
52 Human-readable identifier, unique within a RAG (diagnostics).
53 population : Population
54 The population the (surviving) cluster belongs to.
56 Key into the host rate-kernel library ``k``. The kernel supplies
57 the size-resolved rate; the abstract core never evaluates it.
58 partner_population : Population, optional
59 The second precursor population for binary classes
60 (COALESCENCE: same polarity; ANNIHILATION: opposite polarity).
61 product_population : Population, optional
62 The product population for INTER_POPULATION edges (p' != p).
63 gas_species : int, optional
64 Index into the host gas list, for SOLUTE_TRAPPING edges.
66 Free-form host annotations (e.g. emission channel, sink kind).
71 population: Population
73 partner_population: Optional[Population] =
None
74 product_population: Optional[Population] =
None
75 gas_species: Optional[int] =
None
76 meta: Dict = field(default_factory=dict)
80 """The host-independent stoichiometric contract for this edge."""
88 if self.
edge_class is EdgeClass.INTER_POPULATION:
91 f
"edge {self.label!r}: INTER_POPULATION requires a "
95 f
"edge {self.label!r}: inter-population transfer cannot "
96 "cross the polarity layers")
101 f
"edge {self.label!r}: ANNIHILATION partner must be of "
103 if (self.
edge_class is EdgeClass.COALESCENCE
and
111 f
"edge {self.label!r}: COALESCENCE product_population must "
112 "share the reactants' polarity")
113 if (self.
edge_class is EdgeClass.SOLUTE_TRAPPING
and
116 f
"edge {self.label!r}: SOLUTE_TRAPPING requires gas_species")
119 return f
"<Edge {self.edge_class.value}:{self.label} @ {self.population.name}>"
123 """The host-specific RAG: vertex space, edge set, and kernel library.
125 A host material populates this object by declaring populations, adding
126 :class:`Edge` families, and registering rate kernels. The abstract
127 graph walker then consumes it without any further reference to the
131 def __init__(self, name: str, gas_species: Optional[List[str]] =
None):
133 self.gas_species: List[str] = list(gas_species
or [])
137 self._populations: Dict[Tuple[Polarity, str], Population] = {}
138 self.
_edges: List[Edge] = []
139 self._kernels: Dict[str, Callable] = {}
143 self._monomer_pop: Dict[Polarity, Population] = {}
147 def _pop_key(p: Population) ->
"Tuple[Polarity, str]":
148 return (p.polarity, p.name)
152 if key
in self._populations:
154 f
"duplicate population {p.name!r} in the "
155 f
"{p.polarity.label} layer")
156 self._populations[key] = p
161 return tuple(self._populations.values())
164 return tuple(p
for p
in self._populations.values()
165 if p.polarity
is polarity)
168 """Declare ``p`` as the point-defect monomer pool for its polarity."""
169 if self.
_pop_key(p)
not in self._populations:
171 f
"monomer population {p.name!r} must be registered first")
172 self._monomer_pop[p.polarity] = p
175 """Return the population that holds the ``polarity`` monomer pool."""
177 return self._monomer_pop[polarity]
180 f
"RAG {self.name!r}: no monomer population set for "
181 f
"{polarity.label}")
from None
185 if any(e.label == edge.label
for e
in self.
_edges):
186 raise ValueError(f
"duplicate edge label {edge.label!r}")
187 for p
in (edge.population, edge.partner_population,
188 edge.product_population):
189 if p
is not None and self.
_pop_key(p)
not in self._populations:
191 f
"edge {edge.label!r} references unregistered "
192 f
"{p.polarity.label} population {p.name!r}")
197 def edges(self) -> Tuple[Edge, ...]:
201 return tuple(e
for e
in self.
_edges if e.edge_class
is edge_class)
205 """Bind a rate kernel to a name.
207 ``kernel`` may be a precomputed array/scalar, or a zero-argument
208 callable that builds and returns the kernel on first use. Lazy
209 callables let large O(N²) kernels (coalescence/annihilation, the
210 junction-branching split) be registered cheaply and materialised only
211 if the Python GraphWalker actually evaluates the edge; the C++ solver
212 path computes them on the fly and never requests them, so production
213 runs never pay the allocation.
215 self._kernels[name] = kernel
219 k = self._kernels[name]
222 f
"RAG {self.name!r}: no kernel registered for {name!r}")
from None
227 self._kernels[name] = k
232 """Check structural consistency: every edge's kernel is registered.
234 Signed-defect conservation is guaranteed structurally by
235 EDGE_CLASS_SPEC (the per-class S_ar sign pattern), so it does not
236 need to be re-verified here; only the host-supplied bindings are
240 if e.kernel
not in self._kernels:
242 f
"RAG {self.name!r}: edge {e.label!r} needs unregistered "
243 f
"kernel {e.kernel!r}")
247 active = {k.value: v
for k, v
in by_class.items()
if v}
248 return (f
"RAG {self.name!r}: {len(self._populations)} populations, "
249 f
"{len(self._edges)} edges, {len(self._kernels)} kernels; "
250 f
"edge classes active: {active}")
253 return (f
"<ReactionAdmissibilityGraph {self.name!r} "
254 f
"V_pop={len(self._populations)} E={len(self._edges)}>")
Optional product_population
Optional partner_population
__init__(self, str name, Optional[List[str]] gas_species=None)
None register_kernel(self, str name, kernel)
Tuple[Edge,...] edges(self)
Tuple[Edge,...] edges_of_class(self, EdgeClass edge_class)
"Tuple[Polarity, str]" _pop_key(Population p)
Tuple[Population,...] populations(self)
None set_monomer_population(self, Population p)
Tuple[Population,...] populations_of(self, Polarity polarity)
Population monomer_population(self, Polarity polarity)
Edge add_edge(self, Edge edge)
Population add_population(self, Population p)