causal-automata 0.1.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.
@@ -0,0 +1,2 @@
1
+ causal-automata
2
+ Copyright 2026 Masakazu Yamagiwa
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: causal-automata
3
+ Version: 0.1.0
4
+ Summary: A blazing-fast, JAX-accelerated Cellular Automaton engine for causal discovery.
5
+ Author-email: Masakazu Yamagiwa <myamagiwa@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: NOTICE
10
+ Requires-Dist: numpy>=1.26
11
+ Requires-Dist: networkx>=3.2
12
+ Requires-Dist: scikit-learn>=1.4
13
+ Provides-Extra: cuda
14
+ Requires-Dist: jax[cuda13]; extra == "cuda"
15
+ Requires-Dist: torch>=2.1; extra == "cuda"
16
+ Requires-Dist: gcastle>=1.0.4; extra == "cuda"
17
+ Provides-Extra: jax
18
+ Requires-Dist: jax>=0.4.30; extra == "jax"
19
+ Requires-Dist: jaxlib>=0.4.30; extra == "jax"
20
+ Provides-Extra: gcastle
21
+ Requires-Dist: gcastle>=1.0.4; extra == "gcastle"
22
+ Requires-Dist: torch>=2.1; extra == "gcastle"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Provides-Extra: docs
26
+ Requires-Dist: sphinx>=7.0; extra == "docs"
27
+ Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
28
+ Requires-Dist: myst-parser>=2.0; extra == "docs"
29
+ Dynamic: license-file
30
+
31
+ # causal-automata
32
+
33
+ A blazing-fast, JAX-accelerated Cellular Automaton engine for causal discovery.
34
+
35
+ ## Benchmark — N=100 on Tesla T4
36
+
37
+ Final figures (Colab **Tesla T4**, GOLEM `num_iter=20000`):
38
+
39
+ | Algorithm | Skeleton F1 | Directed F1 | Wall Time (s) |
40
+ | --- | ---: | ---: | ---: |
41
+ | PC (gcastle) | 0.968 | 0.813 | 528.631 |
42
+ | GOLEM (gcastle) | 0.577 | 0.403 | 251.182 |
43
+ | Bayesian CA (JAX) | 0.812 | 0.483 | 0.323 |
44
+
45
+ Bayesian CA is ~**778×** faster than GOLEM and ~**1636×** faster than PC, while beating GOLEM on both skeleton (**0.812 vs 0.577**) and directed (**0.483 vs 0.403**) F1.
46
+
47
+ ## Documentation
48
+
49
+ Full docs (theory, physics, API, CLI): https://causal-automata.readthedocs.io/en/latest/
50
+
51
+ Build locally from `docs/`:
52
+
53
+ ```bash
54
+ pip install -e ".[docs,jax]"
55
+ cd docs && make html
56
+ ```
57
+
58
+ ## How it works — CA physics + V-well
59
+
60
+ State is a binary DAG adjacency `A`, node elevations `E ∈ [0,1]`, and a fixed empirical covariance `Cov` from continuous observations. Each tick is a local cellular update:
61
+
62
+ 1. **Gravity** — keep only downhill edges (`E[parent] > E[child]`), then drift elevations toward the midpoint of parent/child means.
63
+ 2. **V-structure gravity well** — absent undirected pairs that share neighbors and are marginally independent (`|ρ|` below the death threshold with empty conditioning set) mark shared neighbors as **colliders**. Those elevations are sunk (`E -= 2·lr`), so later birth prefers arrows into the collider — the classic V-structure `i → k ← j`.
64
+ 3. **Death / birth** — kill weak edges and birth strong ones using partial correlations conditioned on the top-`k` union neighbors ranked by `|Cov|`. Birth orients by elevation after the `|ρ|` gate.
65
+
66
+ The NumPy prototype (`bayesian.step`) and the pure functional JAX port (`jax_core.step`, `@jax.jit`) share this physics.
67
+
68
+ ## Install (Colab T4 / CUDA)
69
+
70
+ ```bash
71
+ # Runtime → Change runtime type → T4 GPU
72
+ pip install -U "jax[cuda13]" gcastle torch networkx scikit-learn numpy
73
+ pip install -e .
74
+ ```
75
+
76
+ CPU / portable (no CUDA wheel):
77
+
78
+ ```bash
79
+ pip install -e ".[jax,gcastle]"
80
+ # or: uv sync --extra jax --extra gcastle
81
+ ```
82
+
83
+ Full NVIDIA extra (mirrors the Colab stack in project metadata):
84
+
85
+ ```bash
86
+ pip install -e ".[cuda]"
87
+ ```
88
+
89
+ ## Run
90
+
91
+ ```bash
92
+ # Synthetic DAG harness (NumPy default; use --backend jax for JIT)
93
+ python -m causal_automata.bayesian_synth --n 10
94
+ python -m causal_automata.bayesian_synth --n 50 --backend jax
95
+ python -m causal_automata.bayesian_synth --n 100
96
+
97
+ # Benchmark vs gCastle PC / GOLEM (default N=100)
98
+ python -m causal_automata.benchmark
99
+ python -m causal_automata.benchmark --n 100 --seed 0 --ticks 100
100
+ python -m causal_automata.benchmark --smoke # device checks + GOLEM init only
101
+ ```
102
+
103
+ Smoke the JAX compile:
104
+
105
+ ```bash
106
+ python -c "from causal_automata import warmup; print(warmup())"
107
+ ```
108
+
109
+ ## Documentation
110
+
111
+ Sphinx docs (Read the Docs) live under `docs/`. Build locally:
112
+
113
+ ```bash
114
+ pip install -e ".[docs,jax]"
115
+ cd docs && make html
116
+ ```
117
+
118
+ Configuration for hosted builds is in `.readthedocs.yaml`. Theory pages
119
+ summarize the project manuscripts on causal / Bayesian automata; API pages
120
+ are generated from module docstrings via autodoc.
121
+
122
+ ## Package layout
123
+
124
+ ```
125
+ causal-automata/
126
+ ├── causal_automata/
127
+ │ ├── __init__.py
128
+ │ ├── bayesian.py # NumPy Bayesian CA step
129
+ │ ├── bayesian_synth.py # Synthetic linear-DAG harness
130
+ │ ├── jax_core.py # JAX @jit port of step
131
+ │ └── benchmark.py # vs gCastle PC / GOLEM
132
+ ├── docs/ # Sphinx / Read the Docs
133
+ ├── .readthedocs.yaml
134
+ ├── pyproject.toml
135
+ ├── README.md
136
+ └── NOTICE
137
+ ```
138
+
139
+ ## License / notice
140
+
141
+ See `NOTICE`. Copyright 2026 Masakazu Yamagiwa.
@@ -0,0 +1,111 @@
1
+ # causal-automata
2
+
3
+ A blazing-fast, JAX-accelerated Cellular Automaton engine for causal discovery.
4
+
5
+ ## Benchmark — N=100 on Tesla T4
6
+
7
+ Final figures (Colab **Tesla T4**, GOLEM `num_iter=20000`):
8
+
9
+ | Algorithm | Skeleton F1 | Directed F1 | Wall Time (s) |
10
+ | --- | ---: | ---: | ---: |
11
+ | PC (gcastle) | 0.968 | 0.813 | 528.631 |
12
+ | GOLEM (gcastle) | 0.577 | 0.403 | 251.182 |
13
+ | Bayesian CA (JAX) | 0.812 | 0.483 | 0.323 |
14
+
15
+ Bayesian CA is ~**778×** faster than GOLEM and ~**1636×** faster than PC, while beating GOLEM on both skeleton (**0.812 vs 0.577**) and directed (**0.483 vs 0.403**) F1.
16
+
17
+ ## Documentation
18
+
19
+ Full docs (theory, physics, API, CLI): https://causal-automata.readthedocs.io/en/latest/
20
+
21
+ Build locally from `docs/`:
22
+
23
+ ```bash
24
+ pip install -e ".[docs,jax]"
25
+ cd docs && make html
26
+ ```
27
+
28
+ ## How it works — CA physics + V-well
29
+
30
+ State is a binary DAG adjacency `A`, node elevations `E ∈ [0,1]`, and a fixed empirical covariance `Cov` from continuous observations. Each tick is a local cellular update:
31
+
32
+ 1. **Gravity** — keep only downhill edges (`E[parent] > E[child]`), then drift elevations toward the midpoint of parent/child means.
33
+ 2. **V-structure gravity well** — absent undirected pairs that share neighbors and are marginally independent (`|ρ|` below the death threshold with empty conditioning set) mark shared neighbors as **colliders**. Those elevations are sunk (`E -= 2·lr`), so later birth prefers arrows into the collider — the classic V-structure `i → k ← j`.
34
+ 3. **Death / birth** — kill weak edges and birth strong ones using partial correlations conditioned on the top-`k` union neighbors ranked by `|Cov|`. Birth orients by elevation after the `|ρ|` gate.
35
+
36
+ The NumPy prototype (`bayesian.step`) and the pure functional JAX port (`jax_core.step`, `@jax.jit`) share this physics.
37
+
38
+ ## Install (Colab T4 / CUDA)
39
+
40
+ ```bash
41
+ # Runtime → Change runtime type → T4 GPU
42
+ pip install -U "jax[cuda13]" gcastle torch networkx scikit-learn numpy
43
+ pip install -e .
44
+ ```
45
+
46
+ CPU / portable (no CUDA wheel):
47
+
48
+ ```bash
49
+ pip install -e ".[jax,gcastle]"
50
+ # or: uv sync --extra jax --extra gcastle
51
+ ```
52
+
53
+ Full NVIDIA extra (mirrors the Colab stack in project metadata):
54
+
55
+ ```bash
56
+ pip install -e ".[cuda]"
57
+ ```
58
+
59
+ ## Run
60
+
61
+ ```bash
62
+ # Synthetic DAG harness (NumPy default; use --backend jax for JIT)
63
+ python -m causal_automata.bayesian_synth --n 10
64
+ python -m causal_automata.bayesian_synth --n 50 --backend jax
65
+ python -m causal_automata.bayesian_synth --n 100
66
+
67
+ # Benchmark vs gCastle PC / GOLEM (default N=100)
68
+ python -m causal_automata.benchmark
69
+ python -m causal_automata.benchmark --n 100 --seed 0 --ticks 100
70
+ python -m causal_automata.benchmark --smoke # device checks + GOLEM init only
71
+ ```
72
+
73
+ Smoke the JAX compile:
74
+
75
+ ```bash
76
+ python -c "from causal_automata import warmup; print(warmup())"
77
+ ```
78
+
79
+ ## Documentation
80
+
81
+ Sphinx docs (Read the Docs) live under `docs/`. Build locally:
82
+
83
+ ```bash
84
+ pip install -e ".[docs,jax]"
85
+ cd docs && make html
86
+ ```
87
+
88
+ Configuration for hosted builds is in `.readthedocs.yaml`. Theory pages
89
+ summarize the project manuscripts on causal / Bayesian automata; API pages
90
+ are generated from module docstrings via autodoc.
91
+
92
+ ## Package layout
93
+
94
+ ```
95
+ causal-automata/
96
+ ├── causal_automata/
97
+ │ ├── __init__.py
98
+ │ ├── bayesian.py # NumPy Bayesian CA step
99
+ │ ├── bayesian_synth.py # Synthetic linear-DAG harness
100
+ │ ├── jax_core.py # JAX @jit port of step
101
+ │ └── benchmark.py # vs gCastle PC / GOLEM
102
+ ├── docs/ # Sphinx / Read the Docs
103
+ ├── .readthedocs.yaml
104
+ ├── pyproject.toml
105
+ ├── README.md
106
+ └── NOTICE
107
+ ```
108
+
109
+ ## License / notice
110
+
111
+ See `NOTICE`. Copyright 2026 Masakazu Yamagiwa.
@@ -0,0 +1,14 @@
1
+ """causal-automata: JAX-accelerated cellular automaton engine for causal discovery."""
2
+
3
+ from causal_automata.bayesian import step as numpy_step
4
+ from causal_automata.jax_core import device_info, step, warmup
5
+
6
+ __all__ = [
7
+ "step",
8
+ "numpy_step",
9
+ "device_info",
10
+ "warmup",
11
+ "__version__",
12
+ ]
13
+
14
+ __version__ = "0.1.0"
@@ -0,0 +1,288 @@
1
+ """Bayesian CA update: DAG adjacency evolves via partial-correlation CMI.
2
+
3
+ NumPy prototype. Core state is binary adjacency ``A``, node elevations ``E``,
4
+ and a fixed global covariance ``Cov``. Each tick applies gravity (downhill-only
5
+ edges + elevation drift), a V-structure gravity well that sinks colliders,
6
+ then death / birth from batched partial correlations — no sequential ``N×N``
7
+ edge loops.
8
+
9
+ Birth evaluates all absent undirected pairs (no path-2 filter); elevation
10
+ chooses edge direction after the ``|rho|`` threshold is met. Death/birth
11
+ condition on the top-``k`` neighbors ranked by absolute covariance vs the pair.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+ # Small ridge for sub-covariance inversion (linear-Gaussian precision).
19
+ _RIDGE = 1e-8
20
+
21
+
22
+ def _undirected_neighbors(A: np.ndarray) -> np.ndarray:
23
+ """Binary undirected adjacency with zero diagonal."""
24
+ U = ((A + A.T) > 0).astype(np.float64)
25
+ np.fill_diagonal(U, 0.0)
26
+ return U
27
+
28
+
29
+ def _topk_by_score(
30
+ mask: np.ndarray, scores: np.ndarray, k: int
31
+ ) -> tuple[np.ndarray, np.ndarray]:
32
+ """Take up to ``k`` masked columns per row with highest ``scores``.
33
+
34
+ Invalid (``mask`` False) entries are ignored. Ties break toward the lower
35
+ column index. ``Z`` is left-packed with ``-1`` padding.
36
+
37
+ Parameters
38
+ ----------
39
+ mask:
40
+ Boolean array of shape ``(M, N)`` — candidate pool per row.
41
+ scores:
42
+ Float array of shape ``(M, N)`` — ranking scores (higher is better).
43
+ k:
44
+ Maximum number of indices to keep per row.
45
+
46
+ Returns
47
+ -------
48
+ Z, counts
49
+ ``Z`` has shape ``(M, k)`` with ``-1`` padding; ``counts`` is ``(M,)``.
50
+ """
51
+ M, N = mask.shape
52
+ if k <= 0:
53
+ return np.full((M, 0), -1, dtype=np.intp), np.zeros(M, dtype=np.intp)
54
+
55
+ # Invalid → -inf so they sort after all real scores under descending order.
56
+ ranked = np.where(mask, scores, -np.inf)
57
+ # Ascending sort key: lower is better → high score first; +index for ties.
58
+ sort_key = -ranked + np.arange(N, dtype=np.float64) * 1e-12
59
+ order = np.argsort(sort_key, axis=1)
60
+ top = order[:, :k]
61
+ rows = np.arange(M)[:, None]
62
+ valid = mask[rows, top]
63
+ Z = np.where(valid, top, -1).astype(np.intp)
64
+ counts = valid.sum(axis=1).astype(np.intp)
65
+ return Z, counts
66
+
67
+
68
+ def _batched_partial_corr(
69
+ Cov: np.ndarray,
70
+ I: np.ndarray,
71
+ J: np.ndarray,
72
+ Z: np.ndarray,
73
+ counts: np.ndarray,
74
+ ) -> np.ndarray:
75
+ """Partial correlation of ``(I[m], J[m])`` given ``Z[m, :counts[m]]``.
76
+
77
+ Groups edges by conditioning-set size and inverts stacked sub-covariances
78
+ with a single ``numpy.linalg.inv`` call per group.
79
+ """
80
+ M = I.shape[0]
81
+ rho = np.zeros(M, dtype=np.float64)
82
+ if M == 0:
83
+ return rho
84
+
85
+ k = Z.shape[1] if Z.ndim == 2 else 0
86
+ for n_z in range(0, k + 1):
87
+ idx = np.flatnonzero(counts == n_z)
88
+ if idx.size == 0:
89
+ continue
90
+ parts = [I[idx], J[idx]]
91
+ for t in range(n_z):
92
+ parts.append(Z[idx, t])
93
+ nodes = np.column_stack(parts) # (m, 2+n_z)
94
+ # Advanced indexing: Cov[nodes[a], nodes[b]] for each row.
95
+ sub = Cov[nodes[:, :, None], nodes[:, None, :]].astype(np.float64)
96
+ eye = np.eye(sub.shape[-1], dtype=np.float64)
97
+ # Relative ridge tracks submatrix scale (long SEMs can have large vars).
98
+ scale = np.maximum(
99
+ np.mean(np.diagonal(sub, axis1=-2, axis2=-1), axis=-1), 1e-8
100
+ )
101
+ sub = sub + (_RIDGE + 1e-6 * scale)[:, None, None] * eye
102
+ P = np.linalg.inv(sub)
103
+ denom = np.sqrt(np.maximum(P[:, 0, 0] * P[:, 1, 1], 0.0))
104
+ rho[idx] = np.where(denom > 0.0, -P[:, 0, 1] / denom, 0.0)
105
+ return rho
106
+
107
+
108
+ def _union_neighbor_sets(
109
+ U: np.ndarray,
110
+ I: np.ndarray,
111
+ J: np.ndarray,
112
+ k: int,
113
+ Cov: np.ndarray,
114
+ ) -> tuple[np.ndarray, np.ndarray]:
115
+ """Top-``k`` undirected neighbors of ``i`` or ``j``, ranked by |Cov|.
116
+
117
+ Pool = neighbors of ``i`` or ``j`` (excluding endpoints). Score for
118
+ candidate ``m`` is ``max(|Cov[m,i]|, |Cov[m,j]|)`` so conditioning prefers
119
+ the strongest known confounders rather than lowest index.
120
+ """
121
+ M = I.shape[0]
122
+ if M == 0:
123
+ return np.full((0, k), -1, dtype=np.intp), np.zeros(0, dtype=np.intp)
124
+ # U[I] → (M, N); boolean OR of neighbor rows.
125
+ mask = (U[I] > 0) | (U[J] > 0)
126
+ mask[np.arange(M), I] = False
127
+ mask[np.arange(M), J] = False
128
+ # scores[m, n] = max(|Cov[n, I[m]]|, |Cov[n, J[m]]|)
129
+ score_i = np.abs(Cov[:, I].T)
130
+ score_j = np.abs(Cov[:, J].T)
131
+ scores = np.maximum(score_i, score_j)
132
+ return _topk_by_score(mask, scores, k)
133
+
134
+
135
+ def _vstructure_gravity_well(
136
+ A: np.ndarray,
137
+ E: np.ndarray,
138
+ Cov: np.ndarray,
139
+ *,
140
+ learning_rate: float,
141
+ threshold_death: float,
142
+ ) -> np.ndarray:
143
+ """Sink elevations of collider nodes implied by V-structure pattern.
144
+
145
+ Absent undirected pairs ``(i, j)`` that share neighbor(s) via ``U @ U`` and
146
+ are marginally independent (``|rho| < threshold_death`` with empty ``Z``)
147
+ mark each shared neighbor ``k`` as a collider. Those elevations are lowered
148
+ by ``learning_rate * 2`` (once per tick if flagged), then clipped to
149
+ ``[0, 1]``.
150
+ """
151
+ U = _undirected_neighbors(A)
152
+ undirected_absent = (A + A.T) == 0.0
153
+ np.fill_diagonal(undirected_absent, False)
154
+ shared_counts = U @ U
155
+ np.fill_diagonal(shared_counts, 0.0)
156
+ pair_mask = np.triu(undirected_absent & (shared_counts > 0), k=1)
157
+ Pi, Pj = np.nonzero(pair_mask)
158
+ if Pi.size == 0:
159
+ return E
160
+
161
+ # Marginal / empty-Z partial correlation (batched 2×2 precision).
162
+ Z_empty = np.zeros((Pi.size, 0), dtype=np.intp)
163
+ counts0 = np.zeros(Pi.size, dtype=np.intp)
164
+ rho = _batched_partial_corr(Cov, Pi, Pj, Z_empty, counts0)
165
+ independent = np.abs(rho) < threshold_death
166
+ if not np.any(independent):
167
+ return E
168
+
169
+ Ii = Pi[independent]
170
+ Ij = Pj[independent]
171
+ shared = (U[Ii] > 0) & (U[Ij] > 0) # (M_ind, N)
172
+ collider = shared.any(axis=0) # unique k flagged this tick
173
+ E = E.copy()
174
+ E[collider] -= learning_rate * 2.0
175
+ return np.clip(E, 0.0, 1.0)
176
+
177
+
178
+ def step(
179
+ A: np.ndarray,
180
+ E: np.ndarray,
181
+ Cov: np.ndarray,
182
+ *,
183
+ learning_rate: float = 0.1,
184
+ threshold_death: float = 0.05,
185
+ threshold_birth: float = 0.15,
186
+ k: int = 2,
187
+ ) -> tuple[np.ndarray, np.ndarray]:
188
+ """One Bayesian CA tick: gravity, V-well, then edge death / birth.
189
+
190
+ Parameters
191
+ ----------
192
+ A:
193
+ Binary adjacency ``(N, N)``; ``A[i, j] = 1`` means directed edge ``i→j``.
194
+ E:
195
+ Node elevations ``(N,)``, intended in ``[0, 1]``.
196
+ Cov:
197
+ Global covariance ``(N, N)`` of the continuous observations.
198
+ learning_rate:
199
+ Step size for elevation drift toward parent/child midpoint.
200
+ threshold_death:
201
+ Kill existing edges with ``|rho|`` below this value.
202
+ threshold_birth:
203
+ Birth candidate edges with ``|rho|`` above this value.
204
+ k:
205
+ Cap on conditioning-set size ``|Z|`` (default 2). Death/birth pick the
206
+ top-``k`` neighbors by ``max(|Cov[m,i]|, |Cov[m,j]|)``.
207
+
208
+ Returns
209
+ -------
210
+ A_new, E_new
211
+ Updated binary adjacency and elevations (copies; inputs are not mutated).
212
+ """
213
+ A = np.asarray(A, dtype=np.float64).copy()
214
+ E = np.asarray(E, dtype=np.float64).copy().reshape(-1)
215
+ Cov = np.asarray(Cov, dtype=np.float64)
216
+ if A.ndim != 2 or A.shape[0] != A.shape[1]:
217
+ raise ValueError("A must be a square adjacency matrix")
218
+ N = A.shape[0]
219
+ if E.shape != (N,):
220
+ raise ValueError(f"E must have shape ({N},), got {E.shape}")
221
+ if Cov.shape != (N, N):
222
+ raise ValueError(f"Cov must have shape ({N}, {N}), got {Cov.shape}")
223
+ if k < 0:
224
+ raise ValueError("k must be non-negative")
225
+
226
+ # --- 1. Gravity: downhill-only edges, then elevation drift ---------------
227
+ gravity_ok = E[:, None] > E[None, :]
228
+ np.fill_diagonal(gravity_ok, False)
229
+ A *= gravity_ok
230
+
231
+ parent_count = A.sum(axis=0)
232
+ child_count = A.sum(axis=1)
233
+ parent_sum = A.T @ E
234
+ child_sum = A @ E
235
+ # Isolated side: treat missing mean as the node's own elevation (no pull).
236
+ mean_parents = np.divide(
237
+ parent_sum, parent_count, out=E.copy(), where=parent_count > 0
238
+ )
239
+ mean_children = np.divide(
240
+ child_sum, child_count, out=E.copy(), where=child_count > 0
241
+ )
242
+ E = E + learning_rate * ((mean_parents + mean_children) * 0.5 - E)
243
+ E = np.clip(E, 0.0, 1.0)
244
+
245
+ # --- 2. V-structure gravity well (before death / birth edge updates) -----
246
+ E = _vstructure_gravity_well(
247
+ A,
248
+ E,
249
+ Cov,
250
+ learning_rate=learning_rate,
251
+ threshold_death=threshold_death,
252
+ )
253
+
254
+ # Neighbor structure from the post-gravity DAG (undirected for Z).
255
+ U = _undirected_neighbors(A)
256
+
257
+ # --- 3. Death: weak partial correlation given Cov-ranked neighbor Z -----
258
+ I, J = np.nonzero(A > 0)
259
+ if I.size:
260
+ Z, counts = _union_neighbor_sets(U, I, J, k, Cov)
261
+ rho = _batched_partial_corr(Cov, I, J, Z, counts)
262
+ kill = np.abs(rho) < threshold_death
263
+ if np.any(kill):
264
+ A[I[kill], J[kill]] = 0.0
265
+ U = _undirected_neighbors(A)
266
+
267
+ # --- 4. Birth: all absent undirected pairs; elevation picks direction ----
268
+ # Unordered pairs with no edge either way (batched via upper triangle).
269
+ undirected_absent = (A + A.T) == 0.0
270
+ np.fill_diagonal(undirected_absent, False)
271
+ Bi, Bj = np.nonzero(np.triu(undirected_absent, k=1))
272
+ if Bi.size:
273
+ Z, counts = _union_neighbor_sets(U, Bi, Bj, k, Cov)
274
+ rho = _batched_partial_corr(Cov, Bi, Bj, Z, counts)
275
+ strong = np.abs(rho) > threshold_birth
276
+ # Direction from elevations after the rho gate (equal E → no-op).
277
+ forward = strong & (E[Bi] > E[Bj]) # i → j
278
+ backward = strong & (E[Bj] > E[Bi]) # j → i
279
+ if np.any(forward):
280
+ A[Bi[forward], Bj[forward]] = 1.0
281
+ if np.any(backward):
282
+ A[Bj[backward], Bi[backward]] = 1.0
283
+
284
+ # Binary adjacency; clear flat/uphill edges after collider sinks reshape E.
285
+ A = (A > 0).astype(np.float64)
286
+ A *= E[:, None] > E[None, :]
287
+ np.fill_diagonal(A, 0.0)
288
+ return A, E