Skip to content

Commit 63a176c

Browse files
committed
Add TE-directed adaptive coupling + scaling benchmark (measured)
te_adapt_coupling(): K_ij += lr·TE(i→j) — strengthens coupling along causal information channels (Lizier 2012). Scaling benchmark (measured on GTX 1060 host, Python 3.12, numpy): N=16: 0.03 ms/step (29K steps/s) N=64: 0.09 ms/step (10K steps/s) N=256: 7.0 ms/step (143 steps/s) N=1000: 47 ms/step (21 steps/s, 16 MB) Bottleneck: O(N²) coupling matrix multiply. Co-Authored-By: Arcane Sapience <protoscience@anulum.li>
1 parent 2a4b9d7 commit 63a176c

File tree

7 files changed

+233
-12
lines changed

7 files changed

+233
-12
lines changed

benchmarks/scaling_benchmark.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later | Commercial license available
2+
# © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
3+
# © Code 2020–2026 Miroslav Šotek. All rights reserved.
4+
# ORCID: 0009-0009-3560-0851
5+
# Contact: www.anulum.li | protoscience@anulum.li
6+
# SCPN Phase Orchestrator — Scaling benchmark
7+
8+
"""Measure performance scaling from N=16 to N=4000.
9+
10+
Usage:
11+
python benchmarks/scaling_benchmark.py
12+
python benchmarks/scaling_benchmark.py --output benchmarks/scaling_results.json
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import json
19+
import time
20+
21+
import numpy as np
22+
23+
from scpn_phase_orchestrator.upde.engine import UPDEEngine
24+
from scpn_phase_orchestrator.upde.order_params import compute_order_parameter
25+
26+
27+
def bench_one(n: int, n_steps: int = 100, dt: float = 0.01) -> dict:
28+
rng = np.random.default_rng(42)
29+
phases = rng.uniform(0, 2 * np.pi, n)
30+
omegas = np.ones(n)
31+
knm = np.full((n, n), 0.5 / n)
32+
np.fill_diagonal(knm, 0.0)
33+
alpha = np.zeros((n, n))
34+
35+
engine = UPDEEngine(n, dt=dt)
36+
37+
t0 = time.perf_counter()
38+
for _ in range(n_steps):
39+
phases = engine.step(phases, omegas, knm, 0.0, 0.0, alpha)
40+
elapsed = time.perf_counter() - t0
41+
42+
R, _ = compute_order_parameter(phases)
43+
mem_mb = (knm.nbytes + alpha.nbytes + phases.nbytes) / 1e6
44+
45+
return {
46+
"N": n,
47+
"n_steps": n_steps,
48+
"wall_time_s": round(elapsed, 4),
49+
"ms_per_step": round(elapsed / n_steps * 1000, 3),
50+
"steps_per_sec": round(n_steps / elapsed, 1),
51+
"final_R": round(float(R), 4),
52+
"memory_MB": round(mem_mb, 2),
53+
}
54+
55+
56+
def main() -> None:
57+
parser = argparse.ArgumentParser()
58+
parser.add_argument("--output", type=str, default=None)
59+
parser.add_argument(
60+
"--sizes",
61+
type=int,
62+
nargs="+",
63+
default=[16, 64, 256, 1000],
64+
)
65+
args = parser.parse_args()
66+
67+
print(f"{'N':>6} {'ms/step':>10} {'steps/s':>10} {'R':>8} {'mem MB':>8}")
68+
print("-" * 50)
69+
70+
results = []
71+
for n in args.sizes:
72+
r = bench_one(n)
73+
results.append(r)
74+
print(
75+
f"{r['N']:>6} {r['ms_per_step']:>10.3f} "
76+
f"{r['steps_per_sec']:>10.1f} {r['final_R']:>8.4f} "
77+
f"{r['memory_MB']:>8.2f}"
78+
)
79+
80+
if args.output:
81+
with open(args.output, "w") as f:
82+
json.dump(results, f, indent=2)
83+
print(f"\nSaved to {args.output}")
84+
85+
86+
if __name__ == "__main__":
87+
main()

benchmarks/scaling_results.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[
2+
{
3+
"N": 16,
4+
"n_steps": 100,
5+
"wall_time_s": 0.0034,
6+
"ms_per_step": 0.034,
7+
"steps_per_sec": 29155.4,
8+
"final_R": 0.2323,
9+
"memory_MB": 0.0
10+
},
11+
{
12+
"N": 64,
13+
"n_steps": 100,
14+
"wall_time_s": 0.0093,
15+
"ms_per_step": 0.093,
16+
"steps_per_sec": 10798.7,
17+
"final_R": 0.1441,
18+
"memory_MB": 0.07
19+
},
20+
{
21+
"N": 256,
22+
"n_steps": 100,
23+
"wall_time_s": 0.7003,
24+
"ms_per_step": 7.003,
25+
"steps_per_sec": 142.8,
26+
"final_R": 0.0237,
27+
"memory_MB": 1.05
28+
},
29+
{
30+
"N": 1000,
31+
"n_steps": 100,
32+
"wall_time_s": 4.6746,
33+
"ms_per_step": 46.746,
34+
"steps_per_sec": 21.4,
35+
"final_R": 0.0254,
36+
"memory_MB": 16.01
37+
}
38+
]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later | Commercial license available
2+
# © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
3+
# © Code 2020–2026 Miroslav Šotek. All rights reserved.
4+
# ORCID: 0009-0009-3560-0851
5+
# Contact: www.anulum.li | protoscience@anulum.li
6+
# SCPN Phase Orchestrator — Transfer entropy directed adaptive coupling
7+
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
from numpy.typing import NDArray
12+
13+
from scpn_phase_orchestrator.monitor.transfer_entropy import (
14+
transfer_entropy_matrix,
15+
)
16+
17+
__all__ = ["te_adapt_coupling"]
18+
19+
20+
def te_adapt_coupling(
21+
knm: NDArray,
22+
phase_history: NDArray,
23+
lr: float = 0.01,
24+
decay: float = 0.0,
25+
n_bins: int = 8,
26+
) -> NDArray:
27+
"""Adapt coupling matrix using transfer entropy as learning signal.
28+
29+
K_ij(t+1) = (1-decay) * K_ij(t) + lr * TE(i→j)
30+
31+
Strengthens coupling along causal information flow channels.
32+
Weakens where there is no causal influence.
33+
34+
Lizier 2012, "Local Information Transfer as a Spatiotemporal Filter
35+
for Complex Systems," Physical Review E 77(2):026110.
36+
37+
Args:
38+
knm: current (n, n) coupling matrix.
39+
phase_history: (n, T) recent phase trajectories.
40+
lr: learning rate for TE-based update.
41+
decay: coupling decay rate per update (0 = no decay).
42+
n_bins: histogram bins for TE estimation.
43+
"""
44+
te = transfer_entropy_matrix(phase_history, n_bins=n_bins)
45+
knm_new = (1.0 - decay) * knm + lr * te
46+
np.fill_diagonal(knm_new, 0.0)
47+
result: NDArray = np.maximum(knm_new, 0.0)
48+
return result

src/scpn_phase_orchestrator/supervisor/predictive.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414

1515
from scpn_phase_orchestrator.actuation.mapper import ControlAction
1616
from scpn_phase_orchestrator.monitor.boundaries import BoundaryState
17-
from scpn_phase_orchestrator.supervisor.regimes import Regime
18-
from scpn_phase_orchestrator.upde.engine import UPDEEngine
1917
from scpn_phase_orchestrator.upde.metrics import UPDEState
2018
from scpn_phase_orchestrator.upde.order_params import compute_order_parameter
2119
from scpn_phase_orchestrator.upde.reduction import OttAntonsenReduction

tests/test_coupling_est.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,19 +98,16 @@ def test_single_harmonic_sign_consistent(self):
9898
phases = rng.uniform(0, 2 * np.pi, (3, 500))
9999
omegas = np.ones(3)
100100
knm_std = estimate_coupling(phases, omegas, dt=0.01)
101-
knm_harm = estimate_coupling_harmonics(
102-
phases, omegas, dt=0.01, n_harmonics=1
103-
)
101+
knm_harm = estimate_coupling_harmonics(phases, omegas, dt=0.01, n_harmonics=1)
104102
# Sign of dominant coupling should match
105103
mask = np.abs(knm_std) > 0.1
106104
if np.any(mask):
107-
assert np.sum(np.sign(knm_harm["sin_1"][mask]) == np.sign(knm_std[mask])) > 0
105+
signs_match = np.sign(knm_harm["sin_1"][mask]) == np.sign(knm_std[mask])
106+
assert np.sum(signs_match) > 0
108107

109108
def test_custom_n_harmonics(self):
110109
rng = np.random.default_rng(42)
111110
phases = rng.uniform(0, 2 * np.pi, (3, 100))
112-
result = estimate_coupling_harmonics(
113-
phases, np.ones(3), dt=0.01, n_harmonics=3
114-
)
111+
result = estimate_coupling_harmonics(phases, np.ones(3), dt=0.01, n_harmonics=3)
115112
assert "sin_3" in result
116113
assert "cos_3" in result

tests/test_predictive_supervisor.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,7 @@ def test_hard_violation_overrides(self):
8080
knm = np.eye(4)
8181
alpha = np.zeros((4, 4))
8282
bs = BoundaryState(hard_violations=["test"])
83-
actions = ps.decide(
84-
phases, omegas, knm, alpha, _make_state(0.9), bs
85-
)
83+
actions = ps.decide(phases, omegas, knm, alpha, _make_state(0.9), bs)
8684
assert len(actions) == 1
8785
assert actions[0].knob == "zeta"
8886

tests/test_te_adaptive.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later | Commercial license available
2+
# © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
3+
# © Code 2020–2026 Miroslav Šotek. All rights reserved.
4+
# ORCID: 0009-0009-3560-0851
5+
# Contact: www.anulum.li | protoscience@anulum.li
6+
# SCPN Phase Orchestrator — TE adaptive coupling tests
7+
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
12+
from scpn_phase_orchestrator.coupling.te_adaptive import te_adapt_coupling
13+
14+
15+
class TestTEAdaptive:
16+
def test_output_shape(self):
17+
knm = np.full((4, 4), 0.5)
18+
np.fill_diagonal(knm, 0.0)
19+
rng = np.random.default_rng(42)
20+
history = rng.uniform(0, 2 * np.pi, (4, 100))
21+
result = te_adapt_coupling(knm, history)
22+
assert result.shape == (4, 4)
23+
24+
def test_zero_diagonal(self):
25+
knm = np.full((3, 3), 0.5)
26+
np.fill_diagonal(knm, 0.0)
27+
rng = np.random.default_rng(42)
28+
history = rng.uniform(0, 2 * np.pi, (3, 100))
29+
result = te_adapt_coupling(knm, history)
30+
np.testing.assert_array_equal(np.diag(result), 0.0)
31+
32+
def test_non_negative(self):
33+
knm = np.full((3, 3), 0.1)
34+
np.fill_diagonal(knm, 0.0)
35+
rng = np.random.default_rng(42)
36+
history = rng.uniform(0, 2 * np.pi, (3, 200))
37+
result = te_adapt_coupling(knm, history, lr=0.01)
38+
assert np.all(result >= 0)
39+
40+
def test_coupling_increases(self):
41+
knm = np.full((4, 4), 0.1)
42+
np.fill_diagonal(knm, 0.0)
43+
rng = np.random.default_rng(42)
44+
history = rng.uniform(0, 2 * np.pi, (4, 200))
45+
result = te_adapt_coupling(knm, history, lr=1.0)
46+
# With lr=1.0, TE contribution should increase off-diagonal
47+
assert float(result.sum()) >= float(knm.sum())
48+
49+
def test_decay_reduces(self):
50+
knm = np.full((3, 3), 1.0)
51+
np.fill_diagonal(knm, 0.0)
52+
rng = np.random.default_rng(42)
53+
history = rng.uniform(0, 2 * np.pi, (3, 100))
54+
result = te_adapt_coupling(knm, history, lr=0.0, decay=0.5)
55+
assert float(result.sum()) < float(knm.sum())

0 commit comments

Comments
 (0)