Skip to content

Normalised Persistent Entropy — Synchronisation Detector

The monitor.npe module reports a single scalar summary of the synchronisation state of an oscillator ensemble, built from the H₀ persistence diagram of the pairwise circular-distance matrix. It is SCPN's most-sensitive synchronisation probe — the publication motivating it (Scientific Reports 2025) reports that NPE separates partially-synchronised regimes the standard Kuramoto R cannot distinguish.

The module is the fourth reference implementation of the AttnRes-level module standard: five-language backend chain, bit- exact parity across Rust / Julia / Go vs the NumPy reference, and physical invariants under slow-marked stability tests.


1. Mathematical Formalism

1.1 Circular distance

For an oscillator pair (i, j) with phases θ_i, θ_j ∈ \mathbb{R}, the wrapped circular distance is

\[ d(θ_i, θ_j) \;=\; |{\rm atan2}(\sin(θ_i - θ_j),\; \cos(θ_i - θ_j))| \;\in\; [0,\; π]. \]

phase_distance_matrix(phases) returns the N × N row-major matrix D with D_{ii} = 0 and D_{ij} = d(θ_i, θ_j). D is always symmetric.

1.2 H₀ barcode via single-linkage clustering

Ordering the upper-triangle edges \{(i, j, D_{ij}) : i < j\} by D_{ij} ascending and walking them with a Kruskal-style union-find (union-by-rank + path compression) yields the connectivity graph of N points filtered by distance. Each union event records a lifetime — the distance at which two disjoint components merge — giving exactly N - 1 lifetimes when the graph is fully connected (which it is for d \le π).

The sequence of lifetimes is the H₀ persistence barcode of the filtered Vietoris–Rips complex. The module uses single-linkage as a lightweight substitute for a full ripser-style computation: on a circular 1-sphere the H₀ barcode is the only interesting feature dimension, and single-linkage agrees with Vietoris–Rips on the zero-dimensional classes.

Edges with D_{ij} > \text{max\_radius} are ignored, so max_radius is the filtration cutoff. The default max_radius = π admits every edge.

1.3 Normalised entropy

Given lifetimes \ell_1, \ell_2, \ldots, \ell_m with total T = \sum_k \ell_k, define normalised weights

\[ p_k = \ell_k / T,\qquad H(p) = -\sum_{k: p_k > 0} p_k \log p_k. \]

NPE normalises the entropy by its theoretical maximum \log |\{k: p_k > 0\}|:

\[ \text{NPE} \;=\; \frac{H(p)}{\log |\{k : p_k > 0\}|} \;\in\; [0,\; 1]. \]

Interpretation:

NPE Regime
≈ 0 One dominant lifetime — single tight cluster (fully synchronised).
0 < NPE < 1 Partial synchronisation — mixture of clusters.
≈ 1 Uniform lifetime distribution — incoherent state.

Unlike the Kuramoto order parameter R, NPE is not invariant under a global phase shift (it depends only on the pairwise distances). It is the canonical "shape of synchronisation" summary — R is the "how far from incoherent" summary.

1.4 Why not ripser

A full Vietoris–Rips persistence is O(N^3) for H₀+H₁ and would double the run-time of the module with no gain: on S^1 all persistence beyond H₀ is trivial. The union-find path stays O(N^2 α(N)) and, for SCPN's N ≤ 64, finishes in ~10 µs on the Rust backend.


2. Python API

from scpn_phase_orchestrator.monitor.npe import (
    compute_npe,
    phase_distance_matrix,
    ACTIVE_BACKEND,
    AVAILABLE_BACKENDS,
)

# Pairwise wrapped distance matrix.
D = phase_distance_matrix(phases)          # (N, N) in [0, π]

# Default filtration: max_radius = π.
value = compute_npe(phases)                # scalar in [0, 1]

# Custom filtration cutoff.
value_small = compute_npe(phases, max_radius=0.5)

Key parameters:

  • phases: finite real-valued (N,) array. Wrapping is applied internally — values outside [0, 2π) are fine. Boolean aliases and complex samples, including object-dtype complex aliases, are rejected because the circular-distance geometry is defined on real phase angles.
  • max_radius: finite real scalar filtration cutoff in radians. None (default) means π. Boolean aliases, complex values, negative radii, and values above π are rejected before backend dispatch.
  • Input of size < 2 returns 0.0 (undefined barcode).

Return type: float (NPE) or np.ndarray of shape (N, N) (the pairwise distance matrix).


3. Multi-backend Fallback Chain

>>> from scpn_phase_orchestrator.monitor.npe import (
...     ACTIVE_BACKEND, AVAILABLE_BACKENDS,
... )
>>> ACTIVE_BACKEND, AVAILABLE_BACKENDS
('rust', ['rust', 'mojo', 'julia', 'go', 'python'])
Position Backend Build Implementation notes
1 Rust maturin develop from spo-kernel/crates/spo-ffi Canonical fast path; PyO3 exports phase_distance_matrix and compute_npe.
2 Mojo mojo build mojo/npe.mojo -o mojo/npe_mojo -Xlinker -lm Text-stdin subprocess bridge (PDM / NPE dispatch). Union-find skips path compression because Mojo 0.26 aliasing forbids read+write on the same List in one statement; union-by-rank alone keeps amortised complexity correct.
3 Julia juliacall + julia/npe.jl Full path-compressed union-find, 1-based indices internally.
4 Go go build -buildmode=c-shared -o go/libnpe.so go/npe.go ctypes bridge; PhaseDistanceMatrix and ComputeNPE C-ABI exports.
5 Python always present NumPy / list-based reference. The implementation every compiled backend mirrors.

3.1 Parity budget

Backend phase_distance_matrix compute_npe
Rust 4.44e-16 1.11e-16
Julia 4.44e-16 1.11e-16
Go 4.44e-16 1.11e-16
Mojo 4.44e-16 3.63e-11 (tolerance 1e-9 in tests)
Python 0 (reference) 0 (reference)

The Mojo tolerance is wider because the text-protocol round-trip and log() summation over lifetimes amplify a 17-digit round-trip to ~1e-11; tests use atol = 1e-9 to absorb that.

All backend outputs are revalidated by the Python public boundary: distance matrices must be finite, real-valued, symmetric, zero diagonal, bounded in [0, π], and equal to the exact wrapped atan2(sin(Δθ), cos(Δθ)) reference for every phase pair. Backend scalar NPE values must be finite real numbers in [0, 1] and match the exact H₀ persistent-entropy reference for the supplied max_radius. Invalid or mathematically divergent backend payloads fall back to the NumPy reference implementation instead of entering the physics monitor. Object arrays that contain complex scalar aliases are rejected as non-real before float coercion so mixed ingestion payloads cannot degrade into generic numeric conversions. Numeric-string phase or distance payloads are also rejected before float64 coercion, so text-encoded telemetry cannot silently enter the circular-distance contract.

Direct Go, Julia, and Mojo bridge calls also share the same typed pre-dispatch boundary. Phase vectors are accepted only as finite real one-dimensional float64 arrays with boolean aliases, numeric-string aliases, and complex samples, including object-dtype complex aliases, rejected before optional runtime loading. Direct compute_npe_* calls also validate max_radius as a finite non-negative real cutoff not exceeding π before shared-library, Julia, or subprocess execution. Direct backend outputs are also validated before return: distance outputs must contain exactly N × N finite real values, reshape to a symmetric matrix with zero diagonal, remain bounded in [0, π], reject numeric-string aliases before coercion, and preserve exact wrapped circular distances; scalar NPE outputs must be finite real values in [0, 1] and preserve the exact persistent-entropy scalar up to the backend tolerance. This keeps optional polyglot bridges fail-closed if a shared library, Julia side-file, or subprocess emits malformed or physics-divergent numerical payloads. The Mojo subprocess bridge additionally requires exact stdout cardinality: PDM emits exactly N × N scalar lines, and NPE emits exactly one scalar line. Missing, extra, blank, and non-scalar stdout lines fail closed before the distance-matrix or scalar NPE validators accept and compare the result to the reference contract.


4. Benchmarks

Measured on the local Ubuntu 24.04 host, one warm-up via import plus twenty measured compute_npe calls per backend. These numbers measure the public dispatcher path, including exact phase-distance and exact NPE scalar verification. Reproduce with python benchmarks/npe_benchmark.py --sizes 16 64 256 --calls 20. The release reference suite also runs a deterministic parity gate:

python benchmarks/npe_benchmark.py --parity-gate --sizes 20 --calls 1

The parity gate records every declared backend slot in the canonical order Rust, Mojo, Julia, Go, and Python. Available backends are timed through the public dispatcher and compared against the forced Python reference for both the wrapped circular-distance matrix and the scalar H0 persistent-entropy score. Unavailable toolchains remain explicit records with a reason instead of disappearing from the benchmark evidence. Acceptance requires the Python reference, one record per declared backend, unit-interval NPE evidence, and tolerance-bounded agreement for every available backend (1e-12 for native array bridges, 1e-9 for Mojo text round-trips). The stored reference-suite snapshot exposes the gate as npe_polyglot.

Current available backends for this run: rust, mojo, julia, go, python. The deterministic parity gate passed all five declared backend slots with benchmark hash 1573f0fb380d8b26fb76689bf2626c9e11a7a0f0bac58c25768177764cc28605.

N rust (ms) mojo (ms) julia (ms) go (ms) python (ms)
16 1.0591 270.6090 2.0531 1.3303 0.4345
64 14.3669 201.6425 8.2710 9.3504 4.1184
256 125.9204 1280.4799 266.5458 245.3634 102.5902

The native backends still exercise their compiled kernels, but every public result now pays for a deterministic reference check. This is intentional for the safety boundary: optional acceleration must not silently change the wrapped-distance geometry or H₀ entropy.


5. Physical invariants

5.1 Bounds

NPE ∈ [0, 1]. The Hypothesis test test_npe_bounded verifies this across 100 random shuffles per seed; no input has ever produced a value outside the unit interval in the test history.

5.2 Synchronised limit

If all phases are equal, every circular distance is zero, no merges happen outside the max_radius filter, and the barcode collapses to a single lifetime. The test test_npe_synchronised_low asserts NPE ≈ 0 at atol = 1e-12.

5.3 Incoherent limit

For a uniform 64-point sample of S^1, every pair-wise distance equals the inter-point gap and every lifetime is identical. The entropy H(p) = \log(N - 1) saturates the normalisation, so NPE ≈ 1. test_npe_uniform_high asserts NPE > 0.95.

5.4 Clustering drops NPE

Two tight clusters (std = 0.05) at well-separated centres produce a lifetime distribution dominated by the inter-cluster jump; the remaining intra-cluster lifetimes are near zero. Entropy drops below the uniform baseline. test_npe_drops_with_clustering confirms NPE_\text{clustered} < NPE_\text{uniform} at N \in \{32, 128\}.

5.5 Distance matrix properties

  • D is symmetric (test_pdm_symmetric).
  • D_{ii} = 0 (test_pdm_zero_diagonal).
  • 0 ≤ D_{ij} ≤ π (test_pdm_bounded_pi), even for unwrapped phases ∈ [-10, 10].

6. Usage patterns

6.1 Regime monitoring

from scpn_phase_orchestrator.monitor.npe import compute_npe

npe_trace: list[float] = []
for _ in range(n_steps):
    phases = engine.step(phases, omegas, K, 0.0, 0.0, alpha)
    npe_trace.append(compute_npe(phases))

# np.argmin(npe_trace) → moment of tightest synchronisation.

A single scalar per step can be plotted alongside R(t) to reveal regimes the bulk order parameter misses (e.g. a transient chimera where R is intermediate but clusters are highly structured).

6.2 Filter at a specific radius

npe_local = compute_npe(phases, max_radius=π/4)

Short max_radius captures only the most coherent micro-clusters. Pairs with a distance above the cutoff never merge; their lifetimes do not enter the entropy sum.

5.3 Backend switch for benchmarking

from scpn_phase_orchestrator.monitor import npe as npe_mod

saved = npe_mod.ACTIVE_BACKEND
try:
    npe_mod.ACTIVE_BACKEND = "julia"
    val = compute_npe(phases)
finally:
    npe_mod.ACTIVE_BACKEND = saved

No global state survives the finally — the dispatcher always reads ACTIVE_BACKEND at call time, so this pattern is safe in concurrent code.


6. Tests and benchmarks

File Purpose Count
tests/test_npe.py Pre-existing algorithm tests (sync, incoherent limits)
tests/test_npe_backends.py Per-backend parity (Hypothesis + parametrised) 12
tests/test_npe_benchmark.py Reference-suite parity-gate contract 1
tests/test_npe_stability.py Physical invariants, distance-matrix properties (slow) 5
benchmarks/npe_benchmark.py Multi-backend wall-clock harness

Total: ≥ 17 NPE-specific tests at the standard layering, plus the pre-existing algorithm suite.


7. Performance

Representative per-call timings with all backends loaded on the development host (single-thread, warm cache, N ∈ {16, 64, 256}):

N Rust Python Julia Go Mojo
16 ~10 µs ~80 µs ~30 µs ~50 µs ~50 ms (subprocess)
64 ~50 µs ~500 µs ~100 µs ~250 µs ~55 ms
256 ~400 µs ~6 ms ~600 µs ~3 ms ~70 ms

Rust wins the hot path by an order of magnitude over every pure- language alternative. Mojo pays the subprocess spawn at every call, so it is not suitable for per-step monitoring — the dispatcher places Mojo second in the canonical fastest-first order only on the expectation that the future ctypes-shared-library variant will match the Rust path once Mojo 0.27+ stabilises the UnsafePointer C-ABI. Until then, Mojo is useful as a correctness cross-check, not as a production backend for hot loops.


8. Measured benchmarks

Output from PYTHONPATH=src python benchmarks/npe_benchmark.py --sizes 16 64 256 --calls 50 on the development host (AMD-family x86_64, single thread, release Rust wheel, juliacall 0.9.31 bootstrapped, Mojo 0.26.2, Go 1.24.0):

N Rust Mojo Julia Go Python
16 0.025 ms 67.08 ms 151.17 ms 0.748 ms 0.229 ms
64 0.114 ms 70.14 ms 0.404 ms 0.653 ms 1.555 ms
256 2.237 ms 370.74 ms 7.001 ms 9.255 ms 29.399 ms

Observations:

  • Rust wins every row. The margin over Python grows from 9× at N = 16 to 13× at N = 256 — consistent with the union-find plus sort dominating the runtime at large N.
  • Julia catches Go around N = 64 after the JIT warms up; at N = 256 Julia beats Go by 25 %. N = 16 is an outlier because first-call JIT overhead dominates.
  • Mojo is ruled out of hot loops. Each subprocess spawn costs ~50–100 ms regardless of N; the compiled kernel itself runs in microseconds but the text-protocol round-trip adds an unavoidable floor. The ctypes-shared-library upgrade in Mojo 0.27+ is expected to drop this to Rust parity.
  • Python fallback stays within 12× of Rust at small N but degrades sharply for N ≥ 256 because the union-find uses Python-level dict lookups and per-edge sort objects.

8.1 Call-site budget

A monitor loop that calls compute_npe once per simulation step at dt = 0.01 s must fit the computation inside the integration budget. The Rust backend at N = 16 costs 25 µs per call — well below the 10 ms physical timestep target — so monitor loops at N ≤ 64 are free. Monitor loops at N = 256 spend 2.24 ms per call on Rust, or 22 % of the physical timestep; callers who need sub-1 %-overhead monitoring at that size should cache NPE and re-evaluate every k-th step with k ≥ 25.

8.2 Criterion benchmark (bare Rust kernel)

Not exported yet as a criterion bench target — tracked as a follow-up item in the public roadmap. The Rust kernel source lives at spo-kernel/crates/spo-engine/src/npe.rs and is reachable from cargo test -p spo-engine --lib npe.


9. Pipeline position

NPE is a passive monitor — it reads the current phase vector and returns a scalar; it has no side effects on the integrator, no downstream consumers outside a logging / alerting layer, and is therefore safe to enable or disable at any time.

                     ┌────────────────────┐
    phases(t) ──────▶│   compute_npe     │────▶ NPE(t) scalar
                     │  (union-find over  │
                     │   phase distances) │
                     └────────────────────┘
                     ┌────────────────────┐
    phases(t) ──────▶│ phase_distance_    │────▶ D(t) (N, N)
                     │     matrix         │     (optional diagnostic output)
                     └────────────────────┘

9.1 Canonical usage inside the SPO monitor loop

from scpn_phase_orchestrator.monitor.npe import compute_npe
from scpn_phase_orchestrator.upde.order_params import compute_order_parameter

for step in range(n_steps):
    phases = engine.step(phases, omegas, K, 0.0, 0.0, alpha)
    r, psi = compute_order_parameter(phases)
    npe = compute_npe(phases)
    logger.info(
        "step=%d R=%.3f ψ=%.3f NPE=%.3f regime=%s",
        step, r, psi, npe, _label(r, npe),
    )

The two scalars (R, NPE) paint complementary pictures: R picks up the bulk ordering, NPE picks up the structure of that ordering. A chimera regime typically presents with R ≈ 0.5 and NPE ≈ 0.3 — intermediate coherence, but tightly clustered into two groups — that the single-scalar R cannot disambiguate from a genuine partial-sync fixed point with NPE ≈ 0.8.

9.2 Integration with the regime manager

supervisor.regimes.RegimeManager does not yet read NPE directly. A straightforward extension — promoted to a follow-up item in docs/roadmap.md — is to add an NPE threshold as part of the transition condition into the DEGRADED regime, so the supervisor can react to cluster fragmentation before R itself falls. The monitor is wired but non-voting until that integration lands.


10. Ablations

10.1 Effect of max_radius

Shrinking max_radius from π to π/4 truncates the filtration early: only pairs with distance ≤ π/4 can merge. Lifetimes of the first few merges drop to zero (they enter the barcode with no length), and the entropy sum becomes dominated by the few surviving clusters that reach π/4 without being merged. NPE therefore increases slightly in the partially-synchronised regime (more uniform lifetime distribution among the surviving small clusters) and drops in the incoherent regime (many zero-length merges pull H down).

max_radius Regime at R = 0.2 Regime at R = 0.9
π (default) NPE ≈ 0.85 NPE ≈ 0.05
π / 2 NPE ≈ 0.90 NPE ≈ 0.05
π / 4 NPE ≈ 0.95 NPE ≈ 0.02

Users who only care about the local cluster structure (e.g., the first few merges in a slow-time window) should pick a radius that matches the mean intra-cluster distance.

10.2 Why single-linkage rather than average-linkage

Single-linkage matches the H₀ barcode of the Vietoris–Rips complex exactly — it is mathematically the "right" lifting for NPE and has no free hyperparameter. Average-linkage or Ward linkage would introduce a cluster-shape prior that is not present in the source paper; nothing about the phase distribution on S^1 favours a particular cluster shape, so any such prior would bias the measure toward specific regimes. Single-linkage is the unique linkage rule consistent with the topological interpretation.


11. Implementation cross-reference

File Role
src/scpn_phase_orchestrator/monitor/npe.py Dispatcher + NumPy reference
spo-kernel/crates/spo-engine/src/npe.rs Rust kernel (Kruskal-style union-find)
spo-kernel/crates/spo-ffi/src/lib.rs PyO3 bindings compute_npe, phase_distance_matrix
julia/npe.jl Julia NPE module
go/npe.go Go c-shared library
mojo/npe.mojo Mojo text-stdin executable
src/scpn_phase_orchestrator/monitor/_npe_julia.py juliacall bridge
src/scpn_phase_orchestrator/monitor/_npe_go.py ctypes bridge
src/scpn_phase_orchestrator/monitor/_npe_mojo.py subprocess bridge
tests/test_npe.py Pre-existing algorithm tests
tests/test_npe_backends.py 12 per-backend parity tests
tests/test_npe_stability.py 5 stability tests (slow)
benchmarks/npe_benchmark.py Multi-backend wall-clock harness

12. Design notes

12.1 Mojo union-find without path compression

Mojo 0.26 aliasing rules forbid reading and writing the same List in a single expression. The canonical path-compressing union-find

parent[cur] = parent[parent[cur]]  # two reads + one write: rejected

does not compile. The Mojo port therefore walks the parent chain to the root without compression:

while parent[cur] != cur:
    cur = parent[cur]
return cur

Asymptotics stay O(α(N)) amortised thanks to union-by-rank, which is compatible with Mojo's aliasing rules. Measured Mojo-kernel speed is competitive with Go at N ≤ 64; the loss is dwarfed by the subprocess-spawn cost, so path compression would not change the real-world dispatcher preference anyway.

12.2 Why max_entropy = 1 when only one lifetime survives

The normalised-entropy formula divides by log |{k : p_k > 0}|. With a single positive lifetime, log(1) = 0 and the ratio is undefined. The reference sets max_entropy = 1 in that branch — a numerical shortcut that keeps the return value at 0 (because the numerator is also 0: a single lifetime has no entropy to spread). All five backends take the same shortcut at the same branch so parity holds bit-for-bit.

12.3 Wrapping semantics

phase_distance_matrix takes unwrapped phases and wraps internally via atan2(sin(Δθ), cos(Δθ)). The test test_pdm_bounded_pi passes unwrapped phases in [-10, 10] and confirms every distance stays in [0, π] to within 1e-12. This matters because upstream producers in the SPO pipeline (upde.engine) do wrap the phases at every step, but research callers who integrate outside the engine may forget.

12.4 Why path compression is kept in the Rust / Julia / Go /

Python backends

Mojo is the only backend that hits the aliasing constraint; the other four do path compression freely. Bit-exact parity still holds because compression changes only the path length inside the union-find lookup, not the returned root. All five backends return the same forest structure after every union, so the lifetimes list is identical.


13. Failure modes and diagnostics

13.1 NPE stuck at 0 or 1

  • NPE = 0 for a genuinely synchronised input is correct (all lifetimes equal → H = 0). If NPE is zero for a non- synchronised input, the most likely cause is max_radius set below every pair distance, so no merges happen — check phase_distance_matrix(phases).max().
  • NPE = 1 for a genuinely incoherent input is also correct (uniform lifetime distribution → H = \log(N-1)). If NPE ≈ 1 in a partially-synchronised regime, the cluster structure may be degenerate (e.g. all phases on a rational ring like θ_k = 2πk/N); check the histogram of lifetimes — it should have a clear bimodal shape for real clusters.

13.2 Mojo backend returns a value off by > 1e-9

Outside the tolerance budget. Two causes:

  1. The text-protocol payload truncated on stdin. Verify by running the same input through the Python fallback (ACTIVE_BACKEND = 'python') — if they agree to 1e-12 then the fault is the Mojo bridge.
  2. A genuine numerical drift in the Mojo kernel — the log-summing over lifetimes can amplify the 17-digit-round-trip floor. Re-run on smaller N (the error scales with the number of lifetimes).

13.3 Path-compression regression

If a future Mojo version adds aliasing relaxation and path compression becomes possible, the Mojo kernel must be re-verified against the other four backends — the union-find structure is identical but the intermediate parent[] state differs. The parity tests catch any regression because they run the same input through all loaded backends.


14. References

  • Scientific Reports 2025 — original NPE paper showing the measure outperforms Kuramoto R for synchronisation detection on mean-field Kuramoto ensembles.
  • Edelsbrunner & Harer 2010, Computational Topology: An Introduction — persistence homology background, H₀ barcode interpretation.
  • Chazal, Michel, de Silva, Glisse 2013 — "Geometric Inference for Probability Measures": single-linkage / Vietoris–Rips equivalence for H₀ lifts.
  • Kruskal 1956 — original union-find algorithm used here with union-by-rank + path compression.
  • tests/test_npe_backends.py, tests/test_npe_stability.py — the reference bodies of invariants used in the parity and stability audit.

15. API reference

npe

Normalised persistent entropy of the circular phase-distance matrix.

Computed from the H₀ persistence diagram with a 5-backend fallback chain.

  • phase_distance_matrix — pairwise circular distance in [0, π].
  • compute_npe — normalised H₀ persistent entropy via single- linkage clustering on the distance matrix.

Reference: Scientific Reports 2025 — NPE outperforms Kuramoto R for synchronisation detection. The single-linkage shortcut replaces a full Vietoris–Rips persistence (ripser) with O(N²) union-find.

Functions:

phase_distance_matrix

phase_distance_matrix(phases: FloatArray) -> FloatArray

Pairwise circular distance matrix d[i, j] ∈ [0, π].

Parameters

phases : FloatArray Oscillator phases in radians, shape (N,).

Returns

FloatArray The pairwise circular distance matrix, shape (N, N).

Source code in src/scpn_phase_orchestrator/monitor/npe.py
def phase_distance_matrix(phases: FloatArray) -> FloatArray:
    """Pairwise circular distance matrix ``d[i, j] ∈ [0, π]``.

    Parameters
    ----------
    phases : FloatArray
        Oscillator phases in radians, shape ``(N,)``.

    Returns
    -------
    FloatArray
        The pairwise circular distance matrix, shape ``(N, N)``.
    """
    phases = _validate_phases(phases)
    n = phases.size
    expected = _phase_distance_reference(phases)
    backend_fn = _dispatch("phase_distance_matrix")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray], FloatArray]", backend_fn)
        try:
            flat = fn(np.ascontiguousarray(phases.ravel(), dtype=np.float64))
            return _validate_distance_matrix(flat, n_phases=n, expected=expected)
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    return expected

compute_npe

compute_npe(
    phases: FloatArray, max_radius: float | None = None
) -> float

Normalised Persistent Entropy from H₀ persistence diagram.

NPE = H(p) / log(|p|) where p_i = lifetime_i / Σ lifetimes are the normalised birth-to-death lifetimes of the N − 1 components in the H₀ barcode. Returns values in [0, 1]: ~0 means one dominant cluster (synchronised); ~1 means uniform lifetime distribution (incoherent).

Parameters

phases : FloatArray Oscillator phases in radians, shape (N,). max_radius : float | None Maximum radius for the persistence filtration, or None.

Returns

float The normalised persistent entropy.

Source code in src/scpn_phase_orchestrator/monitor/npe.py
def compute_npe(phases: FloatArray, max_radius: float | None = None) -> float:
    """Normalised Persistent Entropy from H₀ persistence diagram.

    ``NPE = H(p) / log(|p|)`` where ``p_i = lifetime_i / Σ lifetimes``
    are the normalised birth-to-death lifetimes of the ``N − 1``
    components in the H₀ barcode. Returns values in ``[0, 1]``:
    ``~0`` means one dominant cluster (synchronised); ``~1`` means
    uniform lifetime distribution (incoherent).

    Parameters
    ----------
    phases : FloatArray
        Oscillator phases in radians, shape ``(N,)``.
    max_radius : float | None
        Maximum radius for the persistence filtration, or ``None``.

    Returns
    -------
    float
        The normalised persistent entropy.
    """
    phases = _validate_phases(phases)
    n = phases.size
    radius = _validate_max_radius(max_radius)
    if n < 2:
        return 0.0
    expected = _compute_npe_reference(phases, radius)

    backend_fn = _dispatch("compute_npe")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray, float], float]", backend_fn)
        try:
            return _validate_npe_value(
                fn(
                    np.ascontiguousarray(phases.ravel(), dtype=np.float64),
                    radius,
                ),
                expected=expected,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    return expected