2core/graph_walker.py — The master equation as a graph-walking accumulator.
4Host-independent. Implements the "graph walker" of Ghoniem (2026),
5*A Generalized Graph-Based Cluster Dynamics Framework*, Section 3.3:
7 dc_a/dt = G_a + sum_r S_ar J_r(c) - D_a c_a
9The walker visits every admissible edge of a :class:`ReactionAdmissibilityGraph`,
10evaluates the reaction flux J_r from the edge's rate kernel, and deposits
11the signed stoichiometric contribution S_ar J_r into the affected vertices
12of the flat ODE state vector defined by a :class:`StateLayout`.
14This is a *reference* discrete (one-ODE-per-size) accumulator: it carries
15no host parameters and no EUROFER assumptions — it dispatches purely on
16:class:`EdgeClass`. The size-resolved rate kernels are precomputed
17arrays registered on the RAG by a host material (Layer 2). The
18production solver remains the C++ core; this Python walker is the
19executable specification the C++ must agree with, and the substrate for
20the bin-moment "reconstruct -> walk -> project" reduction.
22Sign convention. Every per-class routine below realises exactly the
23stoichiometric contract fixed in ``EDGE_CLASS_SPEC``; because those signs
24are structural, summed signed-defect content q = chi*n is conserved by
25construction for every intra-host reaction (sources/sinks aside).
27from __future__
import annotations
29from typing
import Dict, Tuple
33from .cluster_identifier
import Polarity, Population
34from .edge_classes
import EdgeClass
35from .rag
import Edge, ReactionAdmissibilityGraph
36from .state_layout
import StateLayout
40 """Assembles the master-equation RHS by walking a RAG edge-by-edge.
44 rag : ReactionAdmissibilityGraph
45 The host's reaction graph, with rate kernels registered as
46 precomputed numpy arrays (indexed 0-based by size-1) or scalars.
48 The ODE-index map. Every discrete size axis must be a block
49 whose ``meta['population']`` is the corresponding
51 boundary : {'reflection', 'absorption'}
52 Treatment of growth/coalescence at the top of a size axis.
53 ``reflection`` (default) suppresses the over-the-top reaction;
54 ``absorption`` lets it fire and drops the product.
57 def __init__(self, rag: ReactionAdmissibilityGraph, layout: StateLayout,
58 boundary: str =
"reflection"):
59 if boundary
not in (
"reflection",
"absorption"):
60 raise ValueError(f
"unknown boundary mode {boundary!r}")
66 self._pop_block: Dict[Tuple[Polarity, str], str] = {}
67 for b
in layout.blocks:
68 if b.kind !=
"discrete":
70 p = b.meta.get(
"population")
71 if isinstance(p, Population):
72 self._pop_block[(p.polarity, p.name)] = b.name
73 if not self._pop_block:
75 "StateLayout has no discrete blocks tagged with a Population")
91 def assemble(self, t: float, y: np.ndarray) -> np.ndarray:
92 """Return dy/dt for state ``y`` at time ``t`` (the graph walk)."""
93 y = np.asarray(y, dtype=float)
94 if y.shape != (self.
layout.N_eq,):
96 f
"state vector has shape {y.shape}, expected ({self.layout.N_eq},)")
97 dydt = np.zeros(self.
layout.N_eq)
100 conc: Dict[Tuple[Polarity, str], np.ndarray] = {}
101 dconc: Dict[Tuple[Polarity, str], np.ndarray] = {}
102 for key, bname
in self._pop_block.items():
103 sl = self.
layout.slice(bname)
104 conc[key] = np.maximum(y[sl], 0.0)
105 dconc[key] = dydt[sl]
108 for edge
in self.
rag.edges:
109 self.
_dispatch[edge.edge_class](edge, ctx)
113 def _key(self, p: Population) -> Tuple[Polarity, str]:
114 k = (p.polarity, p.name)
115 if k
not in self._pop_block:
117 f
"population {p.name!r} ({p.polarity.label}) has no discrete "
118 "block in the StateLayout")
122 """Return the edge's rate kernel as an array (broadcast if scalar)."""
123 return np.asarray(self.
rag.kernel(edge.kernel), dtype=float)
125 def _monomer(self, ctx:
"_WalkContext", polarity: Polarity) -> float:
126 """Current concentration of the ``polarity`` point-defect monomer."""
127 mp = self.
rag.monomer_population(polarity)
128 return float(ctx.conc[(polarity, mp.name)][0])
130 def _monomer_dydt(self, ctx:
"_WalkContext", polarity: Polarity) -> np.ndarray:
131 mp = self.
rag.monomer_population(polarity)
132 return ctx.dconc[(polarity, mp.name)]
135 def _c_growth(self, edge: Edge, ctx:
"_WalkContext") ->
None:
136 """(chi,n) + chi-monomer -> (chi,n+1). Flux K[n] c_n c_1."""
137 k = self.
_key(edge.population)
138 c, dc = ctx.conc[k], ctx.dconc[k]
139 chi = edge.population.polarity
150 """(chi,n) + (-chi)-monomer -> (chi,n-1). SHRINKAGE / RECOMBINATION.
152 The opposite-polarity monomer is consumed (annihilated). At n=1
153 the product is the empty cluster (pure recombination).
155 k = self.
_key(edge.population)
156 c, dc = ctx.conc[k], ctx.dconc[k]
157 chi = edge.population.polarity
159 c1_opp = self.
_monomer(ctx, chi.opposite)
160 flux = K * c * c1_opp
166 """(chi,n) -> (chi,n-1) + chi-monomer. First-order thermal emission."""
167 k = self.
_key(edge.population)
168 c, dc = ctx.conc[k], ctx.dconc[k]
169 chi = edge.population.polarity
177 def _c_source(self, edge: Edge, ctx:
"_WalkContext") ->
None:
178 """(empty) -> (chi,n). Cascade / transmutation injection G_n."""
179 k = self.
_key(edge.population)
182 def _c_sink(self, edge: Edge, ctx:
"_WalkContext") ->
None:
183 """(chi,n) -> (sink). First-order loss D_n c_n at a fixed sink."""
184 k = self.
_key(edge.population)
186 ctx.dconc[k] -= D * ctx.conc[k]
189 """(chi,n,p) -> (chi,n,p'). Population transfer, size fixed."""
190 ks, kd = self.
_key(edge.population), self.
_key(edge.product_population)
192 flux = kap * ctx.conc[ks]
193 ctx.dconc[ks] -= flux
194 ctx.dconc[kd][:flux.size] += flux
197 """(chi,n) + (chi,n') -> (chi,n+n'). Same-polarity binary growth.
199 Reference double sum; the kernel is a 2-D array K[n-1, n'-1].
201 Cross-population product. If the edge carries a ``product_population``
202 (e.g. an SIA <111>+<111> junction whose product is a sessile <100>
203 loop), the gain is deposited into that block while the reactant losses
204 stay on the source and partner blocks. The product keeps the same
205 polarity, so signed-defect content q = chi*n is conserved either way.
207 Pair weighting. When the two reactants are the same population
208 (``same``) the ordered double loop visits each unordered pair twice
209 (and the diagonal once), so the per-event rate carries the 1/2
210 unordered-pair weight. The gain is deposited once per visit at that
211 same weighted rate: summed over the two visits of an off-diagonal pair
212 this yields exactly one product, and over the single visit of the
213 diagonal it yields the 1/2-weighted self-pair product — so the loss
214 (also weighted) and the gain balance and total content is conserved.
216 ka = self.
_key(edge.population)
217 kb = self.
_key(edge.partner_population)
218 ca, cb = ctx.conc[ka], ctx.conc[kb]
219 da, db = ctx.dconc[ka], ctx.dconc[kb]
220 dprod = (ctx.dconc[self.
_key(edge.product_population)]
221 if edge.product_population
is not None else da)
222 K = np.atleast_2d(self.
_kernel(edge))
224 for i
in range(ca.size):
227 for j
in range(cb.size):
233 rate = K[i, j] * ca[i] * cb[j]
242 """(chi,n) + (-chi,n') -> survivor of size |n-n'|. Reference sum."""
243 ka = self.
_key(edge.population)
244 kb = self.
_key(edge.partner_population)
245 ca, cb = ctx.conc[ka], ctx.conc[kb]
246 da, db = ctx.dconc[ka], ctx.dconc[kb]
247 K = np.atleast_2d(self.
_kernel(edge))
248 for i
in range(ca.size):
251 for j
in range(cb.size):
252 rate = K[i, j] * ca[i] * cb[j]
257 d = (i + 1) - (j + 1)
258 if d > 0
and d - 1 < da.size:
260 elif d < 0
and (-d) - 1 < db.size:
265 """(chi,n,c) <-> (chi,n,c +/- e_s).
267 The composition axis is not materialised by the discrete reference
268 walker; in the EUROFER instantiation helium is carried by the
269 mean-loading reduction (Case 1/2), which owns this edge class.
270 Reached only if a host wires an explicit composition axis.
272 raise NotImplementedError(
273 "SOLUTE_TRAPPING requires an explicit composition axis; in the "
274 "EUROFER instantiation it is handled by the He mean-loading "
275 "reduction (core/reductions). Edge: " + edge.label)
279 """Mutable scratch passed to the per-edge contribution routines."""
281 __slots__ = (
"conc",
"dconc",
"dydt",
"layout")
291 """Broadcast a scalar kernel to length ``n``; pass an array through."""
292 arr = np.asarray(kernel, dtype=float)
294 return np.full(n, float(arr))
295 if arr.shape[0] != n:
297 f
"kernel length {arr.shape[0]} does not match size axis {n}")
__init__(self, conc, dconc, dydt, layout)
None _c_solute_trapping(self, Edge edge, "_WalkContext" ctx)
None _c_inter_population(self, Edge edge, "_WalkContext" ctx)
np.ndarray _kernel(self, Edge edge)
None _c_sink(self, Edge edge, "_WalkContext" ctx)
Tuple[Polarity, str] _key(self, Population p)
None _c_dissociation(self, Edge edge, "_WalkContext" ctx)
np.ndarray assemble(self, float t, np.ndarray y)
float _monomer(self, "_WalkContext" ctx, Polarity polarity)
None _c_ladder_down(self, Edge edge, "_WalkContext" ctx)
None _c_annihilation(self, Edge edge, "_WalkContext" ctx)
np.ndarray _monomer_dydt(self, "_WalkContext" ctx, Polarity polarity)
None _c_growth(self, Edge edge, "_WalkContext" ctx)
None _c_source(self, Edge edge, "_WalkContext" ctx)
None _c_coalescence(self, Edge edge, "_WalkContext" ctx)
__init__(self, ReactionAdmissibilityGraph rag, StateLayout layout, str boundary="reflection")
np.ndarray _as_size_array(kernel, int n)