Skip to content

Monitor

The monitor subsystem provides 30+ dynamical observers plus STL runtime monitoring for different aspects of oscillator network behavior. Most oscillator simulators provide only the global order parameter R. SPO's monitors detect chimera states, cross-frequency coupling, causal information flow, topological invariants, and thermodynamic irreversibility — phenomena that R alone cannot capture.

Detailed module references:

Hybrid Classical-Quantum Order Parameter

compute_hybrid_entanglement_order_parameter() evaluates local quantum co-simulation evidence only. It combines Kuramoto R/Psi with bipartition Von Neumann entropy, normalised entropy, participation ratio, deterministic record hashing, and the quantum_cosimulation_monitor_not_qpu_execution claim boundary.

The monitor now accepts an explicit simulator_backend contract:

  • numpy_statevector_density_matrix: default compatibility path accepting either statevectors or density matrices.
  • numpy_statevector: requires a one-dimensional statevector payload.
  • numpy_density_matrix: requires a square Hermitian positive-semidefinite density matrix payload representing a pure state. Mixed-state reduced entropy is not promoted as entanglement evidence.

All backends are local NumPy simulators. They do not execute QPU workloads, apply controls, or promote simulator evidence to hardware evidence.

Phase and quantum arrays are validated without text, boolean, or complex-to-real coercion, and malformed array protocols fail at the monitor boundary. Published results independently replay scalar domains, bipartition coverage, entropy normalisation, participation bounds, simulator/no-QPU flags, and the canonical record hash, so direct construction cannot fabricate contradictory audit evidence.

hybrid_order

Classical+quantum co-simulation order monitor.

Computes Kuramoto synchrony and qubit-partition entanglement entropy from either statevectors or density matrices using NumPy only.

Classes

HybridOrderParameterResult dataclass

HybridOrderParameterResult(
    R: float,
    Psi: float,
    entanglement_entropy: float,
    normalised_entanglement_entropy: float,
    participation_ratio: float,
    qubit_count: int,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]],
    backend: str,
    claim_boundary: str,
    non_actuating: bool,
    execution_disabled: bool,
    record_hash: str,
)

Result of a hybrid classical-quantum order-parameter evaluation.

Methods:
__post_init__
__post_init__() -> None

Validate and normalize immutable published evidence.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order.py
def __post_init__(self) -> None:
    """Validate and normalize immutable published evidence."""
    r_value = _finite_real(self.R, name="R")
    if not 0.0 <= r_value <= 1.0:
        raise ValueError("R must be a finite number in [0, 1]")
    psi_value = _finite_real(self.Psi, name="Psi")
    if not 0.0 <= psi_value < 2.0 * np.pi:
        raise ValueError("Psi must be a canonical phase in [0, 2*pi)")
    entropy = _finite_real(self.entanglement_entropy, name="entanglement_entropy")
    if entropy < 0.0:
        raise ValueError("entanglement_entropy must be finite and non-negative")
    normalised = _finite_real(
        self.normalised_entanglement_entropy,
        name="normalised_entanglement_entropy",
    )
    if not 0.0 <= normalised <= 1.0:
        raise ValueError(
            "normalised_entanglement_entropy must be finite and in [0, 1]"
        )
    participation = _finite_real(
        self.participation_ratio, name="participation_ratio"
    )
    qubit_count = _positive_int(self.qubit_count, name="qubit_count")
    partition = _validate_bipartition(
        bipartition=self.bipartition, n_qubits=qubit_count
    )
    max_entropy = float(min(len(partition[0]), len(partition[1])))
    if entropy > max_entropy + 1e-12:
        raise ValueError("entanglement_entropy exceeds the bipartition maximum")
    expected_normalised = entropy / max_entropy
    if not np.isclose(normalised, expected_normalised, rtol=1e-10, atol=1e-12):
        raise ValueError(
            "normalised_entanglement_entropy contradicts entanglement_entropy"
        )
    max_participation = float(1 << len(partition[0]))
    if not 1.0 - 1e-12 <= participation <= max_participation + 1e-12:
        raise ValueError("participation_ratio is outside its bipartition bounds")
    backend = _validate_simulator_backend(self.backend)
    if self.claim_boundary != CLAIM_BOUNDARY:
        raise ValueError("claim_boundary must preserve the no-QPU boundary")
    if self.non_actuating is not True:
        raise ValueError("non_actuating must be exactly True")
    if self.execution_disabled is not True:
        raise ValueError("execution_disabled must be exactly True")

    record = _audit_record_body(
        r_value=r_value,
        psi_value=psi_value,
        entropy=entropy,
        normalised_entropy=normalised,
        participation_ratio=participation,
        qubit_count=qubit_count,
        bipartition=partition,
        backend=backend,
    )
    if not isinstance(self.record_hash, str) or self.record_hash != (
        expected_hash := _deterministic_record_hash(record)
    ):
        raise ValueError("record_hash does not match the canonical evidence")

    object.__setattr__(self, "R", r_value)
    object.__setattr__(self, "Psi", psi_value)
    object.__setattr__(self, "entanglement_entropy", entropy)
    object.__setattr__(self, "normalised_entanglement_entropy", normalised)
    object.__setattr__(self, "participation_ratio", participation)
    object.__setattr__(self, "qubit_count", qubit_count)
    object.__setattr__(self, "bipartition", partition)
    object.__setattr__(self, "backend", backend)
    object.__setattr__(self, "record_hash", expected_hash)
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit record.

Returns

dict[str, object] Return a JSON-safe audit record.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit record.

    Returns
    -------
    dict[str, object]
        Return a JSON-safe audit record.
    """
    record = _audit_record_body(
        r_value=self.R,
        psi_value=self.Psi,
        entropy=self.entanglement_entropy,
        normalised_entropy=self.normalised_entanglement_entropy,
        participation_ratio=self.participation_ratio,
        qubit_count=self.qubit_count,
        bipartition=self.bipartition,
        backend=self.backend,
    )
    record["record_hash"] = self.record_hash
    return record

Functions:

compute_hybrid_entanglement_order_parameter

compute_hybrid_entanglement_order_parameter(
    phases: FloatArray,
    quantum_state: object,
    *,
    qubit_count: int | None = None,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]]
    | None = None,
    simulator_backend: str = BACKEND,
) -> HybridOrderParameterResult

Compute classical R/Psi and the entanglement-aware hybrid order metric.

Parameters

phases : FloatArray Classical phase data. quantum_state : object Vector of length 2**n or density matrix shape (2**n, 2**n). qubit_count : int | None Optional explicit qubit-count override; must match the state. bipartition : tuple[tuple[int, ...], tuple[int, ...]] | None Optional pair of qubit index groups for reduced entropy. simulator_backend : str Explicit local simulator contract. The default accepts either statevector or density-matrix NumPy inputs; "numpy_statevector" and "numpy_density_matrix" require the corresponding payload shape and record that backend explicitly.

Returns

HybridOrderParameterResult HybridOrderParameterResult with a deterministic audit record hash.

Raises

ValueError If the quantum state or bipartition is invalid.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order.py
def compute_hybrid_entanglement_order_parameter(
    phases: FloatArray,
    quantum_state: object,
    *,
    qubit_count: int | None = None,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]] | None = None,
    simulator_backend: str = BACKEND,
) -> HybridOrderParameterResult:
    """Compute classical R/Psi and the entanglement-aware hybrid order metric.

    Parameters
    ----------
    phases : FloatArray
        Classical phase data.
    quantum_state : object
        Vector of length ``2**n`` or density matrix shape ``(2**n, 2**n)``.
    qubit_count : int | None
        Optional explicit qubit-count override; must match the state.
    bipartition : tuple[tuple[int, ...], tuple[int, ...]] | None
        Optional pair of qubit index groups for reduced entropy.
    simulator_backend : str
        Explicit local simulator contract. The default accepts either statevector or
        density-matrix NumPy inputs; ``"numpy_statevector"`` and
        ``"numpy_density_matrix"`` require the corresponding payload shape and record
        that backend explicitly.

    Returns
    -------
    HybridOrderParameterResult
        HybridOrderParameterResult with a deterministic audit record hash.

    Raises
    ------
    ValueError
        If the quantum state or bipartition is invalid.
    """
    phases_clean = _require_finite_float_array(phases, name="phases")
    r_value, psi_value = compute_order_parameter(phases_clean)

    backend = _validate_simulator_backend(simulator_backend)
    n_qubits, density_matrix, state_kind = _validate_quantum_state(quantum_state)
    if backend == "numpy_statevector" and state_kind != "statevector":
        raise ValueError("simulator_backend numpy_statevector requires a statevector")
    if backend == "numpy_density_matrix" and state_kind != "density_matrix":
        raise ValueError(
            "simulator_backend numpy_density_matrix requires a density matrix"
        )

    if qubit_count is None:
        qubit_count = n_qubits
    else:
        qubit_count = _positive_int(qubit_count, name="qubit_count")
        if qubit_count != n_qubits:
            raise ValueError("qubit_count is inconsistent with quantum_state size")

    partition = _validate_bipartition(bipartition=bipartition, n_qubits=qubit_count)
    reduced = _reduced_density_matrix(
        density_matrix=density_matrix,
        subsystem_a=partition[0],
        n_qubits=qubit_count,
    )
    entropy, participation_ratio = _von_neumann_entropy(reduced)
    max_entropy = float(min(len(partition[0]), len(partition[1])))
    normalised_entropy = 0.0 if max_entropy <= 0.0 else entropy / max_entropy
    normalised_entropy = float(np.clip(normalised_entropy, 0.0, 1.0))

    result_payload = _audit_record_body(
        r_value=float(r_value),
        psi_value=float(psi_value),
        entropy=float(entropy),
        normalised_entropy=float(normalised_entropy),
        participation_ratio=float(participation_ratio),
        qubit_count=int(qubit_count),
        bipartition=partition,
        backend=backend,
    )
    result_payload["record_hash"] = _deterministic_record_hash(result_payload)
    return HybridOrderParameterResult(
        R=float(r_value),
        Psi=float(psi_value),
        entanglement_entropy=float(entropy),
        normalised_entanglement_entropy=float(normalised_entropy),
        participation_ratio=float(participation_ratio),
        qubit_count=int(qubit_count),
        bipartition=(tuple(partition[0]), tuple(partition[1])),
        backend=str(result_payload["backend"]),
        claim_boundary=str(result_payload["claim_boundary"]),
        non_actuating=bool(result_payload["non_actuating"]),
        execution_disabled=bool(result_payload["execution_disabled"]),
        record_hash=str(result_payload["record_hash"]),
    )

Hybrid Order Scenario Fixtures

build_hybrid_order_parameter_scenarios() emits deterministic review fixtures for quantum-simulation, power-grid, and cardiac-rhythm examples. Scenario and candidate records are JSON-safe, non-actuating, execution-disabled, and carry the same no-QPU claim boundary for Studio and audit use.

Fixture entropy is computed from the declared bipartition's Schmidt spectrum, not from computational-basis probabilities. Validation replays each candidate's unit-normalised amplitudes, entropy, phase-derived order metrics, unique identity, and scenario hash; coercive phase aliases cannot enter review evidence.

hybrid_order_examples

Deterministic scenario fixtures for quantum co-simulation audit evidence.

The fixtures model hybrid order-parameter audits (entanglement entropy plus classical synchrony metrics) for non-actuating review workflows.

Classes

HybridStateCandidate dataclass

HybridStateCandidate(
    state_id: str,
    candidate_type: str,
    amplitudes: ComplexArray,
    entanglement_entropy: float,
    order_metric_r: float,
    order_metric_psi: float,
    objective_labels: tuple[str, ...],
    non_actuating: bool = True,
    execution_disabled: bool = True,
    claim_boundary: str = HybridBoundary,
)

Deterministic candidate state description for a scenario.

HybridOrderScenario dataclass

HybridOrderScenario(
    domain: str,
    scenario_id: str,
    phases: FloatArray,
    qubit_count: int,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]],
    state_candidates: tuple[HybridStateCandidate, ...],
    objective_labels: tuple[str, ...],
    non_actuating: bool = True,
    execution_disabled: bool = True,
    claim_boundary: str = HybridBoundary,
    scenario_hash: str = "",
)

One deterministic scenario with review-safe outputs.

Functions:

build_hybrid_order_parameter_scenarios

build_hybrid_order_parameter_scenarios() -> tuple[
    dict[str, object], ...
]

Return deterministic, JSON-safe hybrid order-parameter scenarios.

Returns

tuple[dict[str, object], ...] Return deterministic, JSON-safe hybrid order-parameter scenarios.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order_examples.py
def build_hybrid_order_parameter_scenarios() -> tuple[dict[str, object], ...]:
    """Return deterministic, JSON-safe hybrid order-parameter scenarios.

    Returns
    -------
    tuple[dict[str, object], ...]
        Return deterministic, JSON-safe hybrid order-parameter scenarios.
    """
    scenarios = (
        _build_scenario(
            domain="quantum_simulation",
            scenario_id="hybrid_order_quantum_simulation_v1",
            qubit_count=2,
            phase_offset=0.15,
            objective_labels=(
                "quantum_cosimulation_validation",
                "entanglement_audit",
                "classical_phase_coherence",
            ),
        ),
        _build_scenario(
            domain="power_grid",
            scenario_id="hybrid_order_power_grid_v1",
            qubit_count=3,
            phase_offset=0.47,
            objective_labels=(
                "islanding_resilience",
                "frequency_lock",
                "quantum_readout_alignment",
            ),
        ),
        _build_scenario(
            domain="cardiac_rhythm",
            scenario_id="hybrid_order_cardiac_rhythm_v1",
            qubit_count=4,
            phase_offset=1.03,
            objective_labels=(
                "phase_stabilisation",
                "entanglement_envelope",
                "rhythm_quality",
            ),
        ),
    )

    records: list[dict[str, object]] = []
    for scenario in scenarios:
        _validate_scenario(scenario)
        scenario.scenario_hash = _compute_scenario_hash(scenario)
        _validate_scenario(scenario)
        records.append(_to_record(scenario))

    return tuple(records)

Boundary Observer

Detects when oscillator dynamics violate configured safety/performance boundaries. Fires alerts when R drops below R_good threshold or exceeds R_bad threshold. Used by the supervisor to trigger regime transitions.

boundaries

Boundary observer utilities for compartment and event-bus safety checks.

The observer evaluates declared soft and hard partitions against runtime state without mutating the monitored values. Missing state variables are ignored so partially observed deployments can still emit useful diagnostics, while unknown severity policy is treated as a fail-hard configuration error before monitoring starts. Events are emitted through the supplied bus only after checks are classified, preserving a clear separation between detection and actuation.

Classes

BoundaryState dataclass

BoundaryState(
    violations: list[str] = list(),
    soft_violations: list[str] = list(),
    hard_violations: list[str] = list(),
)

Snapshot of boundary violations partitioned by severity.

BoundaryObserver

BoundaryObserver(boundary_defs: list[BoundaryDef])

Check measured values against boundary definitions.

Source code in src/scpn_phase_orchestrator/monitor/boundaries.py
def __init__(self, boundary_defs: list[BoundaryDef]):
    if not isinstance(boundary_defs, list):
        raise TypeError("boundary_defs must be a list[BoundaryDef]")
    normalised_defs: list[BoundaryDef] = []
    for idx, bdef in enumerate(boundary_defs):
        if not isinstance(bdef, BoundaryDef):
            raise TypeError(
                f"boundary_defs[{idx}] must be BoundaryDef, got {bdef!r}"
            )
        if (
            not isinstance(bdef.name, str)
            or not bdef.name.strip()
            or not isinstance(bdef.variable, str)
            or not bdef.variable.strip()
        ):
            raise ValueError(
                f"boundary_defs[{idx}] requires non-empty name and variable"
            )
        if not isinstance(bdef.severity, str):
            raise ValueError(f"boundary_defs[{idx}] severity must be a string")
        lower = None if bdef.lower is None else _finite_real(bdef.lower)
        if bdef.lower is not None and lower is None:
            raise ValueError(
                f"boundary_defs[{idx}] lower bound must be finite real, "
                f"got {bdef.lower!r}"
            )
        upper = None if bdef.upper is None else _finite_real(bdef.upper)
        if bdef.upper is not None and upper is None:
            raise ValueError(
                f"boundary_defs[{idx}] upper bound must be finite real, "
                f"got {bdef.upper!r}"
            )
        if lower is not None and upper is not None and lower >= upper:
            raise ValueError(
                f"boundary_defs[{idx}] requires lower < upper, "
                f"got {bdef.lower!r}>{bdef.upper!r}"
            )
        normalised_defs.append(
            BoundaryDef(
                name=bdef.name,
                variable=bdef.variable,
                lower=lower,
                upper=upper,
                severity=bdef.severity,
            )
        )
    self._defs = tuple(normalised_defs)
    self._event_bus: EventBus | None = None
    self._step = 0
Methods:
set_event_bus
set_event_bus(event_bus: EventBus) -> None

Attach an event bus for posting boundary_breach events.

Parameters

event_bus : EventBus Event bus for posting boundary_breach events.

Raises

TypeError If event_bus is not an EventBus.

Source code in src/scpn_phase_orchestrator/monitor/boundaries.py
def set_event_bus(self, event_bus: EventBus) -> None:
    """Attach an event bus for posting boundary_breach events.

    Parameters
    ----------
    event_bus : EventBus
        Event bus for posting ``boundary_breach`` events.

    Raises
    ------
    TypeError
        If ``event_bus`` is not an ``EventBus``.
    """
    from scpn_phase_orchestrator.supervisor.events import EventBus as _EventBus

    if not isinstance(event_bus, _EventBus):
        raise TypeError(f"event_bus must be EventBus, got {event_bus!r}")
    self._event_bus = event_bus
observe
observe(
    values: dict[str, float], *, step: int | None = None
) -> BoundaryState

Evaluate scalar measurements against configured boundaries.

Parameters

values Mapping from monitored variable name to the current scalar measurement. step Optional supervisor step attached to any posted boundary_breach event. When omitted, the observer reuses its previous step counter.

Returns

BoundaryState Partitioned violation snapshot containing all violations plus soft and hard subsets.

Notes

Missing variables are ignored. Unknown severities are logged and treated as hard violations so safety-critical callers fail closed.

Raises

TypeError If values is not a metric mapping. ValueError If a measurement is non-finite.

Source code in src/scpn_phase_orchestrator/monitor/boundaries.py
def observe(
    self, values: dict[str, float], *, step: int | None = None
) -> BoundaryState:
    """Evaluate scalar measurements against configured boundaries.

    Parameters
    ----------
    values
        Mapping from monitored variable name to the current scalar
        measurement.
    step
        Optional supervisor step attached to any posted
        ``boundary_breach`` event. When omitted, the observer reuses
        its previous step counter.

    Returns
    -------
    BoundaryState
        Partitioned violation snapshot containing all violations plus
        soft and hard subsets.

    Notes
    -----
    Missing variables are ignored. Unknown severities are logged and
    treated as hard violations so safety-critical callers fail closed.

    Raises
    ------
    TypeError
        If ``values`` is not a metric mapping.
    ValueError
        If a measurement is non-finite.
    """
    if not isinstance(values, dict):
        raise TypeError(f"values must be dict[str, float], got {values!r}")
    if step is not None:
        if isinstance(step, bool) or not isinstance(step, Integral) or step < 0:
            raise ValueError(f"step must be a non-negative integer, got {step!r}")
        self._step = int(step)
    state = BoundaryState()
    normalised_values: dict[str, float] = {}
    for name, value in values.items():
        if not isinstance(name, str) or not name.strip():
            raise ValueError(f"value keys must be non-empty strings, got {name!r}")
        normalised = _finite_real(value)
        if normalised is None:
            raise ValueError(
                f"values[{name!r}] must be finite float, got {value!r}"
            )
        normalised_values[name] = normalised
    for bdef in self._defs:
        val = normalised_values.get(bdef.variable)
        if val is None:
            continue

        violated = False
        if bdef.lower is not None and val < bdef.lower:
            violated = True
        if bdef.upper is not None and val > bdef.upper:
            violated = True

        if not violated:
            continue

        msg = (
            f"{bdef.name}: {bdef.variable}={val:.4g} "
            f"outside [{bdef.lower}, {bdef.upper}]"
        )
        state.violations.append(msg)
        if bdef.severity == "soft":
            state.soft_violations.append(msg)
        elif bdef.severity == "hard":
            state.hard_violations.append(msg)
        else:
            logger.warning(
                "unknown severity %r on %s, treating as hard",
                bdef.severity,
                bdef.name,
            )
            state.hard_violations.append(msg)

    if state.violations and self._event_bus is not None:
        from scpn_phase_orchestrator.supervisor.events import RegimeEvent

        self._event_bus.post(
            RegimeEvent(
                kind="boundary_breach",
                step=self._step,
                detail="; ".join(state.violations),
            )
        )

    return state

Coherence Monitor

Tracks the Kuramoto order parameter R over time with configurable thresholds for phase-lock detection. Provides R_good (target coherence) and R_bad (harmful mode-locking) as dual objectives.

coherence

Coherence partition monitoring utilities for layer-bound phase states.

This module computes in-group and out-group Kuramoto-style locking metrics, including R_good/R_bad summaries and PLV-based lock detection. Configuration objects validate layer indices, threshold intervals, CLA terms, and denominator semantics before analysis; invalid layer references fail early instead of being silently clipped into a different biological partition.

Classes

CoherenceMonitor

CoherenceMonitor(
    good_layers: list[int], bad_layers: list[int]
)

Track coherence partitioned into good vs bad layer subsets.

Source code in src/scpn_phase_orchestrator/monitor/coherence.py
def __init__(self, good_layers: list[int], bad_layers: list[int]):
    self._good = _validate_layer_indices(good_layers, name="good_layers")
    self._bad = _validate_layer_indices(bad_layers, name="bad_layers")
    if set(self._good) & set(self._bad):
        raise ValueError("good_layers and bad_layers must be disjoint")
Methods:
compute_r_good
compute_r_good(upde_state: UPDEState) -> float

Mean order parameter R across good (synchronise) layers.

Parameters

upde_state : UPDEState The UPDE state to evaluate.

Returns

float The mean order parameter R over the maintain (good) layers.

Source code in src/scpn_phase_orchestrator/monitor/coherence.py
def compute_r_good(self, upde_state: UPDEState) -> float:
    """Mean order parameter R across good (synchronise) layers.

    Parameters
    ----------
    upde_state : UPDEState
        The UPDE state to evaluate.

    Returns
    -------
    float
        The mean order parameter ``R`` over the maintain (good) layers.
    """
    return float(self._mean_r(upde_state, self._good, name="good_layers"))
compute_r_bad
compute_r_bad(upde_state: UPDEState) -> float

Mean order parameter R across bad (desynchronise) layers.

Parameters

upde_state : UPDEState The UPDE state to evaluate.

Returns

float The mean order parameter R over the suppress (bad) layers.

Source code in src/scpn_phase_orchestrator/monitor/coherence.py
def compute_r_bad(self, upde_state: UPDEState) -> float:
    """Mean order parameter R across bad (desynchronise) layers.

    Parameters
    ----------
    upde_state : UPDEState
        The UPDE state to evaluate.

    Returns
    -------
    float
        The mean order parameter ``R`` over the suppress (bad) layers.
    """
    return float(self._mean_r(upde_state, self._bad, name="bad_layers"))
detect_phase_lock
detect_phase_lock(
    upde_state: UPDEState, threshold: float = 0.9
) -> list[tuple[int, int]]

Return pairs of layer indices whose PLV exceeds threshold.

Uses cross_layer_alignment matrix as the primary PLV source (matches Rust implementation). Falls back to lock_signatures if CLA entry is below threshold but a signature overrides it.

Parameters

upde_state : UPDEState The UPDE state to evaluate. threshold : float Decision threshold.

Returns

list[tuple[int, int]] The layer-index pairs whose PLV exceeds the threshold.

Raises

TypeError If upde_state is not a diagnostic state. ValueError If state structure, alignment evidence, threshold, or a consulted fallback lock signature violates its public contract.

Source code in src/scpn_phase_orchestrator/monitor/coherence.py
def detect_phase_lock(
    self, upde_state: UPDEState, threshold: float = 0.9
) -> list[tuple[int, int]]:
    """Return pairs of layer indices whose PLV exceeds threshold.

    Uses cross_layer_alignment matrix as the primary PLV source
    (matches Rust implementation). Falls back to lock_signatures
    if CLA entry is below threshold but a signature overrides it.

    Parameters
    ----------
    upde_state : UPDEState
        The UPDE state to evaluate.
    threshold : float
        Decision threshold.

    Returns
    -------
    list[tuple[int, int]]
        The layer-index pairs whose PLV exceeds the threshold.

    Raises
    ------
    TypeError
        If ``upde_state`` is not a diagnostic state.
    ValueError
        If state structure, alignment evidence, threshold, or a consulted
        fallback lock signature violates its public contract.
    """
    upde_state = _validate_upde_state(upde_state)
    threshold = _validate_plv_threshold(threshold)
    n = len(upde_state.layers)
    cla = _validate_cross_layer_alignment(
        upde_state.cross_layer_alignment, n_layers=n
    )
    locked = []
    for i in range(n):
        for j in range(i + 1, n):
            # Primary: use CLA matrix (always populated from phase data)
            if cla[i, j] >= threshold:
                locked.append((i, j))
                continue
            # Fallback: explicit lock_signatures (manually set)
            key = f"{i}_{j}"
            signatures = upde_state.layers[i].lock_signatures
            if not isinstance(signatures, dict):
                raise ValueError(f"layer {i} lock signatures must be a dictionary")
            sig = signatures.get(key)
            if (
                sig is not None
                and _validate_lock_signature(sig, source=i, target=j) >= threshold
            ):
                locked.append((i, j))
    return locked

Session Start Gate

Verifies that the oscillator network reaches a minimum coherence threshold before the main control loop engages. Prevents the supervisor from acting on transient startup dynamics.

The gate is fail-closed on malformed evidence: phase and imprint vectors must be one-dimensional real numeric arrays with finite entries and the expected oscillator count, and extractor quality values must be finite floats in [0, 1]. Any violation is recorded as a report error and fails the gate; an invalid n_osc raises instead of reporting.

session_start

Session-start validation gate for extractor, imprint, and coherence inputs.

The validator checks startup preconditions across extractor quality signals, imprint availability, and initial coherence metrics before a session is allowed to proceed. It returns explicit warnings and errors without mutating source state or triggering actuation, keeping the gate suitable for dry-run previews, operator review, and fail-closed orchestration handoffs.

The gate is fail-closed on malformed evidence: phase and imprint vectors must be one-dimensional real numeric arrays with finite entries and the expected oscillator count, and extractor quality values must be finite floats in [0, 1]. Any violation is recorded as an error and fails the gate rather than being silently skipped; quality scoring and coherence metrics are only computed from evidence that passed validation. n_osc is a caller-supplied structural parameter, so an invalid n_osc raises instead of reporting.

Classes

SessionCoherenceReport dataclass

SessionCoherenceReport(
    quality_scores: dict[str, float] = dict(),
    initial_r: float = 0.0,
    imprint_level: float = 0.0,
    warnings: list[str] = list(),
    errors: list[str] = list(),
    passed: bool = True,
)

Results of the session-start coherence gate check.

Functions:

check_session_start

check_session_start(
    phase_states: list[PhaseState],
    initial_phases: FloatArray,
    imprint_state: ImprintState,
    n_osc: int,
) -> SessionCoherenceReport

Validate extraction quality, imprint consistency, and initial coherence.

Parameters

phase_states : list[PhaseState] extracted states from all configured channels. initial_phases : FloatArray phase array that will seed the UPDE engine. imprint_state : ImprintState loaded (or fresh) imprint state. n_osc : int expected oscillator count; must be a positive int.

Returns

SessionCoherenceReport SessionCoherenceReport with pass/fail, quality scores, and diagnostics.

Raises

TypeError If n_osc is not an int (bool excluded). ValueError If n_osc is not positive.

Source code in src/scpn_phase_orchestrator/monitor/session_start.py
def check_session_start(
    phase_states: list[PhaseState],
    initial_phases: FloatArray,
    imprint_state: ImprintState,
    n_osc: int,
) -> SessionCoherenceReport:
    """Validate extraction quality, imprint consistency, and initial coherence.

    Parameters
    ----------
    phase_states : list[PhaseState]
        extracted states from all configured channels.
    initial_phases : FloatArray
        phase array that will seed the UPDE engine.
    imprint_state : ImprintState
        loaded (or fresh) imprint state.
    n_osc : int
        expected oscillator count; must be a positive int.

    Returns
    -------
    SessionCoherenceReport
        SessionCoherenceReport with pass/fail, quality scores, and diagnostics.

    Raises
    ------
    TypeError
        If ``n_osc`` is not an int (bool excluded).
    ValueError
        If ``n_osc`` is not positive.
    """
    if isinstance(n_osc, bool) or not isinstance(n_osc, int):
        raise TypeError(f"n_osc must be an int, got {type(n_osc).__name__}")
    if n_osc < 1:
        raise ValueError(f"n_osc must be positive, got {n_osc}")

    report = SessionCoherenceReport()
    scorer = PhaseQualityScorer()

    # Quality per channel — only scored when every quality value is admissible;
    # a poisoned quality would silently disable the thresholds below.
    if _validate_quality_evidence(phase_states, report):
        by_channel: dict[str, list[PhaseState]] = {}
        for ps in phase_states:
            by_channel.setdefault(ps.channel, []).append(ps)

        for ch, states in by_channel.items():
            q = scorer.score(states)
            report.quality_scores[ch] = q
            if q < 0.3:
                report.warnings.append(
                    f"Channel {ch}: low quality ({q:.2f}); extraction may be unreliable"
                )

        if scorer.detect_collapse(phase_states):
            report.errors.append(
                "Signal collapse: majority of extractors below threshold"
            )
            report.passed = False

    # Imprint consistency
    m_k = _validate_real_vector("Imprint vector m_k", imprint_state.m_k, report)
    if m_k is not None:
        if m_k.shape[0] != n_osc:
            report.errors.append(f"Imprint size mismatch: {m_k.shape[0]} != {n_osc}")
            report.passed = False
        else:
            report.imprint_level = float(np.mean(m_k))

    # Initial coherence from extracted phases — the seed that drives the UPDE
    # engine, so a malformed or wrong-sized vector fails the gate.
    phases = _validate_real_vector("initial_phases", initial_phases, report)
    if phases is not None:
        if phases.shape[0] != n_osc:
            report.errors.append(
                f"Initial phase size mismatch: {phases.shape[0]} != {n_osc}"
            )
            report.passed = False
        else:
            r, _ = compute_order_parameter(phases)
            report.initial_r = float(r)
            if r < 0.05:
                report.warnings.append(
                    f"Low initial coherence (R={r:.3f}); starting from near-chaos"
                )

    return report

Merge Window Monitor

MergeWindowMonitor is the PHA-C.4 gate for moving-frame runs where phase lock and axial position lock must both hold before a merge is accepted. It computes wrapped phase dispersion around theta_ref, axial spatial dispersion around z_ref, and a consecutive joint-lock counter. The monitor reports lock_achieved=True only after the configured number of consecutive samples passes both predicates.

See the Merge Window reference for the contract, use cases, and benchmark command.

merge_window

Phase-and-space merge-window lock monitor.

The PHA-C moving-frame lane tracks phase theta and axial position z for candidate merger/coalescence events. A merge is accepted only when both the wrapped phase dispersion and the axial spatial dispersion remain inside their reviewed tolerances for a configured number of consecutive samples.

Classes

MergeWindowToleranceProfile dataclass

MergeWindowToleranceProfile(
    name: str,
    phase_tol_rad: float,
    spatial_tol_m: float,
    multiplier: float,
    baseline_phase_tol_rad: float,
    baseline_spatial_tol_m: float,
)

Resolved phase and spatial tolerances for a PHA-C merge window.

Methods:
__post_init__
__post_init__() -> None

Validate and normalise the resolved named-profile evidence.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def __post_init__(self) -> None:
    """Validate and normalise the resolved named-profile evidence."""
    name = _validate_profile_name(self.name)
    phase_tol = _validate_tolerance(self.phase_tol_rad, name="phase_tol_rad")
    spatial_tol = _validate_tolerance(self.spatial_tol_m, name="spatial_tol_m")
    multiplier = _validate_positive_scalar(self.multiplier, name="multiplier")
    baseline_phase = _validate_tolerance(
        self.baseline_phase_tol_rad,
        name="baseline_phase_tol_rad",
    )
    baseline_spatial = _validate_tolerance(
        self.baseline_spatial_tol_m,
        name="baseline_spatial_tol_m",
    )
    expected_multiplier = MERGE_WINDOW_TOLERANCE_PROFILE_MULTIPLIERS[name]
    if multiplier != expected_multiplier:
        raise ValueError(
            "name and multiplier must match the reviewed tolerance profile"
        )
    expected_phase_tol = baseline_phase * multiplier
    expected_spatial_tol = baseline_spatial * multiplier
    if not np.isclose(
        phase_tol,
        expected_phase_tol,
        rtol=8.0 * np.finfo(np.float64).eps,
        atol=0.0,
    ):
        raise ValueError(
            "phase_tol_rad must equal baseline_phase_tol_rad * multiplier"
        )
    if not np.isclose(
        spatial_tol,
        expected_spatial_tol,
        rtol=8.0 * np.finfo(np.float64).eps,
        atol=0.0,
    ):
        raise ValueError(
            "spatial_tol_m must equal baseline_spatial_tol_m * multiplier"
        )
    object.__setattr__(self, "name", name)
    object.__setattr__(self, "phase_tol_rad", phase_tol)
    object.__setattr__(self, "spatial_tol_m", spatial_tol)
    object.__setattr__(self, "multiplier", multiplier)
    object.__setattr__(self, "baseline_phase_tol_rad", baseline_phase)
    object.__setattr__(self, "baseline_spatial_tol_m", baseline_spatial)
to_dict
to_dict() -> dict[str, float | str]

Return a JSON-safe tolerance-profile payload.

Returns

dict[str, float | str] Return a JSON-safe tolerance-profile payload.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def to_dict(self) -> dict[str, float | str]:
    """Return a JSON-safe tolerance-profile payload.

    Returns
    -------
    dict[str, float | str]
        Return a JSON-safe tolerance-profile payload.
    """
    return merge_window_tolerance_profile_to_dict(self)

MergeReport dataclass

MergeReport(
    t: float,
    phase_dispersion_rad: float,
    spatial_dispersion_m: float,
    phase_margin_rad: float,
    spatial_margin_m: float,
    phase_locked: bool,
    spatial_locked: bool,
    lock_achieved: bool,
    consecutive_lock_samples: int,
)

Audit-ready merge-window state for one sampled instant.

Attributes
t: Sample timestamp in the caller's runtime units.
phase_dispersion_rad: Maximum wrapped distance to the reference phase.
spatial_dispersion_m: Maximum axial distance to the reference point.
phase_margin_rad: Signed distance from phase tolerance to dispersion.
spatial_margin_m: Signed distance from spatial tolerance to dispersion.
phase_locked: True when phase margin is non-negative.
spatial_locked: True when spatial margin is non-negative.
lock_achieved: True after the required consecutive joint-lock count.
consecutive_lock_samples: Current consecutive joint-lock count.
Methods:
__post_init__
__post_init__() -> None

Validate and normalise directly constructed merge evidence.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def __post_init__(self) -> None:
    """Validate and normalise directly constructed merge evidence."""
    timestamp = _validate_real_scalar(self.t, name="t")
    phase_dispersion = _validate_tolerance(
        self.phase_dispersion_rad,
        name="phase_dispersion_rad",
    )
    spatial_dispersion = _validate_tolerance(
        self.spatial_dispersion_m,
        name="spatial_dispersion_m",
    )
    phase_margin = _validate_real_scalar(
        self.phase_margin_rad,
        name="phase_margin_rad",
    )
    spatial_margin = _validate_real_scalar(
        self.spatial_margin_m,
        name="spatial_margin_m",
    )
    phase_locked = _validate_plain_bool(self.phase_locked, name="phase_locked")
    spatial_locked = _validate_plain_bool(
        self.spatial_locked,
        name="spatial_locked",
    )
    lock_achieved = _validate_plain_bool(
        self.lock_achieved,
        name="lock_achieved",
    )
    consecutive = _validate_sample_count(
        self.consecutive_lock_samples,
        name="consecutive_lock_samples",
        minimum=0,
    )
    if phase_locked is not (phase_margin >= 0.0):
        raise ValueError("phase_locked must match the sign of phase_margin_rad")
    if spatial_locked is not (spatial_margin >= 0.0):
        raise ValueError("spatial_locked must match the sign of spatial_margin_m")
    joint_lock = phase_locked and spatial_locked
    if (joint_lock and consecutive == 0) or (not joint_lock and consecutive != 0):
        raise ValueError(
            "consecutive_lock_samples must be positive exactly when jointly locked"
        )
    if lock_achieved and not joint_lock:
        raise ValueError("lock_achieved requires current phase and spatial lock")
    object.__setattr__(self, "t", timestamp)
    object.__setattr__(self, "phase_dispersion_rad", phase_dispersion)
    object.__setattr__(self, "spatial_dispersion_m", spatial_dispersion)
    object.__setattr__(self, "phase_margin_rad", phase_margin)
    object.__setattr__(self, "spatial_margin_m", spatial_margin)
    object.__setattr__(self, "phase_locked", phase_locked)
    object.__setattr__(self, "spatial_locked", spatial_locked)
    object.__setattr__(self, "lock_achieved", lock_achieved)
    object.__setattr__(self, "consecutive_lock_samples", consecutive)
to_dict
to_dict() -> dict[str, float | int | bool]

Return a JSON-safe representation for audit and benchmark records.

Returns

dict[str, float | int | bool] Return a JSON-safe representation for audit and benchmark records.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def to_dict(self) -> dict[str, float | int | bool]:
    """Return a JSON-safe representation for audit and benchmark records.

    Returns
    -------
    dict[str, float | int | bool]
        Return a JSON-safe representation for audit and benchmark records.
    """
    return merge_window_report_to_dict(self)

MergeWindowMonitor

MergeWindowMonitor(
    *,
    phase_tol_rad: object = DEFAULT_PHASE_TOL_RAD,
    spatial_tol_m: object = DEFAULT_SPATIAL_TOL_M,
    required_consecutive_samples: object = 3,
    tolerance_profile: object | None = None,
)

Stateful consecutive-sample gate for PHA-C merge events.

Initialise the stateful merge gate.

Parameters

phase_tol_rad : object Baseline phase tolerance in radians. spatial_tol_m : object Baseline spatial tolerance in metres. required_consecutive_samples : object Positive joint-lock sample count required for acceptance. tolerance_profile : object | None Reviewed named tolerance profile, or None for explicit values.

Raises

ValueError If a tolerance, count, or named-profile contract is invalid.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def __init__(
    self,
    *,
    phase_tol_rad: object = DEFAULT_PHASE_TOL_RAD,
    spatial_tol_m: object = DEFAULT_SPATIAL_TOL_M,
    required_consecutive_samples: object = 3,
    tolerance_profile: object | None = None,
) -> None:
    """Initialise the stateful merge gate.

    Parameters
    ----------
    phase_tol_rad : object
        Baseline phase tolerance in radians.
    spatial_tol_m : object
        Baseline spatial tolerance in metres.
    required_consecutive_samples : object
        Positive joint-lock sample count required for acceptance.
    tolerance_profile : object | None
        Reviewed named tolerance profile, or ``None`` for explicit values.

    Raises
    ------
    ValueError
        If a tolerance, count, or named-profile contract is invalid.
    """
    self.tolerance_profile = None
    if tolerance_profile is None:
        self.phase_tol_rad = _validate_tolerance(
            phase_tol_rad,
            name="phase_tol_rad",
        )
        self.spatial_tol_m = _validate_tolerance(
            spatial_tol_m,
            name="spatial_tol_m",
        )
    else:
        profile = resolve_merge_window_tolerance_profile(
            tolerance_profile,
            phase_baseline_rad=phase_tol_rad,
            spatial_baseline_m=spatial_tol_m,
        )
        self.tolerance_profile = profile
        self.phase_tol_rad = profile.phase_tol_rad
        self.spatial_tol_m = profile.spatial_tol_m
    self.required_consecutive_samples = _validate_sample_count(
        required_consecutive_samples,
        name="required_consecutive_samples",
        minimum=1,
    )
    self._consecutive_lock_samples = 0
Attributes
consecutive_lock_samples property
consecutive_lock_samples: int

Current consecutive joint-lock count.

Returns

int Current consecutive joint-lock count.

Methods:
reset
reset() -> None

Reset the consecutive joint-lock counter.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def reset(self) -> None:
    """Reset the consecutive joint-lock counter."""
    self._consecutive_lock_samples = 0
evaluate
evaluate(
    phases: ArrayLike,
    positions: ArrayLike,
    *,
    t: object = 0.0,
    reference_phase: object = 0.0,
    reference_point: object = 0.0,
) -> MergeReport

Evaluate one sample and update the consecutive joint-lock counter.

Parameters

phases : ArrayLike Oscillator phases in radians, shape (N,). positions : ArrayLike Absolute axial coordinates per oscillator, shape (N,). t : object Absolute time of the sample in seconds. reference_phase : object Reference phase for the lock criterion, in radians. reference_point : object Reference axial coordinate for the spatial-margin criterion.

Returns

MergeReport The merge-window report with the updated lock counter.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def evaluate(
    self,
    phases: ArrayLike,
    positions: ArrayLike,
    *,
    t: object = 0.0,
    reference_phase: object = 0.0,
    reference_point: object = 0.0,
) -> MergeReport:
    """Evaluate one sample and update the consecutive joint-lock counter.

    Parameters
    ----------
    phases : ArrayLike
        Oscillator phases in radians, shape ``(N,)``.
    positions : ArrayLike
        Absolute axial coordinates per oscillator, shape ``(N,)``.
    t : object
        Absolute time of the sample in seconds.
    reference_phase : object
        Reference phase for the lock criterion, in radians.
    reference_point : object
        Reference axial coordinate for the spatial-margin criterion.

    Returns
    -------
    MergeReport
        The merge-window report with the updated lock counter.
    """
    report = evaluate_merge_window(
        phases,
        positions,
        t=t,
        reference_phase=reference_phase,
        reference_point=reference_point,
        phase_tol_rad=self.phase_tol_rad,
        spatial_tol_m=self.spatial_tol_m,
        required_consecutive_samples=self.required_consecutive_samples,
        prior_consecutive_lock_samples=self._consecutive_lock_samples,
    )
    self._consecutive_lock_samples = report.consecutive_lock_samples
    return report
__call__
__call__(
    phases: ArrayLike,
    positions: ArrayLike,
    *,
    t: object = 0.0,
    reference_phase: object = 0.0,
    reference_point: object = 0.0,
) -> MergeReport

Alias for :meth:evaluate for monitor-pipeline call sites.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def __call__(
    self,
    phases: ArrayLike,
    positions: ArrayLike,
    *,
    t: object = 0.0,
    reference_phase: object = 0.0,
    reference_point: object = 0.0,
) -> MergeReport:
    """Alias for :meth:`evaluate` for monitor-pipeline call sites."""
    return self.evaluate(
        phases,
        positions,
        t=t,
        reference_phase=reference_phase,
        reference_point=reference_point,
    )

Functions:

resolve_merge_window_tolerance_profile

resolve_merge_window_tolerance_profile(
    tolerance_profile: object,
    *,
    phase_baseline_rad: object = DEFAULT_PHASE_TOL_RAD,
    spatial_baseline_m: object = DEFAULT_SPATIAL_TOL_M,
) -> MergeWindowToleranceProfile

Resolve a named PHA-C tolerance profile into numeric tolerances.

Parameters

tolerance_profile : object Named tolerance profile, or None for the baseline. phase_baseline_rad : object Baseline phase tolerance in radians. spatial_baseline_m : object Baseline spatial tolerance in metres.

Returns

MergeWindowToleranceProfile The resolved numeric tolerance profile.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def resolve_merge_window_tolerance_profile(
    tolerance_profile: object,
    *,
    phase_baseline_rad: object = DEFAULT_PHASE_TOL_RAD,
    spatial_baseline_m: object = DEFAULT_SPATIAL_TOL_M,
) -> MergeWindowToleranceProfile:
    """Resolve a named PHA-C tolerance profile into numeric tolerances.

    Parameters
    ----------
    tolerance_profile : object
        Named tolerance profile, or ``None`` for the baseline.
    phase_baseline_rad : object
        Baseline phase tolerance in radians.
    spatial_baseline_m : object
        Baseline spatial tolerance in metres.

    Returns
    -------
    MergeWindowToleranceProfile
        The resolved numeric tolerance profile.
    """
    if isinstance(tolerance_profile, MergeWindowToleranceProfile):
        return tolerance_profile
    name = _validate_profile_name(tolerance_profile)
    phase_baseline = _validate_tolerance(
        phase_baseline_rad,
        name="phase_baseline_rad",
    )
    spatial_baseline = _validate_tolerance(
        spatial_baseline_m,
        name="spatial_baseline_m",
    )
    multiplier = MERGE_WINDOW_TOLERANCE_PROFILE_MULTIPLIERS[name]
    return MergeWindowToleranceProfile(
        name=name,
        phase_tol_rad=phase_baseline * multiplier,
        spatial_tol_m=spatial_baseline * multiplier,
        multiplier=multiplier,
        baseline_phase_tol_rad=phase_baseline,
        baseline_spatial_tol_m=spatial_baseline,
    )

evaluate_merge_window

evaluate_merge_window(
    phases: ArrayLike,
    positions: ArrayLike,
    *,
    t: object = 0.0,
    reference_phase: object = 0.0,
    reference_point: object = 0.0,
    phase_tol_rad: object = DEFAULT_PHASE_TOL_RAD,
    spatial_tol_m: object = DEFAULT_SPATIAL_TOL_M,
    required_consecutive_samples: object = 3,
    prior_consecutive_lock_samples: object = 0,
    tolerance_profile: object | None = None,
) -> MergeReport

Evaluate one PHA-C merge-window sample.

Phase lock is max_i |wrap(theta_i - theta_ref)| <= phase_tol_rad. Spatial lock is max_i |z_i - z_ref| <= spatial_tol_m. The combined lock counter increments only when both predicates pass; otherwise it resets to zero. lock_achieved becomes true once the counter reaches required_consecutive_samples.

Parameters

phases : ArrayLike Oscillator phases in radians, shape (N,). positions : ArrayLike Absolute axial coordinates per oscillator, shape (N,). t : object Absolute time of the sample in seconds. reference_phase : object Reference phase for the lock criterion, in radians. reference_point : object Reference axial coordinate for the spatial-margin criterion. phase_tol_rad : object Phase lock tolerance in radians. spatial_tol_m : object Spatial lock tolerance in metres. required_consecutive_samples : object Consecutive in-tolerance samples required to declare lock. prior_consecutive_lock_samples : object Consecutive lock-sample count carried in from a prior window. tolerance_profile : object | None Named tolerance profile, or None for the baseline.

Returns

MergeReport The merge-window evaluation report for the sample.

Raises

ValueError If any input is invalid.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def evaluate_merge_window(
    phases: ArrayLike,
    positions: ArrayLike,
    *,
    t: object = 0.0,
    reference_phase: object = 0.0,
    reference_point: object = 0.0,
    phase_tol_rad: object = DEFAULT_PHASE_TOL_RAD,
    spatial_tol_m: object = DEFAULT_SPATIAL_TOL_M,
    required_consecutive_samples: object = 3,
    prior_consecutive_lock_samples: object = 0,
    tolerance_profile: object | None = None,
) -> MergeReport:
    """Evaluate one PHA-C merge-window sample.

    Phase lock is ``max_i |wrap(theta_i - theta_ref)| <= phase_tol_rad``.
    Spatial lock is ``max_i |z_i - z_ref| <= spatial_tol_m``. The combined lock
    counter increments only when both predicates pass; otherwise it resets to
    zero. ``lock_achieved`` becomes true once the counter reaches
    ``required_consecutive_samples``.

    Parameters
    ----------
    phases : ArrayLike
        Oscillator phases in radians, shape ``(N,)``.
    positions : ArrayLike
        Absolute axial coordinates per oscillator, shape ``(N,)``.
    t : object
        Absolute time of the sample in seconds.
    reference_phase : object
        Reference phase for the lock criterion, in radians.
    reference_point : object
        Reference axial coordinate for the spatial-margin criterion.
    phase_tol_rad : object
        Phase lock tolerance in radians.
    spatial_tol_m : object
        Spatial lock tolerance in metres.
    required_consecutive_samples : object
        Consecutive in-tolerance samples required to declare lock.
    prior_consecutive_lock_samples : object
        Consecutive lock-sample count carried in from a prior window.
    tolerance_profile : object | None
        Named tolerance profile, or ``None`` for the baseline.

    Returns
    -------
    MergeReport
        The merge-window evaluation report for the sample.

    Raises
    ------
    ValueError
        If any input is invalid.
    """
    phase_vector = _as_float_vector(phases, name="phases")
    position_vector = _as_float_vector(positions, name="positions")
    if position_vector.shape != phase_vector.shape:
        raise ValueError("positions must have the same one-dimensional shape as phases")

    timestamp = _validate_real_scalar(t, name="t")
    phase_reference = _validate_real_scalar(reference_phase, name="reference_phase")
    spatial_reference = _validate_real_scalar(reference_point, name="reference_point")
    if tolerance_profile is None:
        phase_tol = _validate_tolerance(phase_tol_rad, name="phase_tol_rad")
        spatial_tol = _validate_tolerance(spatial_tol_m, name="spatial_tol_m")
    else:
        profile = resolve_merge_window_tolerance_profile(
            tolerance_profile,
            phase_baseline_rad=phase_tol_rad,
            spatial_baseline_m=spatial_tol_m,
        )
        phase_tol = profile.phase_tol_rad
        spatial_tol = profile.spatial_tol_m
    required = _validate_sample_count(
        required_consecutive_samples,
        name="required_consecutive_samples",
        minimum=1,
    )
    prior = _validate_sample_count(
        prior_consecutive_lock_samples,
        name="prior_consecutive_lock_samples",
        minimum=0,
    )

    phase_dispersion = _phase_dispersion_rad(phase_vector, phase_reference)
    spatial_dispersion = _spatial_dispersion_m(position_vector, spatial_reference)
    phase_margin = phase_tol - phase_dispersion
    spatial_margin = spatial_tol - spatial_dispersion
    phase_locked = phase_margin >= 0.0
    spatial_locked = spatial_margin >= 0.0
    consecutive = prior + 1 if phase_locked and spatial_locked else 0
    return MergeReport(
        t=timestamp,
        phase_dispersion_rad=phase_dispersion,
        spatial_dispersion_m=spatial_dispersion,
        phase_margin_rad=phase_margin,
        spatial_margin_m=spatial_margin,
        phase_locked=bool(phase_locked),
        spatial_locked=bool(spatial_locked),
        lock_achieved=bool(consecutive >= required),
        consecutive_lock_samples=consecutive,
    )

merge_window_report_to_dict

merge_window_report_to_dict(
    report: MergeReport,
) -> dict[str, float | int | bool]

Convert a :class:MergeReport into a JSON-safe dictionary.

Parameters

report : MergeReport The merge-window report to serialise.

Returns

dict[str, float | int | bool] The JSON-safe merge-window report dictionary.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def merge_window_report_to_dict(report: MergeReport) -> dict[str, float | int | bool]:
    """Convert a :class:`MergeReport` into a JSON-safe dictionary.

    Parameters
    ----------
    report : MergeReport
        The merge-window report to serialise.

    Returns
    -------
    dict[str, float | int | bool]
        The JSON-safe merge-window report dictionary.
    """
    return {
        "t": float(report.t),
        "phase_dispersion_rad": float(report.phase_dispersion_rad),
        "spatial_dispersion_m": float(report.spatial_dispersion_m),
        "phase_margin_rad": float(report.phase_margin_rad),
        "spatial_margin_m": float(report.spatial_margin_m),
        "phase_locked": bool(report.phase_locked),
        "spatial_locked": bool(report.spatial_locked),
        "lock_achieved": bool(report.lock_achieved),
        "consecutive_lock_samples": int(report.consecutive_lock_samples),
    }

merge_window_tolerance_profile_to_dict

merge_window_tolerance_profile_to_dict(
    profile: MergeWindowToleranceProfile,
) -> dict[str, float | str]

Convert a resolved tolerance profile into a JSON-safe dictionary.

Parameters

profile : MergeWindowToleranceProfile The resolved tolerance profile to serialise.

Returns

dict[str, float | str] The JSON-safe tolerance-profile dictionary.

Source code in src/scpn_phase_orchestrator/monitor/merge_window.py
def merge_window_tolerance_profile_to_dict(
    profile: MergeWindowToleranceProfile,
) -> dict[str, float | str]:
    """Convert a resolved tolerance profile into a JSON-safe dictionary.

    Parameters
    ----------
    profile : MergeWindowToleranceProfile
        The resolved tolerance profile to serialise.

    Returns
    -------
    dict[str, float | str]
        The JSON-safe tolerance-profile dictionary.
    """
    return {
        "name": str(profile.name),
        "phase_tol_rad": float(profile.phase_tol_rad),
        "spatial_tol_m": float(profile.spatial_tol_m),
        "multiplier": float(profile.multiplier),
        "baseline_phase_tol_rad": float(profile.baseline_phase_tol_rad),
        "baseline_spatial_tol_m": float(profile.baseline_spatial_tol_m),
    }

Signal Temporal Logic Runtime Verification

STLMonitor evaluates runtime safety formulas over scalar monitor traces. It uses rtamt when available for full STL syntax and includes a builtin robustness evaluator for common safety forms:

  • always (R >= 0.3)
  • eventually (R >= 0.8)
  • always (R >= 0.85 and amplitude_spread < 0.2)
  • always[0,20] (R >= 0.3) — bounded, holds over the next 20 steps
  • eventually[0,50] (R >= 0.8) — bounded, holds within the next 50 steps

The bounded operators always[a,b] / eventually[a,b] take an integer discrete step window (0 <= a <= b) and reduce the pointwise robustness over that window at the initial time, clamped to the trace end — the value matches what rtamt reports at time zero, so the result is identical whether or not rtamt is installed. A window that starts past the trace end is a vacuous quantifier: always yields +inf and eventually yields -inf. until, nested temporal operators, and other syntax still require the optional rtamt backend and raise a clear ImportError when it is absent.

Positive robustness means the formula is satisfied; negative robustness means violated. evaluate_result() returns an audit-ready result with the formula, robustness, satisfaction boolean, and backend name.

Trace signals are validated at the public boundary before builtin evaluation, rtamt handoff, automaton synthesis, controller synthesis, or closed-loop planning. Each signal must be a one-dimensional, finite, real-valued numeric sequence with no boolean aliases; complex/object-complex payloads and NaN/Inf samples are rejected because they do not define ordered STL predicate robustness.

from scpn_phase_orchestrator.monitor.stl import STLMonitor

monitor = STLMonitor("always (R >= 0.3)")
result = monitor.evaluate_result({"R": [0.9, 0.8, 0.6]})
assert result.satisfied

synthesise_stl_monitoring_automaton() converts supported builtin formulas into an audit-ready runtime automaton. The automaton records the state sequence, trace-indexed transitions, first violation or satisfaction index, pointwise robustness margins, and final satisfaction result.

from scpn_phase_orchestrator.monitor.stl import (
    synthesise_stl_monitoring_automaton,
)

automaton = synthesise_stl_monitoring_automaton(
    "always (R >= 0.3)",
    {"R": [0.9, 0.2, 0.6]},
)
audit_payload = automaton.to_audit_record()
assert audit_payload["states"][1]["first_hit_index"] == 1

Policy YAML integration is available through load_policy_stl_specs(), evaluate_policy_stl_specs(), and synthesise_policy_stl_automata() in scpn_phase_orchestrator.supervisor.policy_rules. This keeps STL specification loading in the policy DSL while preserving STLMonitor and the automata synthesizer as runtime evaluators.

synthesise_stl_controller_candidates() adds the first controller-synthesis linkage. It consumes a builtin STL automaton plus the same trace and emits non-actuating signal-level candidates for the weakest violated predicate. The result is an audit/review artefact only: actuating is always False, and callers must still pass any candidate through policy, projection, safety, and actuation gates.

from scpn_phase_orchestrator.monitor.stl import (
    synthesise_stl_controller_candidates,
)

synthesis = synthesise_stl_controller_candidates(
    automaton,
    {"R": [0.9, 0.2, 0.6]},
    action_map={"R": "raise_coupling"},
)
audit_payload = synthesis.to_audit_record()
assert audit_payload["actuating"] is False

project_stl_controller_candidates() then maps those candidates through explicit policy-approved projection templates and the standard ActionProjector. It still returns a review plan only: actuating remains False, unmapped candidates are rejected with reasons, and the approved entries are bounded ControlAction proposals rather than applied commands.

from scpn_phase_orchestrator.monitor.stl import (
    STLActionProjectionTemplate,
    project_stl_controller_candidates,
)

plan = project_stl_controller_candidates(
    synthesis,
    (
        STLActionProjectionTemplate(
            action="raise_coupling",
            knob="K",
            scope="global",
            base_value=0.9,
            step=10.0,
            ttl_s=0.5,
            previous_value=0.9,
            value_bounds=(0.0, 1.0),
            rate_limit=0.05,
        ),
    ),
)
assert plan.to_audit_record()["actuating"] is False

synthesise_stl_closed_loop_plan() now also records a runtime_actuation_gate audit section. The gate routes projected ControlAction proposals through ActuationMapper using the same explicit projection templates, records deterministic actuator-command evidence, and keeps non_actuating plus execution_disabled true. This is the intended use case for STL closed-loop planning: prove that a violated safety formula can be translated into bounded, mapper-valid runtime actions for operator review without enabling live actuation.

from scpn_phase_orchestrator.monitor.stl import (
    synthesise_stl_closed_loop_plan,
)

closed_loop_plan = synthesise_stl_closed_loop_plan(
    automaton,
    {"R": [0.1, 0.2, 0.75]},
    (projection_template,),
    horizon_steps=4,
    action_map={"R": "raise_coupling"},
)
gate = closed_loop_plan.to_audit_record()["runtime_actuation_gate"]
assert gate["execution_disabled"] is True

Curated phase-field specification catalogue

PHASE_FIELD_SPECIFICATIONS is a small, curated catalogue of named single-signal safety properties for Kuramoto-type phase fields — an order-parameter floor, a coupling-gain ceiling, a chimera-index ceiling, a Sakaguchi phase-lag bound, and a winding-stability bound. Each PhaseFieldSpecification renders a builtin-compatible STL formula, so it evaluates without rtamt, and carries a physical rationale plus a soft/hard severity tier. The thresholds are documented engineering defaults, not empirically fitted constants: robustness measures runtime signal margin, it is not a formal proof of correctness. Look one up by name with phase_field_specification() and enumerate the keys with phase_field_specification_names().

from scpn_phase_orchestrator.monitor.stl import phase_field_specification

spec = phase_field_specification("order_parameter_floor")
assert spec.spec == "always (R >= 0.3)"
result = spec.evaluate({"R": [0.9, 0.8, 0.6]})
assert result.satisfied and result.backend == "builtin"

stl

Signal Temporal Logic monitor, synthesis, and runtime actuation gating.

rtamt is an optional dependency: pip install rtamt. The implementation is split into responsibility modules (monitor, automaton synthesis, controller synthesis, action projection, runtime actuation gate, and closed-loop plan) behind a stable re-export surface; HAS_RTAMT reports rtamt availability.

Classes

STLRuntimeActuationGate dataclass

STLRuntimeActuationGate(
    spec: str,
    non_actuating: bool,
    execution_disabled: bool,
    accepted: bool,
    action_count: int,
    mapper_valid_action_count: int,
    mapped_command_count: int,
    commands: tuple[dict[str, object], ...],
    blocked_reasons: tuple[str, ...],
)

Non-actuating runtime-stack validation of projected STL actions.

The gate verifies projected proposals against the same actuator mapping boundary used by runtime actuation, but it never enables execution. This makes the closed-loop STL plan auditable through the safety/actuation stack without converting a review artefact into a live controller command.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable runtime gate record.

Returns

dict[str, object] Return a JSON-serialisable runtime gate record.

Source code in src/scpn_phase_orchestrator/monitor/stl/actuation_gate.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable runtime gate record.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable runtime gate record.
    """
    return {
        "spec": self.spec,
        "non_actuating": self.non_actuating,
        "execution_disabled": self.execution_disabled,
        "accepted": self.accepted,
        "action_count": self.action_count,
        "mapper_valid_action_count": self.mapper_valid_action_count,
        "mapped_command_count": self.mapped_command_count,
        "commands": [dict(command) for command in self.commands],
        "blocked_reasons": list(self.blocked_reasons),
    }

STLAutomatonState dataclass

STLAutomatonState(
    name: str,
    accepting: bool,
    violation: bool,
    first_hit_index: int | None = None,
)

State in a synthesized STL monitoring automaton.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable automaton-state payload.

Returns

dict[str, object] Return a JSON-serialisable automaton-state payload.

Source code in src/scpn_phase_orchestrator/monitor/stl/automaton.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable automaton-state payload.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable automaton-state payload.
    """
    return {
        "name": self.name,
        "accepting": self.accepting,
        "violation": self.violation,
        "first_hit_index": self.first_hit_index,
    }

STLAutomatonTransition dataclass

STLAutomatonTransition(
    source: str,
    target: str,
    time_index: int,
    guard: str,
    robustness: float,
)

Trace-indexed transition taken by a runtime STL automaton.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable automaton-transition payload.

Returns

dict[str, object] Return a JSON-serialisable automaton-transition payload.

Source code in src/scpn_phase_orchestrator/monitor/stl/automaton.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable automaton-transition payload.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable automaton-transition payload.
    """
    return {
        "source": self.source,
        "target": self.target,
        "time_index": self.time_index,
        "guard": self.guard,
        "robustness": self.robustness,
    }

STLMonitoringAutomaton dataclass

STLMonitoringAutomaton(
    spec: str,
    temporal_op: str,
    signals: tuple[str, ...],
    states: tuple[STLAutomatonState, ...],
    transitions: tuple[STLAutomatonTransition, ...],
    robustness: float,
    satisfied: bool,
    backend: str = "builtin",
)

Audit-ready runtime automaton synthesized from a simple STL monitor.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable STL automaton audit payload.

Returns

dict[str, object] Return a JSON-serialisable STL automaton audit payload.

Source code in src/scpn_phase_orchestrator/monitor/stl/automaton.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable STL automaton audit payload.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable STL automaton audit payload.
    """
    return {
        "spec": self.spec,
        "temporal_op": self.temporal_op,
        "signals": list(self.signals),
        "states": [state.to_audit_record() for state in self.states],
        "transitions": [
            transition.to_audit_record() for transition in self.transitions
        ],
        "robustness": self.robustness,
        "satisfied": self.satisfied,
        "backend": self.backend,
    }

STLClosedLoopSynthesisPlan dataclass

STLClosedLoopSynthesisPlan(
    spec: str,
    trace_length: int,
    horizon_steps: int,
    next_review_start_index: int,
    next_review_end_index: int,
    feedback_signals: tuple[str, ...],
    satisfied: bool,
    actuating: bool,
    synthesis: STLControllerSynthesis,
    projected_plan: STLProjectedActionPlan,
    runtime_gate: STLRuntimeActuationGate,
    blocked_reasons: tuple[str, ...],
)

Offline closed-loop STL controller plan for operator review.

The plan binds the current monitor state, signal feedback surface, projected action proposals, and next review horizon. It is intentionally non-actuating: callers must still pass approved actions through runtime policy, safety, and actuation gates before any live controller can use them.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable closed-loop synthesis plan.

Returns

dict[str, object] Return a JSON-serialisable closed-loop synthesis plan.

Source code in src/scpn_phase_orchestrator/monitor/stl/closed_loop.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable closed-loop synthesis plan.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable closed-loop synthesis plan.
    """
    return {
        "spec": self.spec,
        "trace_length": self.trace_length,
        "horizon_steps": self.horizon_steps,
        "next_review_start_index": self.next_review_start_index,
        "next_review_end_index": self.next_review_end_index,
        "feedback_signals": list(self.feedback_signals),
        "satisfied": self.satisfied,
        "actuating": self.actuating,
        "controller_synthesis": self.synthesis.to_audit_record(),
        "projected_action_plan": self.projected_plan.to_audit_record(),
        "runtime_actuation_gate": self.runtime_gate.to_audit_record(),
        "blocked_reasons": list(self.blocked_reasons),
    }

STLControllerCandidate dataclass

STLControllerCandidate(
    signal: str,
    action: str,
    direction: str,
    time_index: int,
    robustness: float,
    rationale: str,
)

Non-actuating controller candidate derived from an STL automaton.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable controller-candidate payload.

Returns

dict[str, object] Return a JSON-serialisable controller-candidate payload.

Source code in src/scpn_phase_orchestrator/monitor/stl/controller.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable controller-candidate payload.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable controller-candidate payload.
    """
    return {
        "signal": self.signal,
        "action": self.action,
        "direction": self.direction,
        "time_index": self.time_index,
        "robustness": self.robustness,
        "rationale": self.rationale,
    }

STLControllerSynthesis dataclass

STLControllerSynthesis(
    spec: str,
    satisfied: bool,
    actuating: bool,
    source_backend: str,
    candidates: tuple[STLControllerCandidate, ...],
)

Audit-ready, non-actuating controller synthesis proposal.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable controller-synthesis payload.

Returns

dict[str, object] Return a JSON-serialisable controller-synthesis payload.

Source code in src/scpn_phase_orchestrator/monitor/stl/controller.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable controller-synthesis payload.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable controller-synthesis payload.
    """
    return {
        "spec": self.spec,
        "satisfied": self.satisfied,
        "actuating": self.actuating,
        "source_backend": self.source_backend,
        "candidates": [
            candidate.to_audit_record() for candidate in self.candidates
        ],
    }

STLMonitor

STLMonitor(spec: str)

Evaluate STL specifications against numeric traces.

Parameters

spec : str An STL specification string, e.g. "always (sync_error <= 0.3)". The builtin backend evaluates the unbounded operators always/eventually and their bounded forms always[a,b]/eventually[a,b] (integer discrete step window, 0 <= a <= b) over a conjunction of atomic predicates; until, nesting, and other syntax require the optional rtamt backend.

Source code in src/scpn_phase_orchestrator/monitor/stl/monitor.py
def __init__(self, spec: str) -> None:
    self._spec_str = spec
    self._simple = _parse_simple_spec(spec)
    self._bounded = _parse_bounded_spec(spec) if self._simple is None else None
    self._stl = rtamt.StlDiscreteTimeSpecification() if rtamt is not None else None
    self._parsed = False
Methods:
evaluate
evaluate(trace: dict[str, list[float]]) -> float

Return the robustness value of spec over trace.

A positive value means the specification is satisfied; negative means violated. The magnitude indicates how far from the boundary.

Parameters

trace : dict[str, list[float]] Signal trace keyed by variable name, each a list of floats.

Returns

float The robustness value of the specification over the trace.

Raises

ImportError If the rtamt STL backend is not installed.

Source code in src/scpn_phase_orchestrator/monitor/stl/monitor.py
def evaluate(self, trace: dict[str, list[float]]) -> float:
    """Return the robustness value of *spec* over *trace*.

    A positive value means the specification is satisfied; negative
    means violated.  The magnitude indicates how far from the boundary.

    Parameters
    ----------
    trace : dict[str, list[float]]
        Signal trace keyed by variable name, each a list of floats.

    Returns
    -------
    float
        The robustness value of the specification over the trace.

    Raises
    ------
    ImportError
        If the rtamt STL backend is not installed.
    """
    _validate_trace(trace)
    length = len(next(iter(trace.values())))

    if self._simple is not None:
        return _evaluate_simple(self._simple, trace)

    if self._bounded is not None:
        return _evaluate_bounded(self._bounded, trace)

    if self._stl is None:
        raise ImportError(
            "rtamt is required for this STL syntax. Install: pip install rtamt"
        )

    if not self._parsed:
        for name in trace:
            self._stl.declare_var(name, "float")
        self._stl.spec = self._spec_str
        self._stl.parse()
        self._parsed = True

    # rtamt discrete-time offline: flat lists per signal + 'time' key
    datasets: dict[str, list[float]] = {}
    for name in trace:
        datasets[name] = _trace_signal_array(name, trace).tolist()
    if "time" not in datasets:
        datasets["time"] = [float(t) for t in range(length)]

    robustness = self._stl.evaluate(datasets)
    # rtamt returns [[time, robustness], ...]; min is worst-case
    if isinstance(robustness, list) and robustness:
        return float(min(r[1] for r in robustness))
    return float(robustness)
evaluate_result
evaluate_result(
    trace: dict[str, list[float]],
) -> STLTraceResult

Evaluate and return robustness plus audit metadata.

Parameters

trace : dict[str, list[float]] Signal trace keyed by variable name, each a list of floats.

Returns

STLTraceResult The robustness value plus audit metadata.

Source code in src/scpn_phase_orchestrator/monitor/stl/monitor.py
def evaluate_result(self, trace: dict[str, list[float]]) -> STLTraceResult:
    """Evaluate and return robustness plus audit metadata.

    Parameters
    ----------
    trace : dict[str, list[float]]
        Signal trace keyed by variable name, each a list of floats.

    Returns
    -------
    STLTraceResult
        The robustness value plus audit metadata.
    """
    robustness = self.evaluate(trace)
    backend = "builtin" if self._is_builtin else "rtamt"
    return STLTraceResult(
        spec=self._spec_str,
        robustness=robustness,
        satisfied=robustness >= 0.0,
        backend=backend,
    )

STLTraceResult dataclass

STLTraceResult(
    spec: str,
    robustness: float,
    satisfied: bool,
    backend: str,
)

Robustness summary for an STL monitor evaluation.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable STL audit payload.

Returns

dict[str, object] Return a JSON-serialisable STL audit payload.

Source code in src/scpn_phase_orchestrator/monitor/stl/monitor.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable STL audit payload.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable STL audit payload.
    """
    return {
        "spec": self.spec,
        "robustness": self.robustness,
        "satisfied": self.satisfied,
        "backend": self.backend,
    }

STLActionProjectionTemplate dataclass

STLActionProjectionTemplate(
    action: str,
    knob: str,
    scope: str,
    base_value: float,
    step: float,
    ttl_s: float,
    previous_value: float,
    value_bounds: tuple[float, float],
    rate_limit: float | None = None,
)

Policy-approved projection template for one STL candidate action.

STLProjectedActionPlan dataclass

STLProjectedActionPlan(
    spec: str,
    actuating: bool,
    approved_actions: tuple[ControlAction, ...],
    rejected_candidates: tuple[dict[str, object], ...],
)

Policy-gated, non-actuating projection of STL candidates.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-serialisable projected-action plan.

Returns

dict[str, object] Return a JSON-serialisable projected-action plan.

Source code in src/scpn_phase_orchestrator/monitor/stl/projection.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-serialisable projected-action plan.

    Returns
    -------
    dict[str, object]
        Return a JSON-serialisable projected-action plan.
    """
    return {
        "spec": self.spec,
        "actuating": self.actuating,
        "approved_actions": [
            _control_action_record(action) for action in self.approved_actions
        ],
        "rejected_candidates": list(self.rejected_candidates),
    }

PhaseFieldSpecification dataclass

PhaseFieldSpecification(
    name: str,
    signal: str,
    temporal_op: str,
    comparison: str,
    threshold: float,
    rationale: str,
    severity: str = "soft",
)

A named single-signal STL property of a Kuramoto-type phase field.

Parameters

name : str Stable catalogue key, e.g. "order_parameter_floor". signal : str Trace key the property constrains, e.g. "R". temporal_op : str Temporal operator, "always" or "eventually". comparison : str Predicate comparison operator: one of >=, >, <=, <, ==. threshold : float Finite predicate threshold. rationale : str Physical or engineering justification for the property and threshold. severity : str Escalation tier, "soft" (default) or "hard".

Raises

ValueError If any field is empty or outside its permitted set, or if the threshold is not finite.

Attributes
spec property
spec: str

Return the builtin-compatible STL formula for this property.

Returns

str An STL string such as "always (R >= 0.3)" that both the builtin and the rtamt backends of :class:~.monitor.STLMonitor accept.

Methods:
monitor
monitor() -> STLMonitor

Return a fresh :class:~.monitor.STLMonitor for this property.

Returns

STLMonitor A monitor bound to :attr:spec.

Source code in src/scpn_phase_orchestrator/monitor/stl/specifications.py
def monitor(self) -> STLMonitor:
    """Return a fresh :class:`~.monitor.STLMonitor` for this property.

    Returns
    -------
    STLMonitor
        A monitor bound to :attr:`spec`.
    """
    return STLMonitor(self.spec)
evaluate
evaluate(trace: dict[str, list[float]]) -> STLTraceResult

Evaluate this property over trace and return its robustness record.

Parameters

trace : dict[str, list[float]] Signal trace keyed by variable name; must include :attr:signal.

Returns

STLTraceResult Robustness value plus audit metadata (spec, satisfied, backend).

Raises

ValueError If the trace is empty, ragged, or numerically invalid.

Source code in src/scpn_phase_orchestrator/monitor/stl/specifications.py
def evaluate(self, trace: dict[str, list[float]]) -> STLTraceResult:
    """Evaluate this property over *trace* and return its robustness record.

    Parameters
    ----------
    trace : dict[str, list[float]]
        Signal trace keyed by variable name; must include :attr:`signal`.

    Returns
    -------
    STLTraceResult
        Robustness value plus audit metadata (spec, satisfied, backend).

    Raises
    ------
    ValueError
        If the trace is empty, ragged, or numerically invalid.
    """
    return self.monitor().evaluate_result(trace)

Functions:

validate_stl_runtime_actuation_gate

validate_stl_runtime_actuation_gate(
    projected_plan: STLProjectedActionPlan,
    templates: Sequence[STLActionProjectionTemplate],
) -> STLRuntimeActuationGate

Validate projected STL actions through runtime actuation mapping.

This is an audit gate only: returned commands are deterministic evidence that proposals can be represented by the configured actuation stack, while execution_disabled and non_actuating remain true for every outcome. Invalid runtime knobs, missing mappings, and empty projected plans fail closed with explicit blocker reasons.

Parameters

projected_plan : STLProjectedActionPlan The projected STL action plan to validate. templates : Sequence[STLActionProjectionTemplate] STL action-projection templates.

Returns

STLRuntimeActuationGate The runtime actuation-gate validation result.

Source code in src/scpn_phase_orchestrator/monitor/stl/actuation_gate.py
def validate_stl_runtime_actuation_gate(
    projected_plan: STLProjectedActionPlan,
    templates: Sequence[STLActionProjectionTemplate],
) -> STLRuntimeActuationGate:
    """Validate projected STL actions through runtime actuation mapping.

    This is an audit gate only: returned commands are deterministic evidence
    that proposals can be represented by the configured actuation stack, while
    ``execution_disabled`` and ``non_actuating`` remain true for every outcome.
    Invalid runtime knobs, missing mappings, and empty projected plans fail
    closed with explicit blocker reasons.

    Parameters
    ----------
    projected_plan : STLProjectedActionPlan
        The projected STL action plan to validate.
    templates : Sequence[STLActionProjectionTemplate]
        STL action-projection templates.

    Returns
    -------
    STLRuntimeActuationGate
        The runtime actuation-gate validation result.
    """
    actions = projected_plan.approved_actions
    if not actions:
        return STLRuntimeActuationGate(
            spec=projected_plan.spec,
            non_actuating=True,
            execution_disabled=True,
            accepted=False,
            action_count=0,
            mapper_valid_action_count=0,
            mapped_command_count=0,
            commands=(),
            blocked_reasons=("no_runtime_actions",),
        )

    templates_by_surface = {
        (template.knob, template.scope): template for template in templates
    }
    mappings: list[ActuatorMapping] = []
    blocked_reasons: list[str] = []
    for action in actions:
        template = templates_by_surface.get((action.knob, action.scope))
        if template is None:
            blocked_reasons.append("actuation_template_missing")
            continue
        try:
            mappings.append(
                ActuatorMapping(
                    name=_runtime_actuator_name(template.knob, template.scope),
                    knob=template.knob,
                    scope=template.scope,
                    limits=template.value_bounds,
                    rate_limit_per_step=template.rate_limit,
                )
            )
        except (TypeError, ValueError):
            blocked_reasons.append("actuation_mapper_rejected_template")

    if not mappings:
        return STLRuntimeActuationGate(
            spec=projected_plan.spec,
            non_actuating=True,
            execution_disabled=True,
            accepted=False,
            action_count=len(actions),
            mapper_valid_action_count=0,
            mapped_command_count=0,
            commands=(),
            blocked_reasons=tuple(dict.fromkeys(blocked_reasons)),
        )

    try:
        mapper = ActuationMapper(mappings)
    except ValueError:
        return STLRuntimeActuationGate(
            spec=projected_plan.spec,
            non_actuating=True,
            execution_disabled=True,
            accepted=False,
            action_count=len(actions),
            mapper_valid_action_count=0,
            mapped_command_count=0,
            commands=(),
            blocked_reasons=("actuation_mapper_rejected_template",),
        )

    valid_actions = tuple(
        action for action in actions if mapper.validate_action(action)
    )
    if len(valid_actions) != len(actions):
        blocked_reasons.append("runtime_action_validation_failed")
    commands = tuple(
        _normalise_runtime_command(command)
        for command in mapper.map_actions(list(valid_actions))
    )
    if len(commands) != len(valid_actions):
        blocked_reasons.append("actuation_mapping_incomplete")
    accepted = (
        len(valid_actions) == len(actions)
        and len(commands) == len(actions)
        and not blocked_reasons
    )
    return STLRuntimeActuationGate(
        spec=projected_plan.spec,
        non_actuating=True,
        execution_disabled=True,
        accepted=accepted,
        action_count=len(actions),
        mapper_valid_action_count=len(valid_actions),
        mapped_command_count=len(commands),
        commands=commands,
        blocked_reasons=tuple(dict.fromkeys(blocked_reasons)),
    )

synthesise_stl_monitoring_automaton

synthesise_stl_monitoring_automaton(
    spec: str, trace: dict[str, list[float]]
) -> STLMonitoringAutomaton

Synthesize a trace automaton for builtin simple STL safety formulas.

The synthesized automaton is intentionally conservative and audit-oriented: it records the state sequence taken by the monitor over the supplied trace for supported always (...) and eventually (...) conjunctions. More expressive STL remains delegated to rtamt for robustness evaluation.

Parameters

spec : str STL specification string. trace : dict[str, list[float]] Signal trace keyed by variable name, each a list of floats.

Returns

STLMonitoringAutomaton The trace automaton for the STL safety formula.

Raises

ValueError If the spec is not a supported builtin STL formula.

Source code in src/scpn_phase_orchestrator/monitor/stl/automaton.py
def synthesise_stl_monitoring_automaton(
    spec: str,
    trace: dict[str, list[float]],
) -> STLMonitoringAutomaton:
    """Synthesize a trace automaton for builtin simple STL safety formulas.

    The synthesized automaton is intentionally conservative and audit-oriented:
    it records the state sequence taken by the monitor over the supplied trace
    for supported ``always (...)`` and ``eventually (...)`` conjunctions. More
    expressive STL remains delegated to ``rtamt`` for robustness evaluation.

    Parameters
    ----------
    spec : str
        STL specification string.
    trace : dict[str, list[float]]
        Signal trace keyed by variable name, each a list of floats.

    Returns
    -------
    STLMonitoringAutomaton
        The trace automaton for the STL safety formula.

    Raises
    ------
    ValueError
        If the spec is not a supported builtin STL formula.
    """
    _validate_trace(trace)
    parsed = _parse_simple_spec(spec)
    if parsed is None:
        raise ValueError(
            "monitoring automata synthesis supports builtin simple STL syntax only"
        )

    temporal_op, predicates = parsed
    pointwise = _pointwise_robustness(predicates, trace)
    guard = _format_predicate_guard(predicates)
    signals = tuple(dict.fromkeys(signal for signal, _, _ in predicates))

    if temporal_op == "always":
        robustness = float(np.min(pointwise))
        return _synthesise_always_automaton(
            spec=spec,
            signals=signals,
            pointwise=pointwise,
            guard=guard,
            robustness=robustness,
        )
    if temporal_op == "eventually":
        robustness = float(np.max(pointwise))
        return _synthesise_eventually_automaton(
            spec=spec,
            signals=signals,
            pointwise=pointwise,
            guard=guard,
            robustness=robustness,
        )
    raise ValueError(f"unsupported STL temporal operator {temporal_op!r}")

synthesise_stl_closed_loop_plan

synthesise_stl_closed_loop_plan(
    automaton: STLMonitoringAutomaton,
    trace: dict[str, list[float]],
    templates: Sequence[STLActionProjectionTemplate],
    *,
    horizon_steps: int = 1,
    action_map: dict[str, str] | None = None,
) -> STLClosedLoopSynthesisPlan

Build an offline closed-loop STL controller plan.

The function synthesizes candidates from the current STL automaton, projects them through explicit policy templates, and records the future feedback review window. It does not mutate runtime state or permit actuation.

Parameters

automaton : STLMonitoringAutomaton The STL monitoring automaton. trace : dict[str, list[float]] Signal trace keyed by variable name, each a list of floats. templates : Sequence[STLActionProjectionTemplate] STL action-projection templates. horizon_steps : int Closed-loop planning horizon in steps. action_map : dict[str, str] | None Mapping of automaton state to action name, or None.

Returns

STLClosedLoopSynthesisPlan The offline closed-loop STL controller plan.

Source code in src/scpn_phase_orchestrator/monitor/stl/closed_loop.py
def synthesise_stl_closed_loop_plan(
    automaton: STLMonitoringAutomaton,
    trace: dict[str, list[float]],
    templates: Sequence[STLActionProjectionTemplate],
    *,
    horizon_steps: int = 1,
    action_map: dict[str, str] | None = None,
) -> STLClosedLoopSynthesisPlan:
    """Build an offline closed-loop STL controller plan.

    The function synthesizes candidates from the current STL automaton, projects
    them through explicit policy templates, and records the future feedback
    review window. It does not mutate runtime state or permit actuation.

    Parameters
    ----------
    automaton : STLMonitoringAutomaton
        The STL monitoring automaton.
    trace : dict[str, list[float]]
        Signal trace keyed by variable name, each a list of floats.
    templates : Sequence[STLActionProjectionTemplate]
        STL action-projection templates.
    horizon_steps : int
        Closed-loop planning horizon in steps.
    action_map : dict[str, str] | None
        Mapping of automaton state to action name, or ``None``.

    Returns
    -------
    STLClosedLoopSynthesisPlan
        The offline closed-loop STL controller plan.
    """
    _validate_horizon_steps(horizon_steps)
    _validate_trace(trace)
    trace_length = len(next(iter(trace.values())))
    synthesis = synthesise_stl_controller_candidates(
        automaton,
        trace,
        action_map=action_map,
    )
    projected_plan = project_stl_controller_candidates(synthesis, templates)
    runtime_gate = validate_stl_runtime_actuation_gate(projected_plan, templates)
    blocked_reasons: list[str] = []
    if synthesis.satisfied:
        blocked_reasons.append("stl_satisfied_no_control_needed")
    if synthesis.candidates and not projected_plan.approved_actions:
        blocked_reasons.append("no_projected_actions")
    if projected_plan.rejected_candidates:
        blocked_reasons.append("unprojected_candidates")
    return STLClosedLoopSynthesisPlan(
        spec=automaton.spec,
        trace_length=trace_length,
        horizon_steps=horizon_steps,
        next_review_start_index=trace_length,
        next_review_end_index=trace_length + horizon_steps - 1,
        feedback_signals=automaton.signals,
        satisfied=synthesis.satisfied,
        actuating=False,
        synthesis=synthesis,
        projected_plan=projected_plan,
        runtime_gate=runtime_gate,
        blocked_reasons=tuple(blocked_reasons),
    )

synthesise_stl_controller_candidates

synthesise_stl_controller_candidates(
    automaton: STLMonitoringAutomaton,
    trace: dict[str, list[float]],
    *,
    action_map: dict[str, str] | None = None,
) -> STLControllerSynthesis

Synthesize non-actuating controller candidates from an STL automaton.

The result is a review artefact, not a controller. It identifies the weakest predicate margin and proposes signal-level adjustment directions for supported builtin always and eventually monitors. Callers must still map candidates through policy, projection, safety, and actuation gates.

Parameters

automaton : STLMonitoringAutomaton The STL monitoring automaton. trace : dict[str, list[float]] Signal trace keyed by variable name, each a list of floats. action_map : dict[str, str] | None Mapping of automaton state to action name, or None.

Returns

STLControllerSynthesis The non-actuating controller-candidate synthesis.

Raises

ValueError If the automaton or trace is invalid.

Source code in src/scpn_phase_orchestrator/monitor/stl/controller.py
def synthesise_stl_controller_candidates(
    automaton: STLMonitoringAutomaton,
    trace: dict[str, list[float]],
    *,
    action_map: dict[str, str] | None = None,
) -> STLControllerSynthesis:
    """Synthesize non-actuating controller candidates from an STL automaton.

    The result is a review artefact, not a controller. It identifies the
    weakest predicate margin and proposes signal-level adjustment directions for
    supported builtin ``always`` and ``eventually`` monitors. Callers must still
    map candidates through policy, projection, safety, and actuation gates.

    Parameters
    ----------
    automaton : STLMonitoringAutomaton
        The STL monitoring automaton.
    trace : dict[str, list[float]]
        Signal trace keyed by variable name, each a list of floats.
    action_map : dict[str, str] | None
        Mapping of automaton state to action name, or ``None``.

    Returns
    -------
    STLControllerSynthesis
        The non-actuating controller-candidate synthesis.

    Raises
    ------
    ValueError
        If the automaton or trace is invalid.
    """
    _validate_trace(trace)
    parsed = _parse_simple_spec(automaton.spec)
    if parsed is None:
        raise ValueError("controller synthesis supports builtin simple STL syntax only")
    temporal_op, predicates = parsed
    if temporal_op != automaton.temporal_op:
        raise ValueError("automaton temporal operator does not match its STL spec")
    index = _controller_focus_index(automaton, predicates, trace)
    candidates = tuple(
        candidate
        for predicate in predicates
        if (
            candidate := _candidate_for_predicate(
                predicate,
                trace,
                time_index=index,
                action_map=action_map or {},
            )
        )
        is not None
    )
    if automaton.satisfied:
        candidates = ()
    return STLControllerSynthesis(
        spec=automaton.spec,
        satisfied=automaton.satisfied,
        actuating=False,
        source_backend=automaton.backend,
        candidates=candidates,
    )

project_stl_controller_candidates

project_stl_controller_candidates(
    synthesis: STLControllerSynthesis,
    templates: Sequence[STLActionProjectionTemplate],
) -> STLProjectedActionPlan

Project STL candidates into bounded, non-actuating action proposals.

Only candidates with an explicit policy-approved projection template are converted. Projection uses the standard :class:ActionProjector; the returned plan remains a review artefact with actuating=False.

Parameters

synthesis : STLControllerSynthesis The STL controller synthesis result. templates : Sequence[STLActionProjectionTemplate] STL action-projection templates.

Returns

STLProjectedActionPlan The bounded, non-actuating projected action plan.

Source code in src/scpn_phase_orchestrator/monitor/stl/projection.py
def project_stl_controller_candidates(
    synthesis: STLControllerSynthesis,
    templates: Sequence[STLActionProjectionTemplate],
) -> STLProjectedActionPlan:
    """Project STL candidates into bounded, non-actuating action proposals.

    Only candidates with an explicit policy-approved projection template are
    converted. Projection uses the standard :class:`ActionProjector`; the
    returned plan remains a review artefact with ``actuating=False``.

    Parameters
    ----------
    synthesis : STLControllerSynthesis
        The STL controller synthesis result.
    templates : Sequence[STLActionProjectionTemplate]
        STL action-projection templates.

    Returns
    -------
    STLProjectedActionPlan
        The bounded, non-actuating projected action plan.
    """
    template_by_action = {template.action: template for template in templates}
    approved: list[ControlAction] = []
    rejected: list[dict[str, object]] = []
    for candidate in synthesis.candidates:
        template = template_by_action.get(candidate.action)
        if template is None:
            rejected.append(
                {
                    "action": candidate.action,
                    "signal": candidate.signal,
                    "reason": "projection_template_missing",
                }
            )
            continue
        raw_action = _candidate_to_control_action(candidate, template)
        projector = ActionProjector(
            rate_limits=(
                {template.knob: template.rate_limit}
                if template.rate_limit is not None
                else {}
            ),
            value_bounds={template.knob: template.value_bounds},
        )
        approved.append(
            projector.project(raw_action, previous_value=template.previous_value)
        )
    return STLProjectedActionPlan(
        spec=synthesis.spec,
        actuating=False,
        approved_actions=tuple(approved),
        rejected_candidates=tuple(rejected),
    )

phase_field_specification

phase_field_specification(
    name: str,
) -> PhaseFieldSpecification

Return the curated specification registered under name.

Parameters

name : str A catalogue key from :func:phase_field_specification_names.

Returns

PhaseFieldSpecification The matching specification.

Raises

KeyError If name is not a registered catalogue key.

Source code in src/scpn_phase_orchestrator/monitor/stl/specifications.py
def phase_field_specification(name: str) -> PhaseFieldSpecification:
    """Return the curated specification registered under *name*.

    Parameters
    ----------
    name : str
        A catalogue key from :func:`phase_field_specification_names`.

    Returns
    -------
    PhaseFieldSpecification
        The matching specification.

    Raises
    ------
    KeyError
        If *name* is not a registered catalogue key.
    """
    try:
        return _CATALOGUE_INDEX[name]
    except KeyError as exc:
        raise KeyError(
            f"unknown phase-field specification {name!r}; "
            f"known: {phase_field_specification_names()}"
        ) from exc

phase_field_specification_names

phase_field_specification_names() -> tuple[str, ...]

Return the catalogue keys in their curated order.

Returns

tuple[str, ...] The name of every specification in :data:PHASE_FIELD_SPECIFICATIONS.

Source code in src/scpn_phase_orchestrator/monitor/stl/specifications.py
def phase_field_specification_names() -> tuple[str, ...]:
    """Return the catalogue keys in their curated order.

    Returns
    -------
    tuple[str, ...]
        The ``name`` of every specification in
        :data:`PHASE_FIELD_SPECIFICATIONS`.
    """
    return tuple(spec.name for spec in PHASE_FIELD_SPECIFICATIONS)

Chimera State Detection

Detects chimera states: the coexistence of coherent (phase-locked) and incoherent (desynchronised) clusters within the same network. This is a fundamentally different phenomenon from uniform synchronization or uniform incoherence — it requires spatially resolved analysis.

Theory: Kuramoto & Battogtokh 2002 discovered that identical oscillators with identical coupling can spontaneously split into synchronised and desynchronised subpopulations. This was later confirmed experimentally in chemical oscillators and electronic circuits.

Algorithm:

  1. Compute local order parameter R_i for each oscillator based on its coupled neighbors (oscillators j where K_ij > 0)
  2. Classify: R_i > 0.7 → coherent, R_i < 0.3 → incoherent
  3. Chimera index = fraction of oscillators in the boundary region

Usage:

from scpn_phase_orchestrator.monitor.chimera import detect_chimera

state = detect_chimera(phases, knm)
# state.coherent_indices: list of phase-locked oscillators
# state.incoherent_indices: list of desynchronised oscillators
# state.chimera_index: 0.0 = pure state, >0 = chimera

chimera

Chimera state detection with a 5-backend fallback chain.

Kuramoto & Battogtokh 2002, Nonlinear Phenomena in Complex Systems 5:380–385. An oscillator i is coherent when its local order parameter R_i = |⟨exp(i(θ_j − θ_i))⟩_{j ∈ N(i)}| exceeds the coherence threshold, incoherent when it falls below the incoherence threshold. The chimera index is the fraction of oscillators that sit in the boundary band in between.

Compute surface:

  • :func:local_order_parameter(N,) per-oscillator R_i vector; the coupling diagonal must be zero so self-coupling is never counted as a neighbour.
  • :func:detect_chimera — classification wrapper returning :class:ChimeraState.

Classes

ChimeraState dataclass

ChimeraState(
    coherent_indices: list[int] = list(),
    incoherent_indices: list[int] = list(),
    chimera_index: float = 0.0,
)

Chimera detection result: coherent/incoherent oscillator partitions and index.

Methods:
__post_init__
__post_init__() -> None

Validate and normalise immutable Chimera result fields.

Source code in src/scpn_phase_orchestrator/monitor/chimera.py
def __post_init__(self) -> None:
    """Validate and normalise immutable Chimera result fields."""
    coherent = _validate_index_list(self.coherent_indices, name="coherent_indices")
    incoherent = _validate_index_list(
        self.incoherent_indices,
        name="incoherent_indices",
    )
    overlap = set(coherent).intersection(incoherent)
    if overlap:
        raise ValueError("coherent_indices and incoherent_indices must be disjoint")
    if isinstance(self.chimera_index, (bool, np.bool_)) or not isinstance(
        self.chimera_index,
        Real,
    ):
        raise ValueError("chimera_index must be a finite real scalar in [0, 1]")
    chimera_index = float(self.chimera_index)
    if not np.isfinite(chimera_index) or not 0.0 <= chimera_index <= 1.0:
        raise ValueError("chimera_index must be finite and lie in [0, 1]")
    object.__setattr__(self, "coherent_indices", coherent)
    object.__setattr__(self, "incoherent_indices", incoherent)
    object.__setattr__(self, "chimera_index", chimera_index)

Functions:

local_order_parameter

local_order_parameter(
    phases: FloatArray, knm: FloatArray
) -> FloatArray

Per-oscillator local order parameter.

R_i = |⟨exp(i(θ_j − θ_i))⟩_{j ∈ N(i)}| with N(i) = {j : K_ij > 0} and a required zero self-coupling diagonal. Zero when oscillator i has no neighbours.

Parameters

phases : FloatArray Oscillator phases in radians, shape (N,). knm : FloatArray Coupling matrix K_nm, shape (N, N).

Returns

FloatArray The per-oscillator local order parameter, shape (N,).

Source code in src/scpn_phase_orchestrator/monitor/chimera.py
def local_order_parameter(phases: FloatArray, knm: FloatArray) -> FloatArray:
    """Per-oscillator local order parameter.

    ``R_i = |⟨exp(i(θ_j − θ_i))⟩_{j ∈ N(i)}|`` with ``N(i) =
    {j : K_ij > 0}`` and a required zero self-coupling diagonal. Zero
    when oscillator ``i`` has no neighbours.

    Parameters
    ----------
    phases : FloatArray
        Oscillator phases in radians, shape ``(N,)``.
    knm : FloatArray
        Coupling matrix ``K_nm``, shape ``(N, N)``.

    Returns
    -------
    FloatArray
        The per-oscillator local order parameter, shape ``(N,)``.
    """
    phases, knm = _validate_chimera_inputs(phases, knm)
    n = int(phases.size)
    if n == 0:
        return np.zeros(0, dtype=np.float64)
    knm_flat = np.ascontiguousarray(knm.ravel(), dtype=np.float64)

    backend_fn = _dispatch()
    if backend_fn is not None:
        try:
            return _validate_local_order(
                backend_fn(phases, knm_flat, n), n_oscillators=n
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    r_local: FloatArray = np.zeros(n, dtype=np.float64)
    knm_2d = knm_flat.reshape(n, n)
    diffs = phases[np.newaxis, :] - phases[:, np.newaxis]
    unit = np.exp(1j * diffs)
    for i in range(n):
        mask = knm_2d[i] > 0
        if not np.any(mask):
            r_local[i] = 0.0
            continue
        r_local[i] = float(np.abs(np.mean(unit[i, mask])))
    return _validate_local_order(r_local, n_oscillators=n)

detect_chimera

detect_chimera(
    phases: FloatArray, knm: FloatArray
) -> ChimeraState

Detect chimera states in a Kuramoto network.

Parameters

phases : FloatArray (N,) oscillator phases. knm : FloatArray (N, N) coupling matrix. K_ij > 0 defines neighbours; diagonal self-coupling must be zero.

Returns

ChimeraState :class:ChimeraState with coherent / incoherent index lists and the boundary-fraction chimera index.

Source code in src/scpn_phase_orchestrator/monitor/chimera.py
def detect_chimera(phases: FloatArray, knm: FloatArray) -> ChimeraState:
    """Detect chimera states in a Kuramoto network.

    Parameters
    ----------
    phases : FloatArray
        ``(N,)`` oscillator phases.
    knm : FloatArray
        ``(N, N)`` coupling matrix. ``K_ij > 0`` defines neighbours; diagonal
        self-coupling must be zero.

    Returns
    -------
    ChimeraState
        :class:`ChimeraState` with coherent / incoherent index lists and the
        boundary-fraction chimera index.
    """
    phases, knm = _validate_chimera_inputs(phases, knm)
    n = int(phases.size)
    if n == 0:
        return ChimeraState()

    r_local = local_order_parameter(phases, knm)
    coherent = [int(i) for i in range(n) if r_local[i] > _COHERENT_THRESHOLD]
    incoherent = [int(i) for i in range(n) if r_local[i] < _INCOHERENT_THRESHOLD]
    boundary = n - len(coherent) - len(incoherent)
    chimera_index = boundary / n if n > 0 else 0.0
    return ChimeraState(
        coherent_indices=coherent,
        incoherent_indices=incoherent,
        chimera_index=chimera_index,
    )

Entrainment Verification Score (EVS)

Detailed documentation: EVS (Entrainment) — detailed reference

Three-criterion battery that distinguishes genuine entrainment (phase-locking to a stimulus) from broadband artifacts. All three criteria must pass for is_entrained=True:

  1. ITPC persistence: Mean inter-trial phase coherence across time points must exceed threshold (default 0.6)
  2. Survival during pause: ITPC must remain elevated after the stimulus stops, proving the oscillator was entrained (not just responding reactively; default threshold 0.4)
  3. Frequency specificity: ITPC at the target frequency divided by ITPC at a control frequency must exceed threshold (default 1.5), proving the locking is frequency-specific

Usage:

from scpn_phase_orchestrator.monitor.evs import EVSMonitor

monitor = EVSMonitor(
    itpc_threshold=0.5,
    persistence_threshold=0.3,
    specificity_threshold=2.0,
)
result = monitor.evaluate(
    phases_trials,        # (n_trials, T) phase matrix
    pause_indices=list(range(500, 600)),
    target_freq=10.0,     # stimulus frequency in Hz
    control_freq=7.0,     # comparison frequency in Hz
)
# result.is_entrained: bool
# result.itpc_value, result.persistence_score, result.specificity_ratio

evs

EVS and phase-locking metrics for finite two-dimensional phase recordings.

The module implements ITPC, persistence across pauses, and frequency-specificity checks for Entrainment Verification Signals. A Rust extension is used when available while the Python fallback remains the reference-compatible path. Inputs are normalized to finite trials x time phase arrays, pause indices are bounds-checked, and candidate frequency vectors must match the trial axis before evidence is reported.

Classes

EVSMonitor

EVSMonitor(
    itpc_threshold: float = 0.6,
    persistence_threshold: float = 0.4,
    specificity_threshold: float = 1.5,
)

Combine ITPC, persistence, and frequency specificity into one score.

Three criteria must all pass for is_entrained=True:

  1. Mean ITPC across all time points >= itpc_threshold
  2. ITPC during/after stimulus pause >= persistence_threshold
  3. ITPC at the target frequency / ITPC at a control frequency

    = specificity_threshold

The specificity test distinguishes frequency-specific entrainment from broadband phase-locking artefacts.

Source code in src/scpn_phase_orchestrator/monitor/evs.py
def __init__(
    self,
    itpc_threshold: float = 0.6,
    persistence_threshold: float = 0.4,
    specificity_threshold: float = 1.5,
) -> None:
    self.itpc_threshold = _validate_unit_threshold(
        itpc_threshold, name="itpc_threshold"
    )
    self.persistence_threshold = _validate_unit_threshold(
        persistence_threshold, name="persistence_threshold"
    )
    self.specificity_threshold = _validate_positive_real(
        specificity_threshold, name="specificity_threshold"
    )
Methods:
evaluate
evaluate(
    phases_trials: FloatArray,
    pause_indices: list[int] | IntArray,
    target_freq: float,
    control_freq: float,
) -> EVSResult

Run the full EVS battery.

Parameters

phases_trials : FloatArray shape (n_trials, n_timepoints), phases in radians at the target frequency. pause_indices : list[int] | IntArray time-point indices within/after a stimulus pause window. target_freq : float stimulus frequency (Hz). control_freq : float non-stimulus control frequency (Hz).

Returns

EVSResult EVSResult with all three sub-scores and the overall verdict.

Source code in src/scpn_phase_orchestrator/monitor/evs.py
def evaluate(
    self,
    phases_trials: FloatArray,
    pause_indices: list[int] | IntArray,
    target_freq: float,
    control_freq: float,
) -> EVSResult:
    """Run the full EVS battery.

    Parameters
    ----------
    phases_trials : FloatArray
        shape (n_trials, n_timepoints), phases in radians at the *target* frequency.
    pause_indices : list[int] | IntArray
        time-point indices within/after a stimulus pause window.
    target_freq : float
        stimulus frequency (Hz).
    control_freq : float
        non-stimulus control frequency (Hz).

    Returns
    -------
    EVSResult
        EVSResult with all three sub-scores and the overall verdict.
    """
    phases = _validate_phase_trials(phases_trials)
    pause_idx = _validate_pause_indices(
        pause_indices,
        n_timepoints=phases.shape[1],
    )
    target = _validate_positive_real(target_freq, name="target_freq")
    control = _validate_positive_real(control_freq, name="control_freq")

    itpc_vals = compute_itpc(phases)
    itpc_mean = float(np.mean(itpc_vals)) if itpc_vals.size > 0 else 0.0

    persistence = itpc_persistence(phases, pause_idx)

    specificity = self._frequency_specificity(
        phases,
        target,
        control,
    )

    entrained = (
        itpc_mean >= self.itpc_threshold
        and persistence >= self.persistence_threshold
        and specificity >= self.specificity_threshold
    )

    return EVSResult(
        itpc_value=itpc_mean,
        persistence_score=persistence,
        specificity_ratio=specificity,
        is_entrained=entrained,
    )

EVS rejects coercive phase aliases before ITPC, normalises pause indices to a unique in-range set, and verifies every native specificity score against the canonical NumPy calculation before it can affect the entrainment verdict.

Partial Information Decomposition (PID)

Decomposes the information that two oscillator groups carry about the global synchronisation state into redundancy (information both groups share) and synergy (information available only from the joint observation), with a 5-backend fallback chain (Rust → Mojo → Julia → Go → Python).

Theory: Williams & Beer 2010 (arXiv:1004.2515). Mutual information is a property of a distribution, so the input is a phase history (T, N) (T timesteps, N oscillators). Each timestep is reduced to three circular observables — the global order-parameter phase (target Y) and the two group order-parameter phases (sources A, B) — binned into n_bins phase bins (default 32). With the specific information I_spec(Y=y; S) = Σ_s p(s|y)·log[p(y|s)/p(y)]:

redundancy  I_red = Σ_y p(y)·min( I_spec(Y=y; A), I_spec(Y=y; B) )   # I_min
synergy     I_syn = MI(A,B; Y) − MI(A; Y) − MI(B; Y) + I_red

Each source's unique information is MI(S; Y) − I_red; all components are non-negative and MI(A; Y) = I_red + U_A holds by construction. A single snapshot (T = 1) carries no distributional information, so every component is 0; meaningful decomposition needs T ≥ 2. Histories, group indices, bin counts, and backend scalar outputs are validated as finite real quantities; boolean aliases, numeric-string aliases, complex dtypes, and out-of-range indices are rejected before estimation or backend acceptance.

Usage:

from scpn_phase_orchestrator.monitor.pid import redundancy, synergy

# history: (T, N) phase history; groups are oscillator index sets into N
R = redundancy(history, group_a=[0, 1, 2], group_b=[3, 4, 5])
S = synergy(history, group_a=[0, 1, 2], group_b=[3, 4, 5])

High synergy means the groups carry complementary information — neither alone predicts the target, but together they do. This detects higher-order functional relationships invisible to pairwise PLV. The polyglot parity gate benchmark_pid_polyglot_parity_gate (benchmarks/pid_benchmark.py, wired into benchmarks/reference_suite.py as pid_polyglot) verifies cross-backend parity of the redundancy/synergy estimates and the decomposition contracts (a co-varying source pair has positive synergy; a fully redundant configuration has vanishing synergy).

pid

Partial information decomposition (PID) about global synchronisation.

Decomposes two oscillator groups with a 5-backend fallback chain.

Model

Williams & Beer 2010 (Nonnegative Decomposition of Multivariate Information, arXiv:1004.2515) decompose the information two sources carry about a target into redundant, unique, and synergistic parts. Estimating it needs a distribution, so the input is a phase history (T, N) (T timesteps, N oscillators). Each timestep is reduced to three circular observables:

  • target Y_t — the global order-parameter phase ∠⟨e^{iθ}⟩ over all oscillators,
  • source A_t — the group-A order-parameter phase,
  • source B_t — the group-B order-parameter phase.

The three series are binned into n_bins equal-width phase bins and the joint distribution is estimated over the T samples.

Decomposition

With the specific information I_spec(Y=y; S) = Σ_s p(s|y)·log[p(y|s)/p(y)]:

redundancy  I_red = Σ_y p(y)·min( I_spec(Y=y; A), I_spec(Y=y; B) )
synergy     I_syn = MI(A,B; Y) − MI(A; Y) − MI(B; Y) + I_red

I_red is the Williams & Beer I_min redundancy; the unique information of each source is MI(S; Y) − I_red and MI(A; Y) = I_red + U_A holds by construction. All terms are non-negative.

A single snapshot (T = 1) carries no distributional information, so every component is 0; meaningful decomposition needs T ≥ 2.

Functions:

redundancy

redundancy(
    phases: FloatArray,
    group_a: list[int] | IntArray,
    group_b: list[int] | IntArray,
    n_bins: int = _DEFAULT_BINS,
) -> float

Redundant information both groups share about the global phase.

I_red = Σ_y p(y)·min(I_spec(Y=y; A), I_spec(Y=y; B)) (Williams & Beer 2010 I_min). phases is a (T, N) phase history.

Parameters

phases : FloatArray Oscillator phases in radians, shape (N,). group_a : list[int] | IntArray Indices of the first oscillator group. group_b : list[int] | IntArray Indices of the second oscillator group. n_bins : int Number of histogram bins.

Returns

float The redundant information the groups share about the global phase.

Source code in src/scpn_phase_orchestrator/monitor/pid.py
def redundancy(
    phases: FloatArray,
    group_a: list[int] | IntArray,
    group_b: list[int] | IntArray,
    n_bins: int = _DEFAULT_BINS,
) -> float:
    """Redundant information both groups share about the global phase.

    ``I_red = Σ_y p(y)·min(I_spec(Y=y; A), I_spec(Y=y; B))`` (Williams & Beer
    2010 ``I_min``). ``phases`` is a ``(T, N)`` phase history.

    Parameters
    ----------
    phases : FloatArray
        Oscillator phases in radians, shape ``(N,)``.
    group_a : list[int] | IntArray
        Indices of the first oscillator group.
    group_b : list[int] | IntArray
        Indices of the second oscillator group.
    n_bins : int
        Number of histogram bins.

    Returns
    -------
    float
        The redundant information the groups share about the global phase.
    """
    red, _ = _decompose(phases, group_a, group_b, n_bins)
    return _validate_pid_scalar(red, name="redundancy")

synergy

synergy(
    phases: FloatArray,
    group_a: list[int] | IntArray,
    group_b: list[int] | IntArray,
    n_bins: int = _DEFAULT_BINS,
) -> float

Synergistic information present only in the joint (A, B).

I_syn = MI(A,B; Y) − MI(A; Y) − MI(B; Y) + I_red. Positive synergy means the combined group carries information about the global state that neither subgroup carries alone. phases is a (T, N) phase history.

Parameters

phases : FloatArray Oscillator phases in radians, shape (N,). group_a : list[int] | IntArray Indices of the first oscillator group. group_b : list[int] | IntArray Indices of the second oscillator group. n_bins : int Number of histogram bins.

Returns

float The synergistic information present only in the joint (A, B).

Source code in src/scpn_phase_orchestrator/monitor/pid.py
def synergy(
    phases: FloatArray,
    group_a: list[int] | IntArray,
    group_b: list[int] | IntArray,
    n_bins: int = _DEFAULT_BINS,
) -> float:
    """Synergistic information present only in the joint ``(A, B)``.

    ``I_syn = MI(A,B; Y) − MI(A; Y) − MI(B; Y) + I_red``. Positive synergy means
    the combined group carries information about the global state that neither
    subgroup carries alone. ``phases`` is a ``(T, N)`` phase history.

    Parameters
    ----------
    phases : FloatArray
        Oscillator phases in radians, shape ``(N,)``.
    group_a : list[int] | IntArray
        Indices of the first oscillator group.
    group_b : list[int] | IntArray
        Indices of the second oscillator group.
    n_bins : int
        Number of histogram bins.

    Returns
    -------
    float
        The synergistic information present only in the joint ``(A, B)``.
    """
    _, syn = _decompose(phases, group_a, group_b, n_bins)
    return _validate_pid_scalar(syn, name="synergy")

Integrated-Information Monitor

Estimates an approximate Phi-style global integration metric from phase trajectories. The monitor builds a pairwise circular mutual-information matrix, evaluates unique bipartitions, and reports the minimum cross-partition information as phi.

This is an engineering proxy for comparing regime traces and writing audit records. It is not an exact IIT quantity and is not a consciousness claim. Phase-series inputs, bin/sample counts, audit scalars, partitions, and pairwise mutual-information matrices are validated as finite real-valued contracts. Boolean aliases, complex dtypes, and object arrays carrying Python or NumPy complex scalar aliases are rejected before circular histogram estimation or audit-record acceptance; numeric text and broken array protocols also fail at the monitor boundary. A directly constructed result independently recomputes total integration, the canonical minimum bipartition, and phi from its pairwise-MI matrix before it can become audit evidence.

Usage:

from scpn_phase_orchestrator.monitor import (
    benchmark_integrated_information_approximations,
    integrated_information,
)

# phase_series: (n_oscillators, n_samples)
result = integrated_information(phase_series, n_bins=16)
record = result.to_audit_record()

benchmark = benchmark_integrated_information_approximations()
benchmark_record = benchmark.to_audit_record()

benchmark_integrated_information_approximations() runs deterministic synthetic calibration cases for independent, modular, phase-lagged chain, noisy locked, and globally locked phase regimes. It is a numerical approximation benchmark, not a hardware performance benchmark; the audit record documents ordering margins and preserves the same engineering-proxy claim boundary. Studio renders those audit records through the public scpn_phase_orchestrator.studio.build_integrated_information_panel() facade, which keeps the monitor passive, requires the explicit engineering-proxy claim boundary, and exposes Phi, normalised Phi, total-integration ranges, and minimum partitions for operator review without enabling actuation or consciousness claims.

information_integration

Approximate integrated-information monitor for phase trajectories.

The monitor reports a bounded engineering proxy over binned circular mutual information. It is intended for regime comparison and audit traces, not for theoretical integrated-information claims.

Classes

IntegratedInformationResult dataclass

IntegratedInformationResult(
    phi: float,
    normalised_phi: float,
    total_integration: float,
    minimum_partition: Partition,
    pairwise_mi: FloatArray,
    n_bins: int,
)

Audit-ready result from the integrated-information monitor.

Attributes
phi: Minimum cross-partition information in nats. This is an
    approximate Phi-style proxy, not an exact IIT quantity.
normalised_phi: ``phi`` divided by ``log(n_bins)`` and clipped
    to ``[0, 1]`` for dashboards.
total_integration: Mean off-diagonal pairwise mutual information
    across all oscillator trajectories.
minimum_partition: Bipartition that minimises cross-partition
    information.
pairwise_mi: Symmetric pairwise mutual-information matrix.
n_bins: Number of circular histogram bins used by the estimator.
Methods:
to_audit_record
to_audit_record() -> dict[str, Any]

Return a JSON-serialisable audit record.

Returns

dict[str, Any] Return a JSON-serialisable audit record.

Source code in src/scpn_phase_orchestrator/monitor/information_integration.py
def to_audit_record(self) -> dict[str, Any]:
    """Return a JSON-serialisable audit record.

    Returns
    -------
    dict[str, Any]
        Return a JSON-serialisable audit record.
    """
    left, right = self.minimum_partition
    return {
        "monitor": "integrated_information",
        "phi": self.phi,
        "normalised_phi": self.normalised_phi,
        "total_integration": self.total_integration,
        "minimum_partition": [list(left), list(right)],
        "pairwise_mi": self.pairwise_mi.tolist(),
        "n_bins": self.n_bins,
        "method": "binned_circular_pairwise_minimum_bipartition",
        "claim_boundary": "engineering_proxy_not_theoretical_iit",
    }

IntegratedInformationBenchmarkCase dataclass

IntegratedInformationBenchmarkCase(
    name: str,
    description: str,
    result: IntegratedInformationResult,
)

Deterministic approximation benchmark case for the Phi proxy.

Methods:
to_audit_record
to_audit_record() -> dict[str, Any]

Return a JSON-serialisable benchmark case record.

Returns

dict[str, Any] Return a JSON-serialisable benchmark case record.

Source code in src/scpn_phase_orchestrator/monitor/information_integration.py
def to_audit_record(self) -> dict[str, Any]:
    """Return a JSON-serialisable benchmark case record.

    Returns
    -------
    dict[str, Any]
        Return a JSON-serialisable benchmark case record.
    """
    return {
        "name": self.name,
        "description": self.description,
        "result": self.result.to_audit_record(),
    }

IntegratedInformationBenchmarkReport dataclass

IntegratedInformationBenchmarkReport(
    cases: tuple[IntegratedInformationBenchmarkCase, ...],
    expected_ordering_passed: bool,
    locked_phi_margin: float,
    modular_total_margin: float,
    noisy_lock_phi_margin: float,
    phase_lag_total_margin: float,
    n_samples: int,
    n_bins: int,
)

Audit report for deterministic integrated-information approximations.

Methods:
to_audit_record
to_audit_record() -> dict[str, Any]

Return a JSON-serialisable benchmark report.

Returns

dict[str, Any] Return a JSON-serialisable benchmark report.

Source code in src/scpn_phase_orchestrator/monitor/information_integration.py
def to_audit_record(self) -> dict[str, Any]:
    """Return a JSON-serialisable benchmark report.

    Returns
    -------
    dict[str, Any]
        Return a JSON-serialisable benchmark report.
    """
    return {
        "monitor": "integrated_information",
        "benchmark": "deterministic_synthetic_approximation_cases",
        "n_samples": self.n_samples,
        "n_bins": self.n_bins,
        "expected_ordering_passed": self.expected_ordering_passed,
        "locked_phi_margin": self.locked_phi_margin,
        "modular_total_margin": self.modular_total_margin,
        "noisy_lock_phi_margin": self.noisy_lock_phi_margin,
        "phase_lag_total_margin": self.phase_lag_total_margin,
        "cases": [case.to_audit_record() for case in self.cases],
        "claim_boundary": "engineering_proxy_not_theoretical_iit",
    }

Functions:

integrated_information

integrated_information(
    phase_series: FloatArray, n_bins: int = _DEFAULT_BINS
) -> IntegratedInformationResult

Estimate an approximate integrated-information metric.

Parameters

phase_series : FloatArray Phase trajectory array with shape (n_oscillators, n_samples). Values are wrapped onto the circular interval before histogramming. n_bins : int Number of circular bins for mutual-information estimation. Must be at least two.

Returns

IntegratedInformationResult IntegratedInformationResult containing the minimum information bipartition and audit fields.

Raises

ValueError If the trajectory is not a finite two-dimensional array with at least two oscillators and two samples.

Source code in src/scpn_phase_orchestrator/monitor/information_integration.py
def integrated_information(
    phase_series: FloatArray,
    n_bins: int = _DEFAULT_BINS,
) -> IntegratedInformationResult:
    """Estimate an approximate integrated-information metric.

    Parameters
    ----------
    phase_series : FloatArray
        Phase trajectory array with shape ``(n_oscillators, n_samples)``. Values are
        wrapped onto the circular interval before histogramming.
    n_bins : int
        Number of circular bins for mutual-information estimation. Must be at least two.

    Returns
    -------
    IntegratedInformationResult
        ``IntegratedInformationResult`` containing the minimum information bipartition
        and audit fields.

    Raises
    ------
    ValueError
        If the trajectory is not a finite two-dimensional array with at least two
        oscillators and two samples.
    """
    phases = _validate_phase_series(phase_series)
    bins = _validate_bins(n_bins)

    pairwise_mi = _pairwise_mi_matrix(phases, bins)
    total_integration = _mean_off_diagonal(pairwise_mi)
    minimum_partition, phi = _minimum_bipartition(pairwise_mi)
    normalised_phi = _normalise_phi(phi, bins)

    return IntegratedInformationResult(
        phi=phi,
        normalised_phi=normalised_phi,
        total_integration=total_integration,
        minimum_partition=minimum_partition,
        pairwise_mi=pairwise_mi,
        n_bins=bins,
    )

benchmark_integrated_information_approximations

benchmark_integrated_information_approximations(
    *, n_samples: int = 256, n_bins: int = 8
) -> IntegratedInformationBenchmarkReport

Run deterministic approximation checks for the Phi proxy.

This is a numerical calibration, not a hardware performance benchmark. It checks five synthetic regimes: independent streams, modular streams with high within-module information but weak cross-module Phi, phase-lagged chains, noisy globally locked streams, and globally locked streams with high cross-partition Phi.

Parameters

n_samples : int Number of samples. n_bins : int Number of histogram bins.

Returns

IntegratedInformationBenchmarkReport The Phi-proxy approximation benchmark report.

Source code in src/scpn_phase_orchestrator/monitor/information_integration.py
def benchmark_integrated_information_approximations(
    *,
    n_samples: int = 256,
    n_bins: int = 8,
) -> IntegratedInformationBenchmarkReport:
    """Run deterministic approximation checks for the Phi proxy.

    This is a numerical calibration, not a hardware performance benchmark. It
    checks five synthetic regimes: independent streams, modular streams with
    high within-module information but weak cross-module Phi, phase-lagged
    chains, noisy globally locked streams, and globally locked streams with high
    cross-partition Phi.

    Parameters
    ----------
    n_samples : int
        Number of samples.
    n_bins : int
        Number of histogram bins.

    Returns
    -------
    IntegratedInformationBenchmarkReport
        The Phi-proxy approximation benchmark report.
    """
    samples = _validate_sample_count(n_samples)
    bins = _validate_bins(n_bins)
    cases = (
        IntegratedInformationBenchmarkCase(
            name="independent",
            description="seeded independent circular phase streams",
            result=integrated_information(_independent_benchmark_series(samples), bins),
        ),
        IntegratedInformationBenchmarkCase(
            name="modular",
            description=(
                "two internally locked modules with weak cross-module "
                "minimum-partition Phi"
            ),
            result=integrated_information(_modular_benchmark_series(samples), bins),
        ),
        IntegratedInformationBenchmarkCase(
            name="phase_lag_chain",
            description="deterministic phase-lagged chain with coherent offsets",
            result=integrated_information(
                _phase_lag_chain_benchmark_series(samples), bins
            ),
        ),
        IntegratedInformationBenchmarkCase(
            name="noisy_locked",
            description="globally locked streams with deterministic phase noise",
            result=integrated_information(
                _noisy_locked_benchmark_series(samples), bins
            ),
        ),
        IntegratedInformationBenchmarkCase(
            name="locked",
            description="globally phase-locked streams with high cross-partition Phi",
            result=integrated_information(_locked_benchmark_series(samples), bins),
        ),
    )
    by_name = {case.name: case.result for case in cases}
    locked_phi_margin = by_name["locked"].phi - by_name["independent"].phi
    modular_total_margin = (
        by_name["modular"].total_integration - by_name["independent"].total_integration
    )
    noisy_lock_phi_margin = by_name["noisy_locked"].phi - by_name["independent"].phi
    phase_lag_total_margin = (
        by_name["phase_lag_chain"].total_integration
        - by_name["independent"].total_integration
    )
    expected_ordering_passed = _expected_benchmark_ordering_passed(by_name)
    return IntegratedInformationBenchmarkReport(
        cases=cases,
        expected_ordering_passed=expected_ordering_passed,
        locked_phi_margin=float(locked_phi_margin),
        modular_total_margin=float(modular_total_margin),
        noisy_lock_phi_margin=float(noisy_lock_phi_margin),
        phase_lag_total_margin=float(phase_lag_total_margin),
        n_samples=samples,
        n_bins=bins,
    )

Lyapunov Exponent

Real-time estimation of the maximal Lyapunov exponent from phase trajectories. The Lyapunov exponent characterizes the system's sensitivity to initial conditions:

  • λ > 0: chaotic (exponential divergence of nearby trajectories)
  • λ ≈ 0: edge of chaos (critical regime, maximal computational capacity)
  • λ < 0: stable attractor (perturbations decay exponentially)

The "edge of chaos" (λ ≈ 0) is where flexible, high-capacity dynamics operate (PNAS 2022) and where reservoir computing achieves optimal performance (arXiv:2407.16172).

The spectrum surface validates phase/frequency vectors, coupling/lag matrices, and optional backend spectra before float coercion. Boolean aliases, complex aliases, numeric-string aliases, non-finite values, unsorted spectra, and wrong cardinality fail closed before publication.

lyapunov

Lyapunov stability monitor with a 5-backend fallback chain.

Two public surfaces:

  • :class:LyapunovGuard — stateful observer that tracks the Lyapunov function V(θ) = -(K/2N) Σ_ij A_ij cos(θ_i − θ_j), its numerical time derivative, and basin-of-attraction membership (van Hemmen & Wreszinski 1993). Single-backend NumPy; inexpensive per call.
  • :func:lyapunov_spectrum — full Lyapunov spectrum via periodic QR reorthogonalisation (Benettin 1980 / Shimada-Nagashima 1979). Multi- backend; the heavy kernel is dispatched to Rust → Mojo → Julia → Go → Python in order of availability.

Classes

LyapunovState dataclass

LyapunovState(
    V: float,
    dV_dt: float,
    in_basin: bool,
    max_phase_diff: float,
)

Lyapunov function V, dV/dt, basin membership, and max phase diff.

Methods:
__post_init__
__post_init__() -> None

Normalize scalar aliases and reject invalid state fields.

Source code in src/scpn_phase_orchestrator/monitor/lyapunov.py
def __post_init__(self) -> None:
    """Normalize scalar aliases and reject invalid state fields."""
    v_value = _validate_finite_real(self.V, name="V")
    dv_dt_value = _validate_finite_real(self.dV_dt, name="dV_dt")
    if not isinstance(self.in_basin, bool):
        raise ValueError(f"in_basin must be a boolean flag, got {self.in_basin!r}")
    max_phase_diff = _validate_non_negative_real(
        self.max_phase_diff,
        name="max_phase_diff",
    )
    if max_phase_diff > np.pi:
        raise ValueError(
            f"max_phase_diff must be <= pi for geodesic phase distance, "
            f"got {self.max_phase_diff!r}"
        )

    self.V = v_value
    self.dV_dt = dv_dt_value
    self.max_phase_diff = max_phase_diff

LyapunovGuard

LyapunovGuard(basin_threshold: object = np.pi / 2)

Lyapunov stability monitor for Kuramoto networks.

V(θ) = -(K/2N) Σ_{i,j} A_ij cos(θ_i - θ_j)

dV/dt ≤ 0 for gradient flow (Kuramoto is gradient on V). Basin of attraction: max|θ_i - θ_j| < π/2 for connected pairs.

van Hemmen & Wreszinski 1993, J. Stat. Phys. 72:145-166.

Create a guard with a validated geodesic basin threshold.

Source code in src/scpn_phase_orchestrator/monitor/lyapunov.py
def __init__(self, basin_threshold: object = np.pi / 2):
    """Create a guard with a validated geodesic basin threshold."""
    basin_threshold = _validate_positive_real(
        basin_threshold,
        name="basin_threshold",
    )
    if basin_threshold > np.pi:
        raise ValueError(
            f"basin_threshold must be <= pi for geodesic phase distance, "
            f"got {basin_threshold!r}"
        )
    self._basin_threshold = basin_threshold
    self._prev_V: float | None = None
Methods:
evaluate
evaluate(phases: object, knm: object) -> LyapunovState

Compute Lyapunov function, its time derivative, and basin check.

Parameters

phases : object Oscillator phases in radians, shape (N,). knm : object Coupling matrix K_nm, shape (N, N).

Returns

LyapunovState The Lyapunov value, its derivative, and the basin-check result.

Source code in src/scpn_phase_orchestrator/monitor/lyapunov.py
def evaluate(self, phases: object, knm: object) -> LyapunovState:
    """Compute Lyapunov function, its time derivative, and basin check.

    Parameters
    ----------
    phases : object
        Oscillator phases in radians, shape ``(N,)``.
    knm : object
        Coupling matrix ``K_nm``, shape ``(N, N)``.

    Returns
    -------
    LyapunovState
        The Lyapunov value, its derivative, and the basin-check result.
    """
    phase_values = _validate_vector(phases, name="phases")
    n = len(phase_values)
    coupling_matrix = _validate_matrix(knm, name="knm", expected_shape=(n, n))
    _validate_zero_diagonal(coupling_matrix, name="knm")
    if n == 0:
        return LyapunovState(V=0.0, dV_dt=0.0, in_basin=True, max_phase_diff=0.0)

    diff = phase_values[:, np.newaxis] - phase_values[np.newaxis, :]
    cos_diff = np.cos(diff)

    # Lyapunov fn for Kuramoto gradient system
    # V(θ) = -(1/2N) Σ K_ij cos(θ_i - θ_j)
    # van Hemmen & Wreszinski 1993, Eq. 2.3
    V = -0.5 * float(np.sum(coupling_matrix * cos_diff)) / n

    # Numerical dV/dt from consecutive calls
    dV_dt = 0.0
    if self._prev_V is not None:
        dV_dt = V - self._prev_V
    self._prev_V = V

    # Basin of attraction: all connected pairs within π/2 of each other
    # (sufficient condition for gradient convergence)
    connected = coupling_matrix > 0
    if np.any(connected):
        abs_diff = np.abs(diff)
        # Geodesic distance on S¹: min(|Δ|, 2π-|Δ|)
        abs_diff = np.minimum(abs_diff, 2 * np.pi - abs_diff)
        max_diff = float(np.max(abs_diff[connected]))
    else:
        max_diff = 0.0

    in_basin = max_diff < self._basin_threshold

    return LyapunovState(
        V=V,
        dV_dt=dV_dt,
        in_basin=in_basin,
        max_phase_diff=max_diff,
    )
reset
reset() -> None

Clear cached previous V, so next evaluate() reports dV/dt = 0.

Source code in src/scpn_phase_orchestrator/monitor/lyapunov.py
def reset(self) -> None:
    """Clear cached previous V, so next evaluate() reports dV/dt = 0."""
    self._prev_V = None

Functions:

lyapunov_spectrum

lyapunov_spectrum(
    phases_init: object,
    omegas: object,
    knm: object,
    alpha: object,
    dt: object = 0.01,
    n_steps: object = 1000,
    qr_interval: object = 10,
    zeta: object = 0.0,
    psi: object = 0.0,
) -> FloatArray

Full Lyapunov spectrum (all N exponents) via QR decomposition.

Evolves N perturbation vectors alongside the Kuramoto ODE. Every qr_interval steps, QR-reorthogonalises and accumulates growth rates from the diagonal of R.

Benettin et al. 1980, Meccanica 15:9-20. Shimada & Nagashima 1979, Prog. Theor. Phys. 61:1605-1616.

Dispatches to the first available backend per the SPO fallback chain (Rust → Mojo → Julia → Go → Python). All five produce the same exponents up to floating-point rounding; the dispatcher's choice only affects wall-clock cost.

Parameters

phases_init : object (N,) initial phases. omegas : object (N,) natural frequencies. knm : object (N, N) coupling matrix. alpha : object (N, N) phase-lag matrix. dt : object integration timestep. n_steps : object total integration steps. qr_interval : object steps between QR reorthogonalisations. zeta : object driver strength. psi : object target driver phase.

Returns

FloatArray (N,) array of Lyapunov exponents, sorted descending.

Raises

ValueError If the integration parameters are invalid.

Source code in src/scpn_phase_orchestrator/monitor/lyapunov.py
def lyapunov_spectrum(
    phases_init: object,
    omegas: object,
    knm: object,
    alpha: object,
    dt: object = 0.01,
    n_steps: object = 1000,
    qr_interval: object = 10,
    zeta: object = 0.0,
    psi: object = 0.0,
) -> FloatArray:
    """Full Lyapunov spectrum (all N exponents) via QR decomposition.

    Evolves N perturbation vectors alongside the Kuramoto ODE. Every
    ``qr_interval`` steps, QR-reorthogonalises and accumulates growth
    rates from the diagonal of R.

    Benettin et al. 1980, Meccanica 15:9-20.
    Shimada & Nagashima 1979, Prog. Theor. Phys. 61:1605-1616.

    Dispatches to the first available backend per the SPO fallback
    chain (Rust → Mojo → Julia → Go → Python). All five produce the
    same exponents up to floating-point rounding; the dispatcher's
    choice only affects wall-clock cost.

    Parameters
    ----------
    phases_init : object
        (N,) initial phases.
    omegas : object
        (N,) natural frequencies.
    knm : object
        (N, N) coupling matrix.
    alpha : object
        (N, N) phase-lag matrix.
    dt : object
        integration timestep.
    n_steps : object
        total integration steps.
    qr_interval : object
        steps between QR reorthogonalisations.
    zeta : object
        driver strength.
    psi : object
        target driver phase.

    Returns
    -------
    FloatArray
        (N,) array of Lyapunov exponents, sorted descending.

    Raises
    ------
    ValueError
        If the integration parameters are invalid.
    """
    p = _validate_vector(phases_init, name="phases_init")
    n = int(p.size)
    if n < 1:
        raise ValueError("phases_init must contain at least one oscillator")
    o = _validate_vector(omegas, name="omegas")
    if o.shape != p.shape:
        raise ValueError(f"omegas shape {o.shape} does not match {p.shape}")
    k = _validate_matrix(knm, name="knm", expected_shape=(n, n))
    _validate_zero_diagonal(k, name="knm")
    a = _validate_matrix(alpha, name="alpha", expected_shape=(n, n))
    dt = _validate_positive_real(dt, name="dt")
    n_steps = _validate_int_at_least(n_steps, name="n_steps", minimum=0)
    qr_interval = _validate_int_at_least(
        qr_interval,
        name="qr_interval",
        minimum=1,
    )
    zeta = _validate_non_negative_real(zeta, name="zeta")
    psi = _validate_finite_real(psi, name="psi")
    backend_fn = _dispatch()
    if backend_fn is None:
        return _validate_spectrum_output(
            _lyapunov_spectrum_python(
                p,
                o,
                k,
                a,
                float(dt),
                int(n_steps),
                int(qr_interval),
                float(zeta),
                float(psi),
            ),
            n=n,
        )
    # Rust PyO3 binding takes flat (N*N,) row-major k/alpha; the other
    # backends accept the 2-D forms directly.
    if ACTIVE_BACKEND == "rust":
        try:
            return _validate_spectrum_output(
                backend_fn(
                    p,
                    o,
                    k.ravel(),
                    a.ravel(),
                    dt,
                    n_steps,
                    qr_interval,
                    zeta,
                    psi,
                ),
                n=n,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            return _validate_spectrum_output(
                _lyapunov_spectrum_python(
                    p,
                    o,
                    k,
                    a,
                    float(dt),
                    int(n_steps),
                    int(qr_interval),
                    float(zeta),
                    float(psi),
                ),
                n=n,
            )
    try:
        return _validate_spectrum_output(
            backend_fn(
                p,
                o,
                k,
                a,
                float(dt),
                int(n_steps),
                int(qr_interval),
                float(zeta),
                float(psi),
            ),
            n=n,
        )
    except (ImportError, RuntimeError, OSError, KeyError):
        return _validate_spectrum_output(
            _lyapunov_spectrum_python(
                p,
                o,
                k,
                a,
                float(dt),
                int(n_steps),
                int(qr_interval),
                float(zeta),
                float(psi),
            ),
            n=n,
        )

Digital-Twin Confidence

Scores how well a running orchestrator tracks its physical or simulated twin from a phase-histogram Jensen–Shannon divergence and an order-parameter Wasserstein-1 distance, calibrated against a nominal baseline into a confidence in [0, 1] plus an operator status. See the dedicated Twin Confidence page for the formalism, the polyglot backend chain, and benchmarks.

The public and direct backend boundaries reject boolean aliases, complex aliases, numeric-string aliases, non-finite payloads, shape mismatches, invalid order-parameter ranges, and backend-output range violations before divergence evidence can feed the operator summary, Prometheus export, Studio panel, or conformal twin-confidence gate.

twin_confidence

Online digital-twin confidence scoring from model–observation divergence.

A running orchestrator and its physical (or simulated) twin both emit a phase state and an order-parameter trajectory at every control tick. This module turns the disagreement between the two streams into a single calibrated confidence score in [0, 1] plus an operator status, using two complementary divergences computed by the multi-language acceleration chain:

  • Phase distribution Jensen–Shannon divergence — model and observed phase vectors are wrapped to [0, 2π) and binned into n_bins histograms; the symmetric Jensen–Shannon divergence (natural log, range [0, ln 2]) measures how differently the two populations are distributed around the ring.
  • Order-parameter Wasserstein-1 distance — the model and observed order-parameter windows R ∈ [0, 1] are compared with the closed-form one-dimensional Wasserstein-1 distance (mean absolute difference of the order-sorted samples, range [0, 1]).

The raw (js, w1) pair is the compute hot path and is produced by the Rust → Mojo → Julia → Go → NumPy fallback chain (fastest available first). The calibration, confidence mapping, operating bands, and audit records are deterministic NumPy/Python on top.

Calibration follows the standard online-monitoring pattern: a baseline of nominal-operation (js, w1) samples fixes per-divergence operating means and standard deviations together with a normal-quantile operating band. At runtime, each new divergence is converted to a one-sided z-score against its baseline, the two z-scores are combined into a composite deviation, and the confidence is exp(-z_composite / sensitivity) — exactly 1.0 while the twin tracks inside its calibrated band, decaying smoothly as it drifts away.

The scorer is review-only: it never proposes or applies actuation. It is a health observable consumed by the digital-twin operator evidence summary and the observability exporters.

Classes

TwinDivergence dataclass

TwinDivergence(
    phase_js_divergence: float,
    order_wasserstein: float,
    n_bins: int,
    backend: str,
)

Raw divergence pair between a model tick and its observed twin tick.

Attributes

phase_js_divergence : float Jensen–Shannon divergence (natural log) between the model and observed phase histograms, in [0, ln 2]. order_wasserstein : float One-dimensional Wasserstein-1 distance between the model and observed order-parameter windows, in [0, 1]. n_bins : int Number of phase histogram bins used. backend : str Name of the acceleration backend that produced the pair.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the divergence pair.

Returns

dict[str, object] Deterministic, JSON-safe mapping of the divergence fields.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the divergence pair.

    Returns
    -------
    dict[str, object]
        Deterministic, JSON-safe mapping of the divergence fields.
    """
    return {
        "phase_js_divergence": self.phase_js_divergence,
        "order_wasserstein": self.order_wasserstein,
        "n_bins": self.n_bins,
        "backend": self.backend,
    }

TwinConfidenceBaseline dataclass

TwinConfidenceBaseline(
    phase_js_mean: float,
    phase_js_std: float,
    order_w1_mean: float,
    order_w1_std: float,
    sample_count: int,
    band_z: float,
)

Calibrated nominal-operation baseline for twin divergences.

Attributes

phase_js_mean, phase_js_std : float Mean and (population) standard deviation of the nominal phase Jensen–Shannon divergence samples. order_w1_mean, order_w1_std : float Mean and (population) standard deviation of the nominal Wasserstein-1 samples. sample_count : int Number of nominal samples the baseline was fitted on. band_z : float Normal-quantile multiplier defining the upper operating band mean + band_z * std for each divergence.

Attributes
phase_js_upper_band property
phase_js_upper_band: float

Return the upper nominal operating band for the phase divergence.

Returns

float phase_js_mean + band_z * phase_js_std.

order_w1_upper_band property
order_w1_upper_band: float

Return the upper nominal operating band for the Wasserstein distance.

Returns

float order_w1_mean + band_z * order_w1_std.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the baseline.

Returns

dict[str, object] Deterministic, JSON-safe mapping of the baseline fields and bands.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the baseline.

    Returns
    -------
    dict[str, object]
        Deterministic, JSON-safe mapping of the baseline fields and bands.
    """
    return {
        "phase_js_mean": self.phase_js_mean,
        "phase_js_std": self.phase_js_std,
        "phase_js_upper_band": self.phase_js_upper_band,
        "order_w1_mean": self.order_w1_mean,
        "order_w1_std": self.order_w1_std,
        "order_w1_upper_band": self.order_w1_upper_band,
        "sample_count": self.sample_count,
        "band_z": self.band_z,
    }

TwinConfidenceScore dataclass

TwinConfidenceScore(
    confidence: float,
    status: str,
    phase_js_divergence: float,
    order_wasserstein: float,
    phase_js_z: float,
    order_w1_z: float,
    composite_z: float,
    phase_js_within_band: bool,
    order_w1_within_band: bool,
    backend: str,
    score_hash: str,
)

Online confidence score for one twin tick against a baseline.

Attributes

confidence : float Calibrated confidence in [0, 1]; 1.0 while the twin tracks inside its nominal band, decaying as it diverges. status : str Operator status: "healthy", "warning", or "critical". phase_js_divergence, order_wasserstein : float The raw divergences scored. phase_js_z, order_w1_z : float One-sided z-scores of each divergence against its baseline. composite_z : float Euclidean combination of the two one-sided z-scores. phase_js_within_band, order_w1_within_band : bool Whether each divergence is inside its calibrated upper operating band. backend : str Acceleration backend that produced the divergences. score_hash : str Deterministic SHA-256 over the audit record (excluding the hash).

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the confidence score.

Returns

dict[str, object] Deterministic, JSON-safe mapping of every score field including the score_hash.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the confidence score.

    Returns
    -------
    dict[str, object]
        Deterministic, JSON-safe mapping of every score field including the
        ``score_hash``.
    """
    return {
        "confidence": self.confidence,
        "status": self.status,
        "phase_js_divergence": self.phase_js_divergence,
        "order_wasserstein": self.order_wasserstein,
        "phase_js_z": self.phase_js_z,
        "order_w1_z": self.order_w1_z,
        "composite_z": self.composite_z,
        "phase_js_within_band": self.phase_js_within_band,
        "order_w1_within_band": self.order_w1_within_band,
        "backend": self.backend,
        "score_hash": self.score_hash,
    }

TwinConfidenceCalibrator dataclass

TwinConfidenceCalibrator(
    band_z: float = _DEFAULT_BAND_Z,
    _phase_js: list[float] = cast("list[float]", None),
    _order_w1: list[float] = cast("list[float]", None),
)

Accumulate nominal twin divergences into a calibrated baseline.

The calibrator ingests divergence pairs gathered while the twin is known to track its model (commissioning, healthy replay, or a trusted window) and fits per-divergence means, population standard deviations, and a normal-quantile operating band. The resulting :class:TwinConfidenceBaseline feeds :func:score_twin_confidence.

Attributes

band_z : float Normal-quantile multiplier for the upper operating band (default 3).

Attributes
sample_count property
sample_count: int

Return the number of nominal samples accumulated.

Returns

int Count of ingested divergence pairs.

Methods:
__post_init__
__post_init__() -> None

Validate configuration and initialise sample buffers.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def __post_init__(self) -> None:
    """Validate configuration and initialise sample buffers."""
    self.band_z = _validate_finite_real("band_z", self.band_z, minimum=0.0)
    self._phase_js = []
    self._order_w1 = []
observe
observe(divergence: TwinDivergence) -> None

Add one nominal divergence pair to the calibration set.

Parameters

divergence : TwinDivergence A divergence pair measured during trusted nominal operation.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def observe(self, divergence: TwinDivergence) -> None:
    """Add one nominal divergence pair to the calibration set.

    Parameters
    ----------
    divergence : TwinDivergence
        A divergence pair measured during trusted nominal operation.
    """
    self._phase_js.append(divergence.phase_js_divergence)
    self._order_w1.append(divergence.order_wasserstein)
observe_many
observe_many(divergences: Sequence[TwinDivergence]) -> None

Add several nominal divergence pairs to the calibration set.

Parameters

divergences : Sequence[TwinDivergence] Divergence pairs measured during trusted nominal operation.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def observe_many(self, divergences: Sequence[TwinDivergence]) -> None:
    """Add several nominal divergence pairs to the calibration set.

    Parameters
    ----------
    divergences : Sequence[TwinDivergence]
        Divergence pairs measured during trusted nominal operation.
    """
    for divergence in divergences:
        self.observe(divergence)
baseline
baseline() -> TwinConfidenceBaseline

Fit and return the calibrated baseline.

Returns

TwinConfidenceBaseline Per-divergence means, population standard deviations, sample count, and operating band multiplier.

Raises

ValueError If no nominal samples have been observed.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def baseline(self) -> TwinConfidenceBaseline:
    """Fit and return the calibrated baseline.

    Returns
    -------
    TwinConfidenceBaseline
        Per-divergence means, population standard deviations, sample count,
        and operating band multiplier.

    Raises
    ------
    ValueError
        If no nominal samples have been observed.
    """
    if not self._phase_js:
        raise ValueError("calibration requires at least one nominal sample")
    phase_js = np.asarray(self._phase_js, dtype=np.float64)
    order_w1 = np.asarray(self._order_w1, dtype=np.float64)
    return TwinConfidenceBaseline(
        phase_js_mean=float(np.mean(phase_js)),
        phase_js_std=float(np.std(phase_js)),
        order_w1_mean=float(np.mean(order_w1)),
        order_w1_std=float(np.std(order_w1)),
        sample_count=int(phase_js.size),
        band_z=self.band_z,
    )

TwinConfidenceSummary dataclass

TwinConfidenceSummary(
    tick_count: int,
    healthy_count: int,
    warning_count: int,
    critical_count: int,
    min_confidence: float,
    mean_confidence: float,
    latest_confidence: float,
    worst_status: str,
    latest_status: str,
    summary_hash: str,
)

Operator-facing aggregate over a sequence of twin-confidence scores.

Attributes

tick_count : int Number of scored ticks. healthy_count, warning_count, critical_count : int Per-status tick counts. min_confidence, mean_confidence : float Minimum and arithmetic-mean confidence across the scored ticks. latest_confidence : float Confidence of the most recently scored tick. worst_status : str "critical" if any tick was critical, else "warning" if any was warning, else "healthy". latest_status : str Status of the most recently scored tick. summary_hash : str Deterministic SHA-256 over the audit record (excluding the hash).

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the summary.

Returns

dict[str, object] Deterministic, JSON-safe mapping of every summary field including the summary_hash.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the summary.

    Returns
    -------
    dict[str, object]
        Deterministic, JSON-safe mapping of every summary field including
        the ``summary_hash``.
    """
    return {
        "tick_count": self.tick_count,
        "healthy_count": self.healthy_count,
        "warning_count": self.warning_count,
        "critical_count": self.critical_count,
        "min_confidence": self.min_confidence,
        "mean_confidence": self.mean_confidence,
        "latest_confidence": self.latest_confidence,
        "worst_status": self.worst_status,
        "latest_status": self.latest_status,
        "summary_hash": self.summary_hash,
    }

Functions:

phase_order_divergence

phase_order_divergence(
    model_phases: FloatArray,
    observed_phases: FloatArray,
    model_order: FloatArray,
    observed_order: FloatArray,
    *,
    n_bins: int = _DEFAULT_N_BINS,
) -> TwinDivergence

Compute the phase/order divergence pair for one twin tick.

Parameters

model_phases, observed_phases : FloatArray Model and observed phase vectors (radians). Must share length N >= 1. model_order, observed_order : FloatArray Model and observed order-parameter windows with values in [0, 1]. Must share length W >= 1. n_bins : int, optional Number of phase histogram bins (default 36, i.e. 10° per bin).

Returns

TwinDivergence The Jensen–Shannon phase divergence and Wasserstein-1 order distance, produced by the fastest available backend.

Raises

ValueError If shapes mismatch, lengths are empty, order values fall outside [0, 1], n_bins is not a positive integer, or a backend returns a non-physical pair.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def phase_order_divergence(
    model_phases: FloatArray,
    observed_phases: FloatArray,
    model_order: FloatArray,
    observed_order: FloatArray,
    *,
    n_bins: int = _DEFAULT_N_BINS,
) -> TwinDivergence:
    """Compute the phase/order divergence pair for one twin tick.

    Parameters
    ----------
    model_phases, observed_phases : FloatArray
        Model and observed phase vectors (radians). Must share length ``N >= 1``.
    model_order, observed_order : FloatArray
        Model and observed order-parameter windows with values in ``[0, 1]``.
        Must share length ``W >= 1``.
    n_bins : int, optional
        Number of phase histogram bins (default ``36``, i.e. 10° per bin).

    Returns
    -------
    TwinDivergence
        The Jensen–Shannon phase divergence and Wasserstein-1 order distance,
        produced by the fastest available backend.

    Raises
    ------
    ValueError
        If shapes mismatch, lengths are empty, order values fall outside
        ``[0, 1]``, ``n_bins`` is not a positive integer, or a backend returns a
        non-physical pair.
    """
    n_bins = _validate_positive_int("n_bins", n_bins)
    model_phases64 = _as_real_vector("model_phases", model_phases)
    observed_phases64 = _as_real_vector("observed_phases", observed_phases)
    model_order64 = _validate_order_window("model_order", model_order)
    observed_order64 = _validate_order_window("observed_order", observed_order)

    n = int(model_phases64.size)
    if n == 0:
        raise ValueError("model_phases must contain at least one phase")
    if observed_phases64.size != n:
        raise ValueError(
            f"observed_phases length {observed_phases64.size} != model_phases {n}"
        )
    w = int(model_order64.size)
    if w == 0:
        raise ValueError("model_order must contain at least one sample")
    if observed_order64.size != w:
        raise ValueError(
            f"observed_order length {observed_order64.size} != model_order {w}"
        )

    backend_name, backend_fn = _dispatch_backend()
    if backend_fn is None:
        raw = _python_kernel(
            model_phases64,
            observed_phases64,
            model_order64,
            observed_order64,
            n,
            w,
            n_bins,
        )
    else:
        raw = backend_fn(
            model_phases64,
            observed_phases64,
            model_order64,
            observed_order64,
            n,
            w,
            n_bins,
        )
    js, w1 = _validate_kernel_output(raw, backend=backend_name)
    return TwinDivergence(
        phase_js_divergence=js,
        order_wasserstein=w1,
        n_bins=n_bins,
        backend=backend_name,
    )

score_twin_confidence

score_twin_confidence(
    divergence: TwinDivergence,
    baseline: TwinConfidenceBaseline,
    *,
    sensitivity: float = _DEFAULT_SENSITIVITY,
    warning_confidence: float = _DEFAULT_WARNING_CONFIDENCE,
    critical_confidence: float = _DEFAULT_CRITICAL_CONFIDENCE,
) -> TwinConfidenceScore

Score one twin divergence against a calibrated baseline.

Each divergence is converted to a one-sided z-score against its baseline mean and standard deviation; the two z-scores are combined into a composite Euclidean deviation, and the confidence is exp(-composite_z / sensitivity)1.0 while both divergences sit at or below their nominal means, decaying smoothly as the twin drifts.

Parameters

divergence : TwinDivergence The divergence pair to score. baseline : TwinConfidenceBaseline The calibrated nominal baseline. sensitivity : float, optional Composite-deviation scale of the confidence decay (default 3): larger values decay more slowly. Must be > 0. warning_confidence : float, optional Confidence at or above which the status is "healthy" rather than "warning" (default 0.6). In [0, 1]. critical_confidence : float, optional Confidence below which the status is "critical" (default 0.3). In [0, 1] and <= warning_confidence.

Returns

TwinConfidenceScore The calibrated confidence, status, z-scores, band membership, and a deterministic audit hash.

Raises

ValueError If sensitivity <= 0 or the confidence thresholds are inconsistent.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def score_twin_confidence(
    divergence: TwinDivergence,
    baseline: TwinConfidenceBaseline,
    *,
    sensitivity: float = _DEFAULT_SENSITIVITY,
    warning_confidence: float = _DEFAULT_WARNING_CONFIDENCE,
    critical_confidence: float = _DEFAULT_CRITICAL_CONFIDENCE,
) -> TwinConfidenceScore:
    """Score one twin divergence against a calibrated baseline.

    Each divergence is converted to a one-sided z-score against its baseline
    mean and standard deviation; the two z-scores are combined into a composite
    Euclidean deviation, and the confidence is ``exp(-composite_z /
    sensitivity)`` — ``1.0`` while both divergences sit at or below their
    nominal means, decaying smoothly as the twin drifts.

    Parameters
    ----------
    divergence : TwinDivergence
        The divergence pair to score.
    baseline : TwinConfidenceBaseline
        The calibrated nominal baseline.
    sensitivity : float, optional
        Composite-deviation scale of the confidence decay (default ``3``):
        larger values decay more slowly. Must be ``> 0``.
    warning_confidence : float, optional
        Confidence at or above which the status is ``"healthy"`` rather than
        ``"warning"`` (default ``0.6``). In ``[0, 1]``.
    critical_confidence : float, optional
        Confidence below which the status is ``"critical"`` (default ``0.3``).
        In ``[0, 1]`` and ``<= warning_confidence``.

    Returns
    -------
    TwinConfidenceScore
        The calibrated confidence, status, z-scores, band membership, and a
        deterministic audit hash.

    Raises
    ------
    ValueError
        If ``sensitivity <= 0`` or the confidence thresholds are inconsistent.
    """
    sensitivity = _validate_finite_real("sensitivity", sensitivity, minimum=_EPS)
    warning_confidence = _validate_unit_interval(
        "warning_confidence", warning_confidence
    )
    critical_confidence = _validate_unit_interval(
        "critical_confidence", critical_confidence
    )
    if critical_confidence > warning_confidence:
        raise ValueError("critical_confidence must be <= warning_confidence")

    phase_js_z = _one_sided_z(
        divergence.phase_js_divergence,
        baseline.phase_js_mean,
        baseline.phase_js_std,
    )
    order_w1_z = _one_sided_z(
        divergence.order_wasserstein,
        baseline.order_w1_mean,
        baseline.order_w1_std,
    )
    composite_z = float(np.hypot(phase_js_z, order_w1_z))
    confidence = float(np.clip(np.exp(-composite_z / sensitivity), 0.0, 1.0))
    status = _confidence_status(
        confidence,
        warning_confidence=warning_confidence,
        critical_confidence=critical_confidence,
    )
    score = TwinConfidenceScore(
        confidence=confidence,
        status=status,
        phase_js_divergence=divergence.phase_js_divergence,
        order_wasserstein=divergence.order_wasserstein,
        phase_js_z=phase_js_z,
        order_w1_z=order_w1_z,
        composite_z=composite_z,
        phase_js_within_band=(
            divergence.phase_js_divergence <= baseline.phase_js_upper_band + _EPS
        ),
        order_w1_within_band=(
            divergence.order_wasserstein <= baseline.order_w1_upper_band + _EPS
        ),
        backend=divergence.backend,
        score_hash="",
    )
    return _with_hash(score)

summarise_twin_confidence

summarise_twin_confidence(
    scores: Sequence[TwinConfidenceScore],
) -> TwinConfidenceSummary

Aggregate a sequence of twin-confidence scores into operator evidence.

Parameters

scores : Sequence[TwinConfidenceScore] The per-tick scores in chronological order.

Returns

TwinConfidenceSummary The deterministic operator-facing aggregate.

Raises

ValueError If scores is empty.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def summarise_twin_confidence(
    scores: Sequence[TwinConfidenceScore],
) -> TwinConfidenceSummary:
    """Aggregate a sequence of twin-confidence scores into operator evidence.

    Parameters
    ----------
    scores : Sequence[TwinConfidenceScore]
        The per-tick scores in chronological order.

    Returns
    -------
    TwinConfidenceSummary
        The deterministic operator-facing aggregate.

    Raises
    ------
    ValueError
        If ``scores`` is empty.
    """
    if not scores:
        raise ValueError("summarise_twin_confidence requires at least one score")
    confidences = [score.confidence for score in scores]
    healthy = sum(1 for score in scores if score.status == "healthy")
    warning = sum(1 for score in scores if score.status == "warning")
    critical = sum(1 for score in scores if score.status == "critical")
    if critical:
        worst_status = "critical"
    elif warning:
        worst_status = "warning"
    else:
        worst_status = "healthy"
    summary = TwinConfidenceSummary(
        tick_count=len(scores),
        healthy_count=healthy,
        warning_count=warning,
        critical_count=critical,
        min_confidence=float(min(confidences)),
        mean_confidence=float(sum(confidences) / len(confidences)),
        latest_confidence=scores[-1].confidence,
        worst_status=worst_status,
        latest_status=scores[-1].status,
        summary_hash="",
    )
    return _with_summary_hash(summary)

twin_confidence_prometheus_text

twin_confidence_prometheus_text(
    summary: TwinConfidenceSummary, *, prefix: str = "spo"
) -> str

Render a twin-confidence summary as Prometheus exposition text.

Parameters

summary : TwinConfidenceSummary The operator-facing aggregate to export. prefix : str, optional Metric-name prefix (default "spo").

Returns

str Prometheus exposition text with confidence gauges, per-status counters, and a numeric worst-status level gauge.

Raises

ValueError If prefix is not a non-empty string.

Source code in src/scpn_phase_orchestrator/monitor/twin_confidence.py
def twin_confidence_prometheus_text(
    summary: TwinConfidenceSummary,
    *,
    prefix: str = "spo",
) -> str:
    """Render a twin-confidence summary as Prometheus exposition text.

    Parameters
    ----------
    summary : TwinConfidenceSummary
        The operator-facing aggregate to export.
    prefix : str, optional
        Metric-name prefix (default ``"spo"``).

    Returns
    -------
    str
        Prometheus exposition text with confidence gauges, per-status counters,
        and a numeric worst-status level gauge.

    Raises
    ------
    ValueError
        If ``prefix`` is not a non-empty string.
    """
    _require_non_empty(prefix, "prefix")
    lines = [
        f"# HELP {prefix}_twin_confidence_mean Mean twin confidence over scored ticks",
        f"# TYPE {prefix}_twin_confidence_mean gauge",
        f"{prefix}_twin_confidence_mean {summary.mean_confidence}",
        f"# HELP {prefix}_twin_confidence_min Minimum twin confidence over ticks",
        f"# TYPE {prefix}_twin_confidence_min gauge",
        f"{prefix}_twin_confidence_min {summary.min_confidence}",
        f"# HELP {prefix}_twin_confidence_latest Most recent twin confidence",
        f"# TYPE {prefix}_twin_confidence_latest gauge",
        f"{prefix}_twin_confidence_latest {summary.latest_confidence}",
        f"# HELP {prefix}_twin_confidence_tick_count Scored twin-confidence ticks",
        f"# TYPE {prefix}_twin_confidence_tick_count gauge",
        f"{prefix}_twin_confidence_tick_count {summary.tick_count}",
        (
            f"# HELP {prefix}_twin_confidence_status_total "
            "Twin-confidence ticks per operator status"
        ),
        f"# TYPE {prefix}_twin_confidence_status_total counter",
        (
            f'{prefix}_twin_confidence_status_total{{status="healthy"}} '
            f"{summary.healthy_count}"
        ),
        (
            f'{prefix}_twin_confidence_status_total{{status="warning"}} '
            f"{summary.warning_count}"
        ),
        (
            f'{prefix}_twin_confidence_status_total{{status="critical"}} '
            f"{summary.critical_count}"
        ),
        (
            f"# HELP {prefix}_twin_confidence_worst_status_level "
            "Worst operator status (0 healthy, 1 warning, 2 critical)"
        ),
        f"# TYPE {prefix}_twin_confidence_worst_status_level gauge",
        (
            f"{prefix}_twin_confidence_worst_status_level "
            f"{_STATUS_LEVELS[summary.worst_status]}"
        ),
    ]
    return "\n".join(lines) + "\n"

Conformal Twin-Confidence Gate

Wraps the twin-confidence stream in a distribution-free admission gate. From a trusted nominal calibration window it learns a threshold on the composite z-deviation such that nominal ticks stay inside the band with probability 1 − target_miscoverage, then admits a tick only when its score is inside the band. The threshold adapts online by Adaptive Conformal Inference (Gibbs & Candès, 2021) so the long-run empirical miscoverage tracks the target under non-stationarity, and it can be regime-conditioned (a separate band per detected sync / chimera / chaotic regime). Review-only: a flagged tick signals the twin has drifted beyond its calibrated band and autonomy should narrow. In the generic simulation loop, callers can supply a calibrated gate and deployment-specific twin-confidence source; rejected conformal ticks suppress the current policy action set and are recorded in result/audit surfaces. The default CLI run has no observed-twin feed, so this admission gate is opt-in.

twin_conformal_gate

Coverage-valid admission gate over the twin-confidence stream.

The twin-confidence score (:mod:scpn_phase_orchestrator.monitor.twin_confidence) quantifies model–observation disagreement, but a raw score gives no statistical guarantee. This module wraps the stream in a distribution-free conformal gate: it learns, from a trusted nominal calibration window, a threshold on a nonconformity score (the composite z-deviation) such that nominal ticks fall inside the band with probability 1 − target_miscoverage, then admits a tick only when its score stays inside the band.

Because the twin's behaviour is non-stationary, the threshold adapts online by Adaptive Conformal Inference (Gibbs & Candès, 2021): the effective miscoverage alpha_t is nudged up when a tick is covered and down when it is missed, so the long-run empirical miscoverage tracks the target. The gate is optionally regime conditioned — it keeps a separate calibration set and alpha_t per detected regime (sync / chimera / chaotic), which SPO already classifies — so the band is appropriate to the current dynamical regime rather than a global average.

This is a review-only safety observable: a flagged tick signals the twin has drifted beyond its calibrated nominal band and that autonomy should narrow; it never actuates. The computation is lightweight online statistics (one sorted calibration array per regime, O(1) per update), so it has no compute hot path and no multi-language backend.

References

Gibbs, I. & Candès, E. (2021). Adaptive conformal inference under distribution shift. NeurIPS. Regime conditioning follows the change-point/transition-conformal direction (e.g. arXiv:2509.02844); only the established ACI core is implemented here, with regime conditioning as the SPO-specific adaptation.

Classes

ConformalGateConfig dataclass

ConformalGateConfig(
    target_miscoverage: float = 0.1,
    adaptation_rate: float = 0.02,
    regime_conditioned: bool = True,
)

Configuration for the conformal admission gate.

Attributes

target_miscoverage : float Desired long-run fraction of nominal ticks falling outside the band (alpha); in (0, 1), default 0.1 (90% coverage). adaptation_rate : float Adaptive Conformal Inference step size (gamma); in (0, 1], default 0.02. regime_conditioned : bool Whether to keep a separate calibration set and adaptive miscoverage per regime (default True).

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the configuration.

Returns

dict[str, object] Deterministic, JSON-safe mapping of the configuration fields.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the configuration.

    Returns
    -------
    dict[str, object]
        Deterministic, JSON-safe mapping of the configuration fields.
    """
    return {
        "target_miscoverage": self.target_miscoverage,
        "adaptation_rate": self.adaptation_rate,
        "regime_conditioned": self.regime_conditioned,
    }

ConformalDecision dataclass

ConformalDecision(
    admitted: bool,
    nonconformity_score: float,
    threshold: float,
    effective_miscoverage: float,
    empirical_coverage: float,
    regime: str,
    tick: int,
    decision_hash: str,
)

One conformal admission decision for a twin-confidence tick.

Attributes

admitted : bool True when the nonconformity score is within the conformal band. nonconformity_score : float The scored tick's nonconformity value. threshold : float The conformal band upper bound used for this decision (may be infinite when the calibration set is too small to bound at the current level). effective_miscoverage : float The adaptive miscoverage alpha_t in force for this decision. empirical_coverage : float Running fraction of admitted ticks for this regime, including this tick. regime : str The regime key the decision was scored against. tick : int Per-regime decision index (1-based). decision_hash : str Deterministic SHA-256 over the audit record (excluding the hash).

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the decision.

Returns

dict[str, object] Deterministic, JSON-safe mapping of every decision field. An infinite threshold is serialised as None to stay JSON-safe.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the decision.

    Returns
    -------
    dict[str, object]
        Deterministic, JSON-safe mapping of every decision field. An infinite
        threshold is serialised as ``None`` to stay JSON-safe.
    """
    return {
        "admitted": self.admitted,
        "nonconformity_score": self.nonconformity_score,
        "threshold": self.threshold if isfinite(self.threshold) else None,
        "effective_miscoverage": self.effective_miscoverage,
        "empirical_coverage": self.empirical_coverage,
        "regime": self.regime,
        "tick": self.tick,
        "decision_hash": self.decision_hash,
    }

TwinConformalGate dataclass

TwinConformalGate(
    config: ConformalGateConfig = ConformalGateConfig(),
)

Adaptive conformal admission gate over twin nonconformity scores.

Attributes

config : ConformalGateConfig The gate configuration.

Methods:
calibrate
calibrate(
    nominal_scores: Sequence[float],
    *,
    regime: str = _DEFAULT_REGIME,
) -> None

Fit the conformal band for a regime from nominal nonconformity scores.

Parameters

nominal_scores : Sequence[float] Nonconformity scores gathered during trusted nominal operation. regime : str, optional Regime key to calibrate (default "default").

Raises

ValueError If nominal_scores is empty, a score is non-finite, or regime is not a non-empty string.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def calibrate(
    self,
    nominal_scores: Sequence[float],
    *,
    regime: str = _DEFAULT_REGIME,
) -> None:
    """Fit the conformal band for a regime from nominal nonconformity scores.

    Parameters
    ----------
    nominal_scores : Sequence[float]
        Nonconformity scores gathered during trusted nominal operation.
    regime : str, optional
        Regime key to calibrate (default ``"default"``).

    Raises
    ------
    ValueError
        If ``nominal_scores`` is empty, a score is non-finite, or ``regime``
        is not a non-empty string.
    """
    if not isinstance(regime, str) or not regime.strip():
        raise ValueError("regime must be a non-empty string")
    if len(nominal_scores) == 0:
        raise ValueError("calibration requires at least one nominal score")
    scores = np.sort(
        np.asarray(
            [_finite_real(value, name="nominal score") for value in nominal_scores],
            dtype=np.float64,
        )
    )
    self._regimes[regime] = _RegimeState(
        calibration=scores,
        alpha_t=self.config.target_miscoverage,
    )
update
update(
    nonconformity_score: float, *, regime: str | None = None
) -> ConformalDecision

Score one tick against the conformal band and adapt the threshold.

Parameters

nonconformity_score : float The tick's nonconformity value (higher = more anomalous). regime : str or None, optional Detected regime; used when the gate is regime conditioned and the regime is calibrated, otherwise the "default" regime is used.

Returns

ConformalDecision The admission decision and the post-update running coverage.

Raises

ValueError If the score is non-finite or no applicable regime has been calibrated.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def update(
    self,
    nonconformity_score: float,
    *,
    regime: str | None = None,
) -> ConformalDecision:
    """Score one tick against the conformal band and adapt the threshold.

    Parameters
    ----------
    nonconformity_score : float
        The tick's nonconformity value (higher = more anomalous).
    regime : str or None, optional
        Detected regime; used when the gate is regime conditioned and the
        regime is calibrated, otherwise the ``"default"`` regime is used.

    Returns
    -------
    ConformalDecision
        The admission decision and the post-update running coverage.

    Raises
    ------
    ValueError
        If the score is non-finite or no applicable regime has been calibrated.
    """
    score = _finite_real(nonconformity_score, name="nonconformity_score")
    key = self._resolve_regime(regime)
    state = self._regimes[key]

    threshold = _conformal_threshold(state.calibration, state.alpha_t)
    admitted = score <= threshold
    alpha_used = state.alpha_t

    state.total += 1
    if admitted:
        state.admitted += 1
    self._ticks += 1

    # Adaptive Conformal Inference: alpha_{t+1} = alpha_t + gamma*(alpha - err)
    miscovered = 0.0 if admitted else 1.0
    state.alpha_t = float(
        np.clip(
            state.alpha_t
            + self.config.adaptation_rate
            * (self.config.target_miscoverage - miscovered),
            0.0,
            1.0,
        )
    )

    decision = ConformalDecision(
        admitted=bool(admitted),
        nonconformity_score=score,
        threshold=float(threshold),
        effective_miscoverage=alpha_used,
        empirical_coverage=state.admitted / state.total,
        regime=key,
        tick=state.total,
        decision_hash="",
    )
    return _with_decision_hash(decision)
empirical_coverage
empirical_coverage(*, regime: str | None = None) -> float

Return the running admitted fraction for a regime.

Parameters

regime : str or None, optional Regime key (default-resolved when None).

Returns

float Admitted ticks over total ticks for the regime, or 0.0 before any tick has been scored.

Raises

ValueError If no applicable regime has been calibrated.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def empirical_coverage(self, *, regime: str | None = None) -> float:
    """Return the running admitted fraction for a regime.

    Parameters
    ----------
    regime : str or None, optional
        Regime key (default-resolved when ``None``).

    Returns
    -------
    float
        Admitted ticks over total ticks for the regime, or ``0.0`` before any
        tick has been scored.

    Raises
    ------
    ValueError
        If no applicable regime has been calibrated.
    """
    state = self._regimes[self._resolve_regime(regime)]
    if state.total == 0:
        return 0.0
    return state.admitted / state.total
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the gate state.

Returns

dict[str, object] Configuration, total ticks scored, and per-regime calibration size, adaptive miscoverage, and coverage.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the gate state.

    Returns
    -------
    dict[str, object]
        Configuration, total ticks scored, and per-regime calibration size,
        adaptive miscoverage, and coverage.
    """
    return {
        "config": self.config.to_audit_record(),
        "ticks": self._ticks,
        "regimes": {
            key: {
                "calibration_size": int(state.calibration.size),
                "effective_miscoverage": state.alpha_t,
                "coverage": (state.admitted / state.total) if state.total else 0.0,
                "ticks": state.total,
            }
            for key, state in sorted(self._regimes.items())
        },
    }

Functions:

confidence_nonconformity

confidence_nonconformity(
    score: TwinConfidenceScore,
) -> float

Return the nonconformity score used by the gate for a confidence score.

The composite one-sided z-deviation is already a non-negative "how far from nominal" quantity, which is exactly the nonconformity scale the conformal gate expects.

Parameters

score : TwinConfidenceScore A scored twin-confidence tick.

Returns

float The composite z-deviation as a nonconformity score.

Source code in src/scpn_phase_orchestrator/monitor/twin_conformal_gate.py
def confidence_nonconformity(score: TwinConfidenceScore) -> float:
    """Return the nonconformity score used by the gate for a confidence score.

    The composite one-sided z-deviation is already a non-negative "how far from
    nominal" quantity, which is exactly the nonconformity scale the conformal gate
    expects.

    Parameters
    ----------
    score : TwinConfidenceScore
        A scored twin-confidence tick.

    Returns
    -------
    float
        The composite z-deviation as a nonconformity score.
    """
    return float(score.composite_z)

Conformal Alarm Streams

Extends the same finite-sample split-conformal calibration to early-warning alarm streams. ConformalAlarmStream learns an alarm threshold from a window of trusted nominal (transition-free) detector scores so that, on exchangeable nominal operation, the probability of a false alarm is bounded by the configured target_false_alarm (the conformal alpha); the guarantee is the marginal split-conformal one and nothing more. It flags an alarm whenever a live score exceeds the threshold, reports the running empirical false-alarm rate over the ticks it is told are nominal, and can adapt the threshold online by Adaptive Conformal Inference (Gibbs & Candès, 2021) when the nominal distribution drifts — consuming only nominal ticks, because an alarm on an event tick is a detection, not a false alarm. It makes no claim about detection power. Review-only: an alarm signals the nominal false-alarm budget was exceeded at a calibrated rate.

Configuration and decision records normalise valid Python/NumPy real scalars to JSON-safe floats and reject boolean or non-finite numeric aliases. Nominal labels must be exact booleans (or None for an unlabelled tick), regime keys must be non-empty text, and direct decision records enforce unit-interval rates, non-negative tick counts, and finite scores before audit serialisation.

from scpn_phase_orchestrator.monitor.conformal_alarm import (
    ConformalAlarmConfig,
    ConformalAlarmStream,
)

stream = ConformalAlarmStream(ConformalAlarmConfig(target_false_alarm=0.1))
stream.calibrate(nominal_scores)          # trusted transition-free window
decision = stream.update(live_score, is_nominal=False)
assert isinstance(decision.alarm, bool)

conformal_alarm

Split-conformal false-alarm control for early-warning detector streams.

An early-warning detector emits a stream of scores, higher meaning more evidence of an approaching transition. Turning that stream into alarms needs a threshold whose false-alarm rate on nominal operation is controlled, not guessed. This module calibrates that threshold with the same finite-sample split-conformal quantile the twin-confidence gate uses (Vovk et al.; Gibbs & Candès 2021), so on exchangeable nominal scores the probability of an alarm is bounded by the target false-alarm rate. It fires alarms on a live stream, reports the empirical false-alarm rate over the nominal ticks it is told about, and can adapt the threshold online with Adaptive Conformal Inference when the nominal distribution drifts.

The coverage statement is exactly the split-conformal one — a bound on the nominal false-alarm rate under exchangeability — and nothing more: an alarm on an event tick is a detection, not a miscoverage, so online adaptation only ever consumes ticks that are declared nominal. It makes no claim about detection power.

Classes

ConformalAlarmConfig dataclass

ConformalAlarmConfig(
    target_false_alarm: float = 0.1,
    adaptation_rate: float = 0.0,
    regime_conditioned: bool = False,
)

Configuration of a split-conformal alarm stream.

Parameters

target_false_alarm : float Allowed long-run fraction of nominal ticks that raise an alarm (the conformal alpha); in (0, 1), default 0.1. adaptation_rate : float Adaptive Conformal Inference step size; 0.0 (default) keeps the fixed split-conformal threshold, a positive value lets the threshold track a drifting nominal distribution over the ticks declared nominal. regime_conditioned : bool Whether to keep a separate calibration and adaptive rate per regime.

Raises

ValueError If target_false_alarm is not in (0, 1) or adaptation_rate is negative.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe mapping of the configuration.

Returns

dict[str, object] The target false alarm, adaptation rate, and regime-conditioning flag.

Source code in src/scpn_phase_orchestrator/monitor/conformal_alarm.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe mapping of the configuration.

    Returns
    -------
    dict[str, object]
        The target false alarm, adaptation rate, and regime-conditioning flag.
    """
    return {
        "target_false_alarm": self.target_false_alarm,
        "adaptation_rate": self.adaptation_rate,
        "regime_conditioned": self.regime_conditioned,
    }

ConformalAlarmDecision dataclass

ConformalAlarmDecision(
    alarm: bool,
    score: float,
    threshold: float,
    effective_false_alarm: float,
    empirical_false_alarm: float,
    regime: str,
    nominal_ticks: int,
)

The alarm decision for one tick and the running nominal coverage.

Parameters

alarm : bool True when the score exceeds the conformal threshold. score : float The tick's detector score. threshold : float The conformal threshold used (may be +inf when the calibration is too small to place a finite bound at the target rate). effective_false_alarm : float The adaptive false-alarm target alpha_t in force for this decision. empirical_false_alarm : float Running fraction of nominal ticks that alarmed, this tick included when it is nominal. regime : str The regime the decision was scored under. nominal_ticks : int Number of nominal ticks scored in this regime so far.

Methods:
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe mapping of the decision.

The threshold is serialised as the string "inf" when unbounded so the record stays strict JSON.

Returns

dict[str, object] The alarm flag, score, threshold, effective and empirical false-alarm rates, regime, and nominal tick count.

Source code in src/scpn_phase_orchestrator/monitor/conformal_alarm.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe mapping of the decision.

    The threshold is serialised as the string ``"inf"`` when unbounded so the
    record stays strict JSON.

    Returns
    -------
    dict[str, object]
        The alarm flag, score, threshold, effective and empirical false-alarm
        rates, regime, and nominal tick count.
    """
    threshold: object = self.threshold
    if not np.isfinite(self.threshold):
        threshold = "inf"
    return {
        "alarm": self.alarm,
        "score": self.score,
        "threshold": threshold,
        "effective_false_alarm": self.effective_false_alarm,
        "empirical_false_alarm": self.empirical_false_alarm,
        "regime": self.regime,
        "nominal_ticks": self.nominal_ticks,
    }

ConformalAlarmStream dataclass

ConformalAlarmStream(
    config: ConformalAlarmConfig = ConformalAlarmConfig(),
)

Adaptive split-conformal alarm stream over detector scores.

Attributes

config : ConformalAlarmConfig The alarm-stream configuration.

Methods:
calibrate
calibrate(
    nominal_scores: Sequence[float],
    *,
    regime: str = _DEFAULT_REGIME,
) -> None

Fit the conformal threshold for a regime from nominal detector scores.

Parameters

nominal_scores : Sequence[float] Detector scores gathered during trusted transition-free operation. regime : str, optional Regime key to calibrate (default "default").

Raises

ValueError If nominal_scores is empty, a score is non-finite, or regime is not a non-empty string.

Source code in src/scpn_phase_orchestrator/monitor/conformal_alarm.py
def calibrate(
    self,
    nominal_scores: Sequence[float],
    *,
    regime: str = _DEFAULT_REGIME,
) -> None:
    """Fit the conformal threshold for a regime from nominal detector scores.

    Parameters
    ----------
    nominal_scores : Sequence[float]
        Detector scores gathered during trusted transition-free operation.
    regime : str, optional
        Regime key to calibrate (default ``"default"``).

    Raises
    ------
    ValueError
        If ``nominal_scores`` is empty, a score is non-finite, or ``regime``
        is not a non-empty string.
    """
    if not isinstance(regime, str) or not regime.strip():
        raise ValueError("regime must be a non-empty string")
    if len(nominal_scores) == 0:
        raise ValueError("calibration requires at least one nominal score")
    scores = np.sort(
        np.asarray(
            [_finite_real(value, name="nominal score") for value in nominal_scores],
            dtype=np.float64,
        )
    )
    self._regimes[regime] = _RegimeState(
        calibration=scores,
        alpha_t=self.config.target_false_alarm,
    )
update
update(
    score: float,
    *,
    is_nominal: bool | None = None,
    regime: str | None = None,
) -> ConformalAlarmDecision

Score one tick against the conformal threshold and report coverage.

Parameters

score : float The tick's detector score (higher = more anomalous). is_nominal : bool or None, optional Whether the tick is known to be transition-free. Only nominal ticks update the empirical false-alarm rate and the adaptive threshold; an alarm on an event tick is a detection, not a false alarm, and an unlabelled tick (None) is scored without touching the calibration. regime : str or None, optional Detected regime; used when the stream is regime conditioned and the regime is calibrated, otherwise the "default" regime is used.

Returns

ConformalAlarmDecision The alarm decision and the running nominal false-alarm rate.

Raises

ValueError If the score is non-finite or no applicable regime has been calibrated.

Source code in src/scpn_phase_orchestrator/monitor/conformal_alarm.py
def update(
    self,
    score: float,
    *,
    is_nominal: bool | None = None,
    regime: str | None = None,
) -> ConformalAlarmDecision:
    """Score one tick against the conformal threshold and report coverage.

    Parameters
    ----------
    score : float
        The tick's detector score (higher = more anomalous).
    is_nominal : bool or None, optional
        Whether the tick is known to be transition-free. Only nominal ticks
        update the empirical false-alarm rate and the adaptive threshold; an
        alarm on an event tick is a detection, not a false alarm, and an
        unlabelled tick (``None``) is scored without touching the calibration.
    regime : str or None, optional
        Detected regime; used when the stream is regime conditioned and the
        regime is calibrated, otherwise the ``"default"`` regime is used.

    Returns
    -------
    ConformalAlarmDecision
        The alarm decision and the running nominal false-alarm rate.

    Raises
    ------
    ValueError
        If the score is non-finite or no applicable regime has been calibrated.
    """
    if is_nominal is not None and not isinstance(is_nominal, bool):
        raise ValueError("is_nominal must be a boolean or None")
    value = _finite_real(score, name="score")
    key = self._resolve_regime(regime)
    state = self._regimes[key]

    threshold = _conformal_threshold(state.calibration, state.alpha_t)
    alarm = value > threshold
    alpha_used = state.alpha_t

    if is_nominal:
        state.total_nominal += 1
        if alarm:
            state.alarmed_nominal += 1
        # Adaptive Conformal Inference over nominal ticks:
        # alpha_{t+1} = alpha_t + gamma * (target - realised false alarm).
        realised = 1.0 if alarm else 0.0
        state.alpha_t = float(
            np.clip(
                state.alpha_t
                + self.config.adaptation_rate
                * (self.config.target_false_alarm - realised),
                0.0,
                1.0,
            )
        )

    return ConformalAlarmDecision(
        alarm=bool(alarm),
        score=value,
        threshold=float(threshold),
        effective_false_alarm=alpha_used,
        empirical_false_alarm=self._empirical_false_alarm(state),
        regime=key,
        nominal_ticks=state.total_nominal,
    )
empirical_false_alarm
empirical_false_alarm(
    *, regime: str | None = None
) -> float

Return the running nominal false-alarm rate for a regime.

Parameters

regime : str or None, optional Regime key (default-resolved when None).

Returns

float Alarmed nominal ticks over total nominal ticks, or 0.0 before any nominal tick has been scored.

Raises

ValueError If no applicable regime has been calibrated.

Source code in src/scpn_phase_orchestrator/monitor/conformal_alarm.py
def empirical_false_alarm(self, *, regime: str | None = None) -> float:
    """Return the running nominal false-alarm rate for a regime.

    Parameters
    ----------
    regime : str or None, optional
        Regime key (default-resolved when ``None``).

    Returns
    -------
    float
        Alarmed nominal ticks over total nominal ticks, or ``0.0`` before any
        nominal tick has been scored.

    Raises
    ------
    ValueError
        If no applicable regime has been calibrated.
    """
    return self._empirical_false_alarm(self._regimes[self._resolve_regime(regime)])
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit mapping of the stream state.

Returns

dict[str, object] Configuration and, per regime, the calibration size, adaptive false alarm, nominal tick count, and empirical false-alarm rate.

Source code in src/scpn_phase_orchestrator/monitor/conformal_alarm.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit mapping of the stream state.

    Returns
    -------
    dict[str, object]
        Configuration and, per regime, the calibration size, adaptive false
        alarm, nominal tick count, and empirical false-alarm rate.
    """
    return {
        "config": self.config.to_audit_record(),
        "regimes": {
            key: {
                "calibration_size": int(state.calibration.size),
                "effective_false_alarm": state.alpha_t,
                "nominal_ticks": state.total_nominal,
                "empirical_false_alarm": self._empirical_false_alarm(state),
            }
            for key, state in sorted(self._regimes.items())
        },
    }

Entropy Production Rate

Measures the thermodynamic irreversibility of the phase dynamics. Higher entropy production means the system is further from equilibrium — it is actively dissipating energy to maintain its current synchronization state.

Theory: For Kuramoto dynamics, entropy production rate is proportional to the mean squared coupling torque. A system at thermal equilibrium (detailed balance) has zero entropy production; a synchronised Kuramoto network actively maintained by coupling has positive entropy production.

The public dispatcher and backend adapters reject boolean aliases, numeric-string aliases, complex/object-complex payloads, non-finite values, shape mismatches, negative timesteps, and negative backend entropy-rate outputs before publishing a dissipation value.

entropy_prod

Overdamped-Kuramoto thermodynamic dissipation rate with a 5-backend chain.

Σ = Σ_i (dθ_i/dt)² · dt
dθ_i/dt = ω_i + (α / N) · Σ_j K_ij · sin(θ_j − θ_i)

Zero at frequency-locked fixed points; positive otherwise. Reference: Acebrón et al. 2005, Rev. Mod. Phys. 77:137–185.

Functions:

entropy_production_rate

entropy_production_rate(
    phases: object,
    omegas: object,
    knm: object,
    alpha: object,
    dt: object,
) -> float

Thermodynamic dissipation rate Σ (dθ/dt)² · dt.

dθ_i/dt = ω_i + (α / N) Σ_j K_ij sin(θ_j − θ_i). Zero at frequency-locked fixed points; positive otherwise.

Acebrón et al. 2005, Rev. Mod. Phys. 77:137–185.

Parameters

phases : object (N,) instantaneous phases in radians. omegas : object (N,) natural frequencies. knm : object (N, N) coupling matrix. alpha : object global coupling strength. dt : object integration timestep for the · dt factor.

Returns

float Non-negative dissipation scalar.

Raises

ValueError If the inputs are non-finite or mismatched.

Source code in src/scpn_phase_orchestrator/monitor/entropy_prod.py
def entropy_production_rate(
    phases: object,
    omegas: object,
    knm: object,
    alpha: object,
    dt: object,
) -> float:
    """Thermodynamic dissipation rate ``Σ (dθ/dt)² · dt``.

    ``dθ_i/dt = ω_i + (α / N) Σ_j K_ij sin(θ_j − θ_i)``. Zero at
    frequency-locked fixed points; positive otherwise.

    Acebrón et al. 2005, Rev. Mod. Phys. **77**:137–185.

    Parameters
    ----------
    phases : object
        ``(N,)`` instantaneous phases in radians.
    omegas : object
        ``(N,)`` natural frequencies.
    knm : object
        ``(N, N)`` coupling matrix.
    alpha : object
        global coupling strength.
    dt : object
        integration timestep for the ``· dt`` factor.

    Returns
    -------
    float
        Non-negative dissipation scalar.

    Raises
    ------
    ValueError
        If the inputs are non-finite or mismatched.
    """
    phases = _validate_vector(phases, name="phases")
    n = int(phases.size)
    omegas = _validate_vector(omegas, name="omegas")
    if omegas.shape != phases.shape:
        raise ValueError(f"omegas shape {omegas.shape} does not match {phases.shape}")
    knm = _validate_matrix(knm, name="knm", expected_shape=(n, n))
    alpha = _validate_finite_float(alpha, name="alpha")
    dt = _validate_finite_float(dt, name="dt")
    if dt < 0.0:
        raise ValueError(f"dt must be non-negative, got {dt!r}")
    if n == 0 or dt == 0.0:
        return 0.0
    backend_fn = _dispatch()
    if backend_fn is not None:
        try:
            backend_rate = backend_fn(phases, omegas, knm, alpha, dt)
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None
        else:
            return _validate_entropy_rate(
                backend_rate,
                name="backend entropy rate",
            )

    diff = phases[np.newaxis, :] - phases[:, np.newaxis]
    coupling = np.sum(knm * np.sin(diff), axis=1)
    dtheta_dt = omegas + (alpha / n) * coupling
    return _validate_entropy_rate(np.sum(dtheta_dt**2) * dt)

Winding Number

Topological invariant counting how many times the phase wraps around the circle [0, 2π) over a time window. The winding number is an integer-valued quantity that is robust to noise and small perturbations.

Usage:

from scpn_phase_orchestrator.monitor.winding import winding_numbers

# phases_history: (T, N) phase trajectory
w = winding_numbers(phases_history)  # (N,) integer winding numbers

Different winding numbers for different oscillators indicate frequency differences; a sudden change in winding number signals a phase slip (loss of synchronization with a specific partner).

Public and direct accelerator contracts reject boolean aliases, numeric-string aliases, complex/object-complex payloads, non-finite phase histories, malformed cardinality, non-integer winding outputs, out-of-bound winding counts, and exact-reference divergence before integer winding evidence reaches reports or benchmark gates.

winding

Cumulative winding-number tracker with a 5-backend fallback chain.

w_i = floor(Σ_t wrap(Δθ_{i,t}) / 2π) where wrap(x) ∈ (−π, π]. Counts how many full rotations each oscillator completes across a phase history; positive = counterclockwise, negative = clockwise.

Functions:

winding_numbers

winding_numbers(phases_history: FloatArray) -> IntArray

Cumulative winding number of each oscillator over a trajectory.

w_i = floor(Σ_t wrap(Δθ_{i,t}) / 2π) with wrap(x) ∈ (−π, π].

Parameters

phases_history : FloatArray (T, N) phases in radians.

Returns

IntArray (N,) int64 array of winding numbers.

Source code in src/scpn_phase_orchestrator/monitor/winding.py
def winding_numbers(phases_history: FloatArray) -> IntArray:
    """Cumulative winding number of each oscillator over a trajectory.

    ``w_i = floor(Σ_t wrap(Δθ_{i,t}) / 2π)`` with
    ``wrap(x) ∈ (−π, π]``.

    Parameters
    ----------
    phases_history : FloatArray
        ``(T, N)`` phases in radians.

    Returns
    -------
    IntArray
        ``(N,)`` int64 array of winding numbers.
    """
    phases_history = _validate_phase_history(phases_history)
    if phases_history.ndim != 2 or phases_history.shape[0] < 2:
        n = phases_history.shape[-1] if phases_history.ndim == 2 else 0
        return np.zeros(n, dtype=np.int64)

    t, n = int(phases_history.shape[0]), int(phases_history.shape[1])
    flat: FloatArray = np.ascontiguousarray(phases_history.ravel(), dtype=np.float64)
    expected = _winding_reference(phases_history)

    backend_fn = _dispatch()
    if backend_fn is not None:
        try:
            return _validate_backend_winding(
                backend_fn(flat, t, n),
                n=n,
                t=t,
                expected=expected,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            n = int(n)

    return _validate_backend_winding(expected, n=n, t=t, expected=expected)

winding_vector

winding_vector(phases_history: FloatArray) -> IntArray

N-dimensional integer classification vector from winding numbers.

Alias for :func:winding_numbers; topologically distinct trajectories map to distinct integer-lattice points.

Parameters

phases_history : FloatArray Phase history, shape (T, N).

Returns

IntArray The integer winding classification vector.

Source code in src/scpn_phase_orchestrator/monitor/winding.py
def winding_vector(phases_history: FloatArray) -> IntArray:
    """N-dimensional integer classification vector from winding numbers.

    Alias for :func:`winding_numbers`; topologically distinct
    trajectories map to distinct integer-lattice points.

    Parameters
    ----------
    phases_history : FloatArray
        Phase history, shape ``(T, N)``.

    Returns
    -------
    IntArray
        The integer winding classification vector.
    """
    return winding_numbers(phases_history)

Inter-Trial Phase Coherence (ITPC)

Standard neuroscience measure of phase consistency across repeated trials or time windows. ITPC = |mean(exp(i*theta))| computed across trials at each time point.

ITPC = 1: perfect phase alignment across trials (stimulus-locked). ITPC ≈ 0: random phase relationship (no consistent response).

Used by the EVS monitor as one of three entrainment criteria.

itpc

Lachaux 1999 inter-trial phase coherence with a 5-backend fallback chain.

Two kernels:

  • :func:compute_itpc — ITPC across trials at each time point.
  • :func:itpc_persistence — mean ITPC at stimulus-pause indices.

Functions:

compute_itpc

compute_itpc(phases_trials: object) -> FloatArray

Inter-Trial Phase Coherence at each time point.

ITPC = |mean(exp(i·θ))| across trials (Lachaux et al. 1999).

Parameters

phases_trials : object shape (n_trials, n_timepoints) — phases in radians. A 1-D input is treated as a single trial.

Returns

FloatArray (n_timepoints,) array of ITPC values in [0, 1].

Source code in src/scpn_phase_orchestrator/monitor/itpc.py
def compute_itpc(phases_trials: object) -> FloatArray:
    """Inter-Trial Phase Coherence at each time point.

    ``ITPC = |mean(exp(i·θ))|`` across trials (Lachaux et al. 1999).

    Parameters
    ----------
    phases_trials : object
        shape ``(n_trials, n_timepoints)`` — phases in radians. A 1-D input is treated
        as a single trial.

    Returns
    -------
    FloatArray
        ``(n_timepoints,)`` array of ITPC values in ``[0, 1]``.
    """
    phases = _validate_phases_trials(phases_trials)
    if phases.ndim == 1:
        return np.array([1.0], dtype=np.float64)
    if phases.shape[0] == 0:
        return np.array([], dtype=np.float64)
    n_trials, n_tp = phases.shape
    expected = _compute_itpc_reference(phases)

    backend_fn = _dispatch("itpc")
    if backend_fn is not None:
        try:
            if ACTIVE_BACKEND == "rust":
                fn_rust = cast(
                    "Callable[[FloatArray, int, int], FloatArray]",
                    backend_fn,
                )
                flat = np.ascontiguousarray(phases.ravel(), dtype=np.float64)
                return _validate_itpc_values(
                    fn_rust(flat, n_trials, n_tp),
                    n_timepoints=n_tp,
                    expected=expected,
                )
            fn = cast("Callable[[FloatArray, int, int], FloatArray]", backend_fn)
            return _validate_itpc_values(
                fn(phases.ravel(), int(n_trials), int(n_tp)),
                n_timepoints=n_tp,
                expected=expected,
                atol=1e-9 if ACTIVE_BACKEND == "mojo" else 1e-12,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    return _validate_itpc_values(expected, n_timepoints=n_tp, expected=expected)

itpc_persistence

itpc_persistence(
    phases_trials: object, pause_indices: object
) -> float

Mean ITPC at stimulus-pause indices.

Distinguishes true neural entrainment from evoked response: if ITPC remains high after the driving stimulus stops, oscillators have genuinely phase-locked. If it drops immediately, the response was merely evoked.

Parameters

phases_trials : object (n_trials, n_timepoints) phases in radians. pause_indices : object time-point indices falling within / after a pause.

Returns

float Mean ITPC across pause_indices. 0.0 if empty.

Source code in src/scpn_phase_orchestrator/monitor/itpc.py
def itpc_persistence(
    phases_trials: object,
    pause_indices: object,
) -> float:
    """Mean ITPC at stimulus-pause indices.

    Distinguishes true neural entrainment from evoked response: if ITPC
    remains high after the driving stimulus stops, oscillators have
    genuinely phase-locked. If it drops immediately, the response was
    merely evoked.

    Parameters
    ----------
    phases_trials : object
        ``(n_trials, n_timepoints)`` phases in radians.
    pause_indices : object
        time-point indices falling within / after a pause.

    Returns
    -------
    float
        Mean ITPC across ``pause_indices``. ``0.0`` if empty.
    """
    phases = _validate_phases_trials(phases_trials)
    pause_idx = _validate_pause_indices(pause_indices)
    if pause_idx.size == 0:
        return 0.0

    if phases.ndim == 1:
        phases = phases.reshape(1, -1)
    n_trials, n_tp = phases.shape
    itpc_full = _compute_itpc_reference(phases)
    valid = pause_idx[(pause_idx >= 0) & (pause_idx < itpc_full.size)]
    expected = 0.0 if valid.size == 0 else float(np.mean(itpc_full[valid]))

    backend_fn = _dispatch("persistence")
    if backend_fn is not None:
        try:
            if ACTIVE_BACKEND == "rust":
                fn_rust = cast(
                    "Callable[[FloatArray, int, int, IntArray], float]",
                    backend_fn,
                )
                return _validate_persistence_value(
                    fn_rust(
                        np.ascontiguousarray(phases.ravel(), dtype=np.float64),
                        n_trials,
                        n_tp,
                        np.ascontiguousarray(pause_idx, dtype=np.int64),
                    ),
                    expected=expected,
                )
            fn = cast("Callable[[FloatArray, int, int, IntArray], float]", backend_fn)
            return _validate_persistence_value(
                fn(phases.ravel(), int(n_trials), int(n_tp), pause_idx),
                expected=expected,
                atol=1e-9 if ACTIVE_BACKEND == "mojo" else 1e-12,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    return _validate_persistence_value(expected, expected=expected)

Phase Transfer Entropy

Directed information-theoretic measure of causal influence between oscillators. Transfer entropy TE(i→j) quantifies how much the past of oscillator i reduces uncertainty about the future of oscillator j, beyond what j's own past provides.

Key property: Unlike PLV (symmetric), transfer entropy is directional — TE(i→j) ≠ TE(j→i) in general. This detects causal coupling direction, not just correlation.

Used by the te_adaptive coupling module to adapt K_ij based on measured causal information flow (Lizier 2012).

transfer_entropy

Phase transfer entropy via binned histograms with a 5-backend chain.

Two compute kernels:

  • phase_transfer_entropy — scalar TE(X → Y) on a pair of equal-length phase series.
  • transfer_entropy_matrix(N, N) pairwise TE matrix over N oscillator trajectories.

Estimator: 1-step Markov-order conditional entropy difference

TE(X → Y) = H(Y_{t+1} | Y_t) − H(Y_{t+1} | Y_t, X_t)

with phases wrapped to [0, 2π) and binned into n_bins equal-width intervals. Higher TE indicates stronger directional coupling from source to target.

Functions:

phase_transfer_entropy

phase_transfer_entropy(
    source: FloatArray, target: FloatArray, n_bins: int = 16
) -> float

Transfer entropy TE(X → Y) on binned phase series.

Parameters

source : FloatArray Source phase series, shape (T,). target : FloatArray Target phase series, shape (T,). n_bins : int Number of histogram bins.

Returns

float The transfer entropy TE(X → Y).

Raises

ValueError If the source or target series contain boolean aliases, numeric-string aliases, complex values, non-finite values, or non-vector shapes.

Source code in src/scpn_phase_orchestrator/monitor/transfer_entropy.py
def phase_transfer_entropy(
    source: FloatArray, target: FloatArray, n_bins: int = 16
) -> float:
    """Transfer entropy ``TE(X → Y)`` on binned phase series.

    Parameters
    ----------
    source : FloatArray
        Source phase series, shape ``(T,)``.
    target : FloatArray
        Target phase series, shape ``(T,)``.
    n_bins : int
        Number of histogram bins.

    Returns
    -------
    float
        The transfer entropy ``TE(X → Y)``.

    Raises
    ------
    ValueError
        If the source or target series contain boolean aliases, numeric-string
        aliases, complex values, non-finite values, or non-vector shapes.
    """
    bin_count = _validate_n_bins(n_bins)
    source_values = _validate_phase_vector(source, name="source")
    target_values = _validate_phase_vector(target, name="target")
    n_samples = min(len(source_values), len(target_values))
    if n_samples < 3:
        return 0.0
    source_values = source_values[:n_samples]
    target_values = target_values[:n_samples]
    expected = _phase_te_reference(source_values, target_values, bin_count)
    backend_fn = _dispatch("phase_te")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray, FloatArray, int], float]", backend_fn)
        try:
            result = fn(
                np.ascontiguousarray(source_values, dtype=np.float64),
                np.ascontiguousarray(target_values, dtype=np.float64),
                bin_count,
            )
            result = _validate_te_scalar(
                result,
                name="backend transfer entropy",
                max_entropy=float(np.log(bin_count)),
            )
            if not np.isclose(
                result,
                expected,
                rtol=0.0,
                atol=1e-9 if ACTIVE_BACKEND == "mojo" else 1e-12,
            ):
                raise ValueError(
                    "backend transfer entropy diverged from exact reference"
                )
            return result
        except (ImportError, RuntimeError, OSError, KeyError):
            bin_count = int(bin_count)

    return expected

transfer_entropy_matrix

transfer_entropy_matrix(
    phase_series: FloatArray, n_bins: int = 16
) -> FloatArray

Return the pairwise TE matrix [i, j] = TE(i → j) with zero diagonal.

Parameters

phase_series : FloatArray Phase time series, shape (T, N). n_bins : int Number of histogram bins.

Returns

FloatArray The pairwise TE matrix with zero diagonal.

Raises

ValueError If phase_series contains boolean aliases, numeric-string aliases, complex values, non-finite values, or a non-matrix shape.

Source code in src/scpn_phase_orchestrator/monitor/transfer_entropy.py
def transfer_entropy_matrix(phase_series: FloatArray, n_bins: int = 16) -> FloatArray:
    """Return the pairwise TE matrix ``[i, j] = TE(i → j)`` with zero diagonal.

    Parameters
    ----------
    phase_series : FloatArray
        Phase time series, shape ``(T, N)``.
    n_bins : int
        Number of histogram bins.

    Returns
    -------
    FloatArray
        The pairwise TE matrix with zero diagonal.

    Raises
    ------
    ValueError
        If ``phase_series`` contains boolean aliases, numeric-string aliases,
        complex values, non-finite values, or a non-matrix shape.
    """
    bin_count = _validate_n_bins(n_bins)
    series = _validate_phase_series(phase_series, name="phase_series")
    n_osc, n_time = series.shape
    expected = _te_matrix_reference(series, bin_count)
    backend_fn = _dispatch("te_matrix")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray, int, int, int], FloatArray]", backend_fn)
        try:
            flat = fn(
                np.ascontiguousarray(series.ravel(), dtype=np.float64),
                n_osc,
                n_time,
                bin_count,
            )
            return _validate_te_matrix(
                flat,
                n_osc=n_osc,
                max_entropy=float(np.log(bin_count)),
                expected=expected,
                atol=1e-9 if ACTIVE_BACKEND == "mojo" else 1e-12,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            n_time = int(n_time)

    return expected

Recurrence Quantification Analysis (RQA)

Extracts dynamical invariants from phase trajectories via recurrence plots. RQA is powerful because it works on short, non-stationary time series where spectral methods fail.

Eight measures:

Measure Symbol Meaning
Recurrence rate RR Density of recurrence points
Determinism DET Fraction forming diagonal lines → deterministic dynamics
Average diagonal L Mean diagonal line length → prediction horizon
Max diagonal L_max Inversely related to max Lyapunov exponent
Diagonal entropy ENTR Complexity of deterministic structure
Laminarity LAM Fraction forming vertical lines → laminar states
Trapping time TT Mean time in laminar state
Max vertical V_max Longest laminar episode

Cross-RQA extends this to detect synchronization between two oscillator groups by computing the cross-recurrence matrix.

Usage:

from scpn_phase_orchestrator.monitor.recurrence import rqa, cross_rqa

# Auto-RQA on a single trajectory
result = rqa(trajectory, epsilon=0.3, metric="angular")
print(f"DET={result.determinism:.3f}, LAM={result.laminarity:.3f}")

# Cross-RQA between two oscillator groups
cr = cross_rqa(traj_a, traj_b, epsilon=0.3)
print(f"Cross-DET={cr.determinism:.3f}")

References: Eckmann, Kamphorst & Ruelle 1987; Zbilut & Webber 1992; Marwan et al. 2007, Phys. Reports 438:237-329.

recurrence

Recurrence analysis with a 5-backend fallback chain.

Compute surface:

  • :func:recurrence_matrixR_ij = Θ(ε − ‖x_i − x_j‖).
  • :func:cross_recurrence_matrix — cross-recurrence of two trajectories.
  • :func:rqa — full Recurrence Quantification Analysis using the dispatched matrix; line-length histograms + RQA statistics stay Python-side for uniformity.
  • :func:cross_rqa — cross-RQA; same pattern.

References: Eckmann, Kamphorst & Ruelle 1987, Europhys. Lett. 4:973–977; Zbilut & Webber 1992, Phys. Lett. A 171:199–203; Marwan et al. 2007, Phys. Reports 438:237–329.

Classes

RQAResult dataclass

RQAResult(
    recurrence_rate: float,
    determinism: float,
    avg_diagonal: float,
    max_diagonal: int,
    entropy_diagonal: float,
    laminarity: float,
    trapping_time: float,
    max_vertical: int,
)

Standard RQA measures from Marwan et al. 2007.

Functions:

recurrence_matrix

recurrence_matrix(
    trajectory: FloatArray,
    epsilon: float,
    metric: str = "euclidean",
) -> BoolArray

Binary recurrence matrix R_ij = ‖x_i − x_j‖ ≤ ε.

Parameters

trajectory : FloatArray (T, d) or (T,) state-space trajectory. epsilon : float recurrence threshold. metric : str "euclidean" or "angular" (chord distance on ).

Returns

BoolArray (T, T) boolean array.

Source code in src/scpn_phase_orchestrator/monitor/recurrence.py
def recurrence_matrix(
    trajectory: FloatArray,
    epsilon: float,
    metric: str = "euclidean",
) -> BoolArray:
    """Binary recurrence matrix ``R_ij = ‖x_i − x_j‖ ≤ ε``.

    Parameters
    ----------
    trajectory : FloatArray
        ``(T, d)`` or ``(T,)`` state-space trajectory.
    epsilon : float
        recurrence threshold.
    metric : str
        ``"euclidean"`` or ``"angular"`` (chord distance on ``S¹``).

    Returns
    -------
    BoolArray
        ``(T, T)`` boolean array.
    """
    traj = _validate_trajectory(trajectory, name="trajectory")
    epsilon = _validate_epsilon(epsilon)
    t, d = int(traj.shape[0]), int(traj.shape[1])
    angular = _validate_metric(metric)
    if t == 0:
        return np.zeros((0, 0), dtype=bool)
    flat = traj.ravel()
    expected = _expected_recurrence_matrix(
        traj,
        traj,
        epsilon=epsilon,
        angular=angular,
    )

    backend_fn = _dispatch("rm")
    if backend_fn is not None:
        fn = cast(
            "Callable[[FloatArray, int, int, float, bool], ByteArray]",
            backend_fn,
        )
        try:
            return _backend_recurrence_matrix(
                fn(flat, t, d, epsilon, angular),
                t=t,
                name="recurrence_matrix",
                expected=expected,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            angular = bool(angular)

    return expected

cross_recurrence_matrix

cross_recurrence_matrix(
    traj_a: FloatArray,
    traj_b: FloatArray,
    epsilon: float,
    metric: str = "euclidean",
) -> BoolArray

Cross-recurrence matrix CR_ij = ‖x_i − y_j‖ ≤ ε.

traj_a and traj_b must have the same length and dimensionality.

Parameters

traj_a : FloatArray First trajectory, shape (T, d). traj_b : FloatArray Second trajectory, shape (T, d). epsilon : float Recurrence threshold. metric : str Distance metric name.

Returns

BoolArray The binary cross-recurrence matrix.

Raises

ValueError If the two trajectories are incompatible.

Source code in src/scpn_phase_orchestrator/monitor/recurrence.py
def cross_recurrence_matrix(
    traj_a: FloatArray,
    traj_b: FloatArray,
    epsilon: float,
    metric: str = "euclidean",
) -> BoolArray:
    """Cross-recurrence matrix ``CR_ij = ‖x_i − y_j‖ ≤ ε``.

    ``traj_a`` and ``traj_b`` must have the same length and
    dimensionality.

    Parameters
    ----------
    traj_a : FloatArray
        First trajectory, shape ``(T, d)``.
    traj_b : FloatArray
        Second trajectory, shape ``(T, d)``.
    epsilon : float
        Recurrence threshold.
    metric : str
        Distance metric name.

    Returns
    -------
    BoolArray
        The binary cross-recurrence matrix.

    Raises
    ------
    ValueError
        If the two trajectories are incompatible.
    """
    a = _validate_trajectory(traj_a, name="traj_a")
    b = _validate_trajectory(traj_b, name="traj_b")
    epsilon = _validate_epsilon(epsilon)
    t, d = int(a.shape[0]), int(a.shape[1])
    if b.shape != a.shape:
        raise ValueError(f"trajectories must match: a={a.shape} b={b.shape}")
    angular = _validate_metric(metric)
    if t == 0:
        return np.zeros((0, 0), dtype=bool)
    a_flat = a.ravel()
    b_flat = b.ravel()
    expected = _expected_recurrence_matrix(
        a,
        b,
        epsilon=epsilon,
        angular=angular,
    )

    backend_fn = _dispatch("cross_rm")
    if backend_fn is not None:
        fn = cast(
            "Callable[[FloatArray, FloatArray, int, int, float, bool], ByteArray]",
            backend_fn,
        )
        try:
            return _backend_recurrence_matrix(
                fn(a_flat, b_flat, t, d, epsilon, angular),
                t=t,
                name="cross_recurrence_matrix",
                expected=expected,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            angular = bool(angular)

    return expected

rqa

rqa(
    trajectory: FloatArray,
    epsilon: float,
    l_min: int = 2,
    v_min: int = 2,
    metric: str = "euclidean",
) -> RQAResult

Full Recurrence Quantification Analysis.

The recurrence matrix is computed via the 5-backend dispatcher; line-length histograms and RQA statistics are computed in Python for uniformity across backends.

Parameters

trajectory : FloatArray Phase-space trajectory, shape (T, d). epsilon : float Recurrence threshold. l_min : int Minimum diagonal-line length counted by RQA. v_min : int Minimum vertical-line length counted by RQA. metric : str Distance metric name.

Returns

RQAResult The recurrence quantification analysis result.

Source code in src/scpn_phase_orchestrator/monitor/recurrence.py
def rqa(
    trajectory: FloatArray,
    epsilon: float,
    l_min: int = 2,
    v_min: int = 2,
    metric: str = "euclidean",
) -> RQAResult:
    """Full Recurrence Quantification Analysis.

    The recurrence matrix is computed via the 5-backend dispatcher;
    line-length histograms and RQA statistics are computed in
    Python for uniformity across backends.

    Parameters
    ----------
    trajectory : FloatArray
        Phase-space trajectory, shape ``(T, d)``.
    epsilon : float
        Recurrence threshold.
    l_min : int
        Minimum diagonal-line length counted by RQA.
    v_min : int
        Minimum vertical-line length counted by RQA.
    metric : str
        Distance metric name.

    Returns
    -------
    RQAResult
        The recurrence quantification analysis result.
    """
    R = recurrence_matrix(trajectory, epsilon, metric)
    return _rqa_from_matrix(R, l_min, v_min, exclude_main_diag=True)

cross_rqa

cross_rqa(
    traj_a: FloatArray,
    traj_b: FloatArray,
    epsilon: float,
    l_min: int = 2,
    metric: str = "euclidean",
) -> RQAResult

Cross-Recurrence Quantification Analysis between two trajectories.

Parameters

traj_a : FloatArray First trajectory, shape (T, d). traj_b : FloatArray Second trajectory, shape (T, d). epsilon : float Recurrence threshold. l_min : int Minimum diagonal-line length counted by RQA. metric : str Distance metric name.

Returns

RQAResult The cross-recurrence quantification analysis result.

Source code in src/scpn_phase_orchestrator/monitor/recurrence.py
def cross_rqa(
    traj_a: FloatArray,
    traj_b: FloatArray,
    epsilon: float,
    l_min: int = 2,
    metric: str = "euclidean",
) -> RQAResult:
    """Cross-Recurrence Quantification Analysis between two trajectories.

    Parameters
    ----------
    traj_a : FloatArray
        First trajectory, shape ``(T, d)``.
    traj_b : FloatArray
        Second trajectory, shape ``(T, d)``.
    epsilon : float
        Recurrence threshold.
    l_min : int
        Minimum diagonal-line length counted by RQA.
    metric : str
        Distance metric name.

    Returns
    -------
    RQAResult
        The cross-recurrence quantification analysis result.
    """
    CR = cross_recurrence_matrix(traj_a, traj_b, epsilon, metric)
    return _rqa_from_matrix(CR, l_min, l_min, exclude_main_diag=False)

Delay Embedding (Attractor Reconstruction)

Reconstructs the full state-space attractor from a scalar observable using Takens' embedding theorem. This is the prerequisite for computing correlation dimension, Lyapunov exponents from scalar data, and recurrence analysis on scalar measurements.

Three-step procedure:

  1. Optimal delay τ via first minimum of average mutual information (Fraser & Swinney 1986)
  2. Optimal dimension m via False Nearest Neighbors (Kennel, Brown & Abarbanel 1992)
  3. Embedding constructs vectors v(t) = [x(t), x(t-τ), ..., x(t-(m-1)τ)]

Inputs and backend outputs are validated as finite real-valued arrays. Boolean aliases and complex samples are rejected before the Rust/Mojo/Julia/Go backend chain because Takens delay coordinates, Fraser-Swinney mutual information, and false-nearest-neighbour distances are defined over real scalar observations. The Mojo subprocess adapter also validates raw stdout cardinality for delay-coordinate rows, mutual-information scalars, and nearest-neighbour distance/index pairs before numeric parsing, so blank-line insertion or missing rows cannot be normalised into a plausible embedding payload.

Usage:

from scpn_phase_orchestrator.monitor.embedding import auto_embed

# Automatic: determines τ and m, then embeds
result = auto_embed(signal)
print(f"τ={result.delay}, m={result.dimension}")
trajectory = result.trajectory  # (T', m) array

# Manual control
from scpn_phase_orchestrator.monitor.embedding import (
    optimal_delay, optimal_dimension, delay_embed,
)
tau = optimal_delay(signal, max_lag=100)
m = optimal_dimension(signal, delay=tau, max_dim=10)
embedded = delay_embed(signal, delay=tau, dimension=m)

References: Takens 1981, Lecture Notes in Mathematics 898:366-381.

embedding

Delay-embedding analysis with a 5-backend fallback chain.

Three compute primitives on the multi-language chain:

  • :func:delay_embed — time-delay embedding matrix.
  • :func:mutual_information — Fraser-Swinney 1986 average mutual information.
  • :func:nearest_neighbor_distances — brute-force k=1 kNN in the embedded space (consumed by FNN).

Two wrappers stay Python-side (they are control flow over the primitives):

  • :func:optimal_delay — first local minimum of MI (Fraser-Swinney).
  • :func:optimal_dimension — Kennel-Brown-Abarbanel 1992 FNN.
  • :func:auto_embed — convenience that chains optimal_delay, optimal_dimension, and :func:delay_embed.

The Rust backend exposes native optimal_delay_rust and optimal_dimension_rust entry points; when Rust is active those wrappers use the native path for maximum throughput. The Python fallback composes the primitives through the dispatcher.

MI and NN are exposed by Julia / Go / Mojo / Python only — Rust does not expose standalone MI or kNN FFI; those slots dispatch to the next available backend in the chain.

Classes

EmbeddingResult dataclass

EmbeddingResult(
    trajectory: FloatArray,
    delay: int,
    dimension: int,
    T_effective: int,
)

Delay-embedding output.

Methods:
__post_init__
__post_init__() -> None

Validate and normalise the embedded trajectory record.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def __post_init__(self) -> None:
    """Validate and normalise the embedded trajectory record."""
    trajectory = _validate_embedded(self.trajectory)
    delay = _validate_int_at_least(self.delay, name="delay", minimum=1)
    dimension = _validate_int_at_least(self.dimension, name="dimension", minimum=1)
    t_effective = _validate_int_at_least(
        self.T_effective,
        name="T_effective",
        minimum=0,
    )
    if trajectory.shape != (t_effective, dimension):
        raise ValueError(
            f"trajectory shape {trajectory.shape} does not match "
            f"(T_effective={t_effective}, dimension={dimension})"
        )
    self.trajectory = trajectory
    self.delay = delay
    self.dimension = dimension
    self.T_effective = t_effective

Functions:

delay_embed

delay_embed(
    signal: object, delay: object, dimension: object
) -> FloatArray

Time-delay embedding: v(t) = [x(t), x(t+τ), x(t+2τ), …].

Parameters

signal : object Real-valued time series, shape (T,). delay : object Embedding delay τ in samples. dimension : object Embedding dimension.

Returns

FloatArray The time-delay embedding, shape (M, dimension).

Raises

ValueError If delay or dimension is non-positive or too large for the signal.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def delay_embed(
    signal: object,
    delay: object,
    dimension: object,
) -> FloatArray:
    """Time-delay embedding: ``v(t) = [x(t), x(t+τ), x(t+2τ), …]``.

    Parameters
    ----------
    signal : object
        Real-valued time series, shape ``(T,)``.
    delay : object
        Embedding delay ``τ`` in samples.
    dimension : object
        Embedding dimension.

    Returns
    -------
    FloatArray
        The time-delay embedding, shape ``(M, dimension)``.

    Raises
    ------
    ValueError
        If ``delay`` or ``dimension`` is non-positive or too large for the signal.
    """
    s = _validate_signal(signal)
    delay = _validate_int_at_least(delay, name="delay", minimum=1)
    dimension = _validate_int_at_least(dimension, name="dimension", minimum=1)
    t_eff = int(s.size) - (dimension - 1) * delay
    if t_eff <= 0:
        msg = (
            f"Signal too short (T={s.size}) for delay={delay}, "
            f"dimension={dimension}: need T > {(dimension - 1) * delay}"
        )
        raise ValueError(msg)

    backend_fn = _dispatch("de")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray, int, int], FloatArray]", backend_fn)
        try:
            return _validate_delay_embedding_output(
                fn(s, delay, dimension),
                signal=s,
                delay=delay,
                t_effective=t_eff,
                dimension=dimension,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    indices = np.arange(dimension) * delay
    rows = np.arange(t_eff)[:, np.newaxis] + indices[np.newaxis, :]
    trajectory: FloatArray = np.asarray(s[rows], dtype=np.float64)
    return _validate_delay_embedding_output(
        trajectory,
        signal=s,
        delay=delay,
        t_effective=t_eff,
        dimension=dimension,
    )

mutual_information

mutual_information(
    signal: object, lag: object, n_bins: object = 32
) -> float

Fraser-Swinney 1986 average mutual information at lag.

Parameters

signal : object Real-valued time series, shape (T,). lag : object Lag in samples. n_bins : object Number of histogram bins.

Returns

float The average mutual information at the given lag.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def mutual_information(
    signal: object,
    lag: object,
    n_bins: object = 32,
) -> float:
    """Fraser-Swinney 1986 average mutual information at ``lag``.

    Parameters
    ----------
    signal : object
        Real-valued time series, shape ``(T,)``.
    lag : object
        Lag in samples.
    n_bins : object
        Number of histogram bins.

    Returns
    -------
    float
        The average mutual information at the given lag.
    """
    s = _validate_signal(signal)
    lag = _validate_int_at_least(lag, name="lag", minimum=0)
    n_bins = _validate_int_at_least(n_bins, name="n_bins", minimum=2)
    if s.size - lag <= 0:
        return 0.0

    backend_fn = _dispatch("mi")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray, int, int], float]", backend_fn)
        try:
            return _validate_non_negative_scalar(
                fn(s, lag, n_bins),
                name="mutual_information",
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    t_total = s.size - lag
    x = s[:t_total]
    y = s[lag : lag + t_total]
    hist_xy, _, _ = np.histogram2d(x, y, bins=n_bins)
    hist_x = hist_xy.sum(axis=1)
    hist_y = hist_xy.sum(axis=0)
    total = hist_xy.sum()
    if total <= 0:
        return 0.0
    p_xy = hist_xy / total
    p_x = hist_x / total
    p_y = hist_y / total
    mi = 0.0
    for i in range(n_bins):
        for j in range(n_bins):
            if p_xy[i, j] > 0 and p_x[i] > 0 and p_y[j] > 0:
                mi += p_xy[i, j] * np.log(p_xy[i, j] / (p_x[i] * p_y[j]))
    return _validate_non_negative_scalar(mi, name="mutual_information")

nearest_neighbor_distances

nearest_neighbor_distances(
    embedded: object,
) -> tuple[FloatArray, IntArray]

Brute-force k = 1 kNN on the rows of embedded.

Parameters

embedded : object Delay-embedded trajectory, shape (M, dimension).

Returns

tuple[FloatArray, IntArray] The nearest-neighbour distances and their indices.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def nearest_neighbor_distances(
    embedded: object,
) -> tuple[FloatArray, IntArray]:
    """Brute-force ``k = 1`` kNN on the rows of ``embedded``.

    Parameters
    ----------
    embedded : object
        Delay-embedded trajectory, shape ``(M, dimension)``.

    Returns
    -------
    tuple[FloatArray, IntArray]
        The nearest-neighbour distances and their indices.
    """
    e = _validate_embedded(embedded)
    t, m = int(e.shape[0]), int(e.shape[1])
    if t == 0:
        return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.int64)

    backend_fn = _dispatch("nn")
    if backend_fn is not None:
        fn = cast(
            "Callable[[FloatArray, int, int], tuple[FloatArray, IntArray]]",
            backend_fn,
        )
        try:
            dist, idx = fn(np.ascontiguousarray(e.ravel(), dtype=np.float64), t, m)
            return _validate_nn_output(dist, idx, n_points=t)
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    nn_dist = np.full(t, np.inf)
    nn_idx = np.zeros(t, dtype=np.int64)
    for i in range(t):
        diffs = e - e[i]
        dists = np.sqrt(np.sum(diffs**2, axis=1))
        dists[i] = np.inf
        j = int(np.argmin(dists))
        nn_dist[i] = dists[j]
        nn_idx[i] = j
    return _validate_nn_output(nn_dist, nn_idx, n_points=t)

optimal_delay

optimal_delay(
    signal: object,
    max_lag: object = 100,
    n_bins: object = 32,
) -> int

First local minimum of :func:mutual_information vs lag.

Parameters

signal : object Real-valued time series, shape (T,). max_lag : object Largest lag to search. n_bins : object Number of histogram bins.

Returns

int The first mutual-information minimum, as a lag in samples.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def optimal_delay(
    signal: object,
    max_lag: object = 100,
    n_bins: object = 32,
) -> int:
    """First local minimum of :func:`mutual_information` vs ``lag``.

    Parameters
    ----------
    signal : object
        Real-valued time series, shape ``(T,)``.
    max_lag : object
        Largest lag to search.
    n_bins : object
        Number of histogram bins.

    Returns
    -------
    int
        The first mutual-information minimum, as a lag in samples.
    """
    s = _validate_signal(signal)
    max_lag = _validate_int_at_least(max_lag, name="max_lag", minimum=1)
    n_bins = _validate_int_at_least(n_bins, name="n_bins", minimum=2)

    if ACTIVE_BACKEND == "rust":
        try:
            fn = cast(
                "Callable[[FloatArray, int, int], int]",
                _load_backend("rust")["optimal_delay"],
            )
            return int(fn(s, max_lag, n_bins))
        except (ImportError, RuntimeError, OSError, KeyError):
            max_lag = int(max_lag)

    max_lag = min(max_lag, s.size // 2)
    mi_values = np.array([mutual_information(s, lag, n_bins) for lag in range(max_lag)])
    for i in range(1, len(mi_values) - 1):
        if mi_values[i] < mi_values[i - 1] and mi_values[i] < mi_values[i + 1]:
            return i
    return 1

optimal_dimension

optimal_dimension(
    signal: object,
    delay: object,
    max_dim: object = 10,
    rtol: object = 15.0,
    atol: object = 2.0,
) -> int

Kennel-Brown-Abarbanel 1992 FNN to select embedding dimension.

Parameters

signal : object Real-valued time series, shape (T,). delay : object Embedding delay τ in samples. max_dim : object Largest embedding dimension to test. rtol : object Relative tolerance for the false-nearest-neighbour test. atol : object Absolute tolerance for the false-nearest-neighbour test.

Returns

int The selected embedding dimension.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def optimal_dimension(
    signal: object,
    delay: object,
    max_dim: object = 10,
    rtol: object = 15.0,
    atol: object = 2.0,
) -> int:
    """Kennel-Brown-Abarbanel 1992 FNN to select embedding dimension.

    Parameters
    ----------
    signal : object
        Real-valued time series, shape ``(T,)``.
    delay : object
        Embedding delay ``τ`` in samples.
    max_dim : object
        Largest embedding dimension to test.
    rtol : object
        Relative tolerance for the false-nearest-neighbour test.
    atol : object
        Absolute tolerance for the false-nearest-neighbour test.

    Returns
    -------
    int
        The selected embedding dimension.
    """
    s = _validate_signal(signal)
    delay = _validate_int_at_least(delay, name="delay", minimum=1)
    max_dim = _validate_int_at_least(max_dim, name="max_dim", minimum=1)
    rtol = _validate_non_negative_real(rtol, name="rtol")
    atol = _validate_non_negative_real(atol, name="atol")
    sigma = float(np.std(s))
    if sigma == 0:
        return 1

    if ACTIVE_BACKEND == "rust":
        try:
            fn = cast(
                "Callable[..., int]",
                _load_backend("rust")["optimal_dimension"],
            )
            return int(
                fn(s, delay, max_dim, rtol, atol),
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            max_dim = int(max_dim)

    for m in range(1, max_dim + 1):
        t_next = s.size - m * delay
        if t_next <= 1:
            return m
        emb_m = delay_embed(s, delay, m)
        t_m = emb_m.shape[0]
        nn_dist, nn_idx = nearest_neighbor_distances(emb_m)

        n_false = 0
        n_valid = 0
        for i in range(t_m):
            j = int(nn_idx[i])
            d = nn_dist[i]
            if d == 0 or not np.isfinite(d):
                continue
            i_next = i + m * delay
            j_next = j + m * delay
            if i_next >= s.size or j_next >= s.size:
                continue
            n_valid += 1
            extra = abs(s[i_next] - s[j_next])
            if extra / d > rtol:
                n_false += 1
                continue
            new_dist = (d * d + extra * extra) ** 0.5
            if new_dist / sigma > atol:
                n_false += 1
        fnn_frac = n_false / n_valid if n_valid > 0 else 0.0
        if fnn_frac < 0.01:
            return m
    return max_dim

auto_embed

auto_embed(
    signal: object,
    max_lag: object = 100,
    max_dim: object = 10,
) -> EmbeddingResult

optimal_delayoptimal_dimensiondelay_embed.

Parameters

signal : object Real-valued time series, shape (T,). max_lag : object Largest lag to search. max_dim : object Largest embedding dimension to test.

Returns

EmbeddingResult The auto-selected delay/dimension embedding result.

Source code in src/scpn_phase_orchestrator/monitor/embedding.py
def auto_embed(
    signal: object,
    max_lag: object = 100,
    max_dim: object = 10,
) -> EmbeddingResult:
    """``optimal_delay`` ∘ ``optimal_dimension`` ∘ ``delay_embed``.

    Parameters
    ----------
    signal : object
        Real-valued time series, shape ``(T,)``.
    max_lag : object
        Largest lag to search.
    max_dim : object
        Largest embedding dimension to test.

    Returns
    -------
    EmbeddingResult
        The auto-selected delay/dimension embedding result.
    """
    tau = optimal_delay(signal, max_lag)
    m = optimal_dimension(signal, tau, max_dim)
    traj = delay_embed(signal, tau, m)
    return EmbeddingResult(
        trajectory=traj,
        delay=tau,
        dimension=m,
        T_effective=int(traj.shape[0]),
    )

Psychedelic State Metrics

The psychedelic monitor is a research diagnostic for phase-dispersion simulation inspired by entropic-brain hypotheses. Public Python calls and Go/Julia/Mojo entropy adapters reject boolean aliases, numeric-string aliases, complex phases, object arrays carrying Python or NumPy complex scalar aliases, non-finite phases, invalid bin counts, numeric-string entropy payloads, complex entropy payloads, and invalid coupling-reduction backend matrices before results are accepted. This preserves the circular Shannon entropy and Kuramoto coupling semantics over real-valued phase observations; it is not a clinical, dosage, or actuation interface.

Direct accelerator boundary contract: Go, Julia, and Mojo entropy adapters use one shared float64 validation path before loading shared-library, Julia, or subprocess runtimes. Empty phase samples return zero entropy without requiring optional runtimes, matching the public Python fallback and preserving the Shannon special case for an empty empirical distribution. Direct backend entropy outputs are also revalidated as finite real scalars in the physical interval [0, log(n_bins)] and must not arrive as numeric strings; malformed Mojo raw stdout line counts, blank-line insertion, and non-scalar tokens are rejected before the value reaches downstream monitor logic.

psychedelic

Psychedelic phase-dispersion simulation utilities for research diagnostics.

The helpers model coupling reduction, phase entropy, and trajectory evolution through an optional backend chain while keeping a deterministic Python fallback. Inputs are constrained to finite phase vectors, finite square coupling matrices, and unit-interval coupling factors before simulation begins. The module is a research simulation surface only; it does not provide clinical guidance, actuation, dosage advice, or patient-state decisions.

Classes

Functions:

reduce_coupling

reduce_coupling(
    knm: FloatArray, reduction_factor: float
) -> FloatArray

Scale coupling matrix by (1 − reduction_factor).

Parameters

knm : FloatArray (n, n) coupling matrix. reduction_factor : float fraction to reduce, in [0, 1].

Returns

FloatArray Scaled copy. Zero when reduction_factor == 1.

Source code in src/scpn_phase_orchestrator/monitor/psychedelic.py
def reduce_coupling(knm: FloatArray, reduction_factor: float) -> FloatArray:
    """Scale coupling matrix by ``(1 − reduction_factor)``.

    Parameters
    ----------
    knm : FloatArray
        ``(n, n)`` coupling matrix.
    reduction_factor : float
        fraction to reduce, in ``[0, 1]``.

    Returns
    -------
    FloatArray
        Scaled copy. Zero when ``reduction_factor == 1``.
    """
    k = _validate_coupling_matrix(knm, name="knm")
    factor = _validate_unit_interval(reduction_factor, name="reduction_factor")
    if _HAS_RUST_REDUCE:
        flat = np.ascontiguousarray(k.ravel())
        return _validate_reduced_coupling(
            _rust_reduce(flat, factor), expected_shape=k.shape
        )
    return _validate_reduced_coupling(k * (1.0 - factor), expected_shape=k.shape)

entropy_from_phases

entropy_from_phases(
    phases: FloatArray, n_bins: int = 36
) -> float

Circular Shannon entropy of a phase distribution.

Wraps phases to [0, 2π), bins into n_bins equal-width intervals (default 36 = 10° resolution), returns entropy in nats.

Carhart-Harris et al. 2014, Front. Hum. Neurosci. 8:20 ("The entropic brain").

Parameters

phases : FloatArray Oscillator phases in radians, shape (N,). n_bins : int Number of histogram bins.

Returns

float The circular Shannon entropy of the phase distribution.

Source code in src/scpn_phase_orchestrator/monitor/psychedelic.py
def entropy_from_phases(phases: FloatArray, n_bins: int = 36) -> float:
    """Circular Shannon entropy of a phase distribution.

    Wraps phases to ``[0, 2π)``, bins into ``n_bins`` equal-width
    intervals (default 36 = 10° resolution), returns entropy in
    nats.

    Carhart-Harris et al. 2014, Front. Hum. Neurosci. **8**:20
    ("The entropic brain").

    Parameters
    ----------
    phases : FloatArray
        Oscillator phases in radians, shape ``(N,)``.
    n_bins : int
        Number of histogram bins.

    Returns
    -------
    float
        The circular Shannon entropy of the phase distribution.
    """
    bin_count = _validate_n_bins(n_bins)
    phase_values = _validate_phase_vector(phases, name="phases")
    if phase_values.size == 0:
        return 0.0
    backend_fn = _dispatch()
    if backend_fn is not None:
        try:
            return _validate_entropy_value(
                backend_fn(phase_values, bin_count),
                n_bins=bin_count,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    wrapped = phase_values % (2.0 * np.pi)
    counts, _ = np.histogram(
        wrapped,
        bins=bin_count,
        range=(0, 2.0 * np.pi),
    )
    total = counts.sum()
    if total == 0:
        return 0.0
    probs = counts / total
    probs = probs[probs > 0]
    return _validate_entropy_value(-np.sum(probs * np.log(probs)), n_bins=bin_count)

simulate_psychedelic_trajectory

simulate_psychedelic_trajectory(
    engine: UPDEEngine,
    phases: FloatArray,
    omegas: FloatArray,
    knm: FloatArray,
    alpha: FloatArray,
    reduction_schedule: list[float],
    n_steps_per_level: int = 100,
) -> list[dict[str, Any]]

Progressively reduce coupling, recording observables at each level.

Models the entropic brain hypothesis: reduced serotonergic gating (coupling reduction) increases neural entropy and breaks coherent states into chimera-like patterns.

Parameters

engine : UPDEEngine UPDE integrator instance. phases : FloatArray initial oscillator phases, shape (n,). omegas : FloatArray natural frequencies, shape (n,). knm : FloatArray baseline coupling matrix, shape (n, n). alpha : FloatArray phase-lag matrix, shape (n, n). reduction_schedule : list[float] list of reduction_factor values (0 to 1). n_steps_per_level : int integration steps at each coupling level.

Returns

list[dict[str, Any]] List of dicts, one per level, with keys: reduction_factor, R, entropy, chimera_index, phases.

Raises

ValueError If the reduction schedule or state arrays are invalid.

Source code in src/scpn_phase_orchestrator/monitor/psychedelic.py
def simulate_psychedelic_trajectory(
    engine: UPDEEngine,
    phases: FloatArray,
    omegas: FloatArray,
    knm: FloatArray,
    alpha: FloatArray,
    reduction_schedule: list[float],
    n_steps_per_level: int = 100,
) -> list[dict[str, Any]]:
    """Progressively reduce coupling, recording observables at each level.

    Models the entropic brain hypothesis: reduced serotonergic gating
    (coupling reduction) increases neural entropy and breaks coherent
    states into chimera-like patterns.

    Parameters
    ----------
    engine : UPDEEngine
        UPDE integrator instance.
    phases : FloatArray
        initial oscillator phases, shape (n,).
    omegas : FloatArray
        natural frequencies, shape (n,).
    knm : FloatArray
        baseline coupling matrix, shape (n, n).
    alpha : FloatArray
        phase-lag matrix, shape (n, n).
    reduction_schedule : list[float]
        list of reduction_factor values (0 to 1).
    n_steps_per_level : int
        integration steps at each coupling level.

    Returns
    -------
    list[dict[str, Any]]
        List of dicts, one per level, with keys: reduction_factor, R, entropy,
        chimera_index, phases.

    Raises
    ------
    ValueError
        If the reduction schedule or state arrays are invalid.
    """
    p = _validate_phase_vector(phases, name="phases").copy()
    n = int(p.size)
    if n == 0:
        raise ValueError("phases must contain at least one oscillator")
    omega_values = _validate_phase_vector(omegas, name="omegas")
    if omega_values.shape != (n,):
        raise ValueError(f"omegas must have shape ({n},), got {omega_values.shape}")
    k_base = _validate_coupling_matrix(knm, name="knm", expected_n=n)
    alpha_values = _validate_coupling_matrix(alpha, name="alpha", expected_n=n)
    schedule = _validate_reduction_schedule(reduction_schedule)
    step_count = _validate_step_count(n_steps_per_level, name="n_steps_per_level")
    results: list[dict[str, Any]] = []

    for rf in schedule:
        k_reduced = reduce_coupling(k_base, rf)
        p = engine.run(
            p,
            omega_values,
            k_reduced,
            zeta=0.0,
            psi=0.0,
            alpha=alpha_values,
            n_steps=step_count,
        )
        r_val, _ = engine.compute_order_parameter(p)
        ent = entropy_from_phases(p)
        chimera = detect_chimera(p, k_reduced)

        results.append(
            {
                "reduction_factor": rf,
                "R": r_val,
                "entropy": ent,
                "chimera_index": chimera.chimera_index,
                "phases": p.copy(),
            }
        )

    return results

Fractal Dimension

Estimates the fractal dimension of attractors from embedded trajectories. Two complementary measures:

Correlation dimension D₂ (Grassberger & Procaccia 1983): Counts the fraction of point pairs within distance ε, then extracts the power-law exponent C(ε) ~ ε^D₂. The scaling region is automatically identified as the range with most stable local slopes.

Kaplan-Yorke dimension D_KY (Kaplan & Yorke 1979): Computed from the Lyapunov spectrum as D_KY = j + (Σᵢ₌₁ʲ λᵢ)/|λⱼ₊₁| where j is the largest index with non-negative cumulative sum. The Kaplan-Yorke conjecture equates D_KY to the information dimension.

Usage:

from scpn_phase_orchestrator.monitor.dimension import (
    correlation_dimension, kaplan_yorke_dimension,
)

# From embedded trajectory
result = correlation_dimension(trajectory, n_epsilons=30)
print(f"D2={result.D2:.2f}, scaling={result.scaling_range}")

# From Lyapunov spectrum
from scpn_phase_orchestrator.monitor.lyapunov import lyapunov_spectrum
spec = lyapunov_spectrum(phases, omegas, knm, alpha)
D_KY = kaplan_yorke_dimension(spec)
print(f"D_KY={D_KY:.2f}")

References: Grassberger & Procaccia 1983, Phys. Rev. Lett. 50:346-349; Kaplan & Yorke 1979, Lecture Notes in Mathematics 730:228-237.

dimension

Fractal dimension estimation with a 5-backend fallback chain.

Implements:

  • :func:correlation_integral — Grassberger-Procaccia 1983 C(ε).
  • :func:correlation_dimensionD2 via log-log slope on C(ε).
  • :func:kaplan_yorke_dimensionD_KY from a Lyapunov spectrum (Kaplan & Yorke 1979).

For parity across backends the RNG that picks subsampled pairs is owned by the Python dispatcher and its seeded indices are passed to every non-Rust backend. The Rust path keeps its own internal RNG for backward compatibility; full-pairs mode is bit-exact across all five.

Classes

CorrelationDimensionResult dataclass

CorrelationDimensionResult(
    D2: float,
    epsilons: FloatArray,
    C_eps: FloatArray,
    slope: FloatArray,
    scaling_range: tuple[float, float],
)

Result of correlation dimension estimation.

Attributes
D2: Estimated correlation dimension.
epsilons: (K,) array of distance thresholds used.
C_eps: (K,) correlation integral values C(ε).
slope: (K-1,) local log-log slopes.
scaling_range: (ε_lo, ε_hi) range where power law holds.

Functions:

correlation_integral

correlation_integral(
    trajectory: object,
    epsilons: object,
    max_pairs: object = 50000,
    seed: object = 42,
) -> FloatArray

Correlation integral C(ε) = fraction of pairs within ε.

Grassberger-Procaccia 1983: C(ε) ∝ ε^{D₂} in the scaling region.

Dispatches to the active backend. For T · (T−1)/2 ≤ max_pairs all pairs are evaluated and every backend returns bit-exact agreement; when subsampling is needed the Python dispatcher owns the RNG and passes deterministic indices to every non-Rust backend, while the Rust path keeps its in-kernel RNG for API stability.

Parameters

trajectory : object (T, d) embedded trajectory. epsilons : object (K,) distance thresholds. max_pairs : object maximum number of pairs to evaluate. seed : object RNG seed for pair subsampling.

Returns

FloatArray (K,) array of C(ε) values.

Source code in src/scpn_phase_orchestrator/monitor/dimension.py
def correlation_integral(
    trajectory: object,
    epsilons: object,
    max_pairs: object = 50000,
    seed: object = 42,
) -> FloatArray:
    """Correlation integral ``C(ε) = fraction of pairs within ε``.

    Grassberger-Procaccia 1983: ``C(ε) ∝ ε^{D₂}`` in the scaling
    region.

    Dispatches to the active backend. For ``T · (T−1)/2 ≤ max_pairs``
    all pairs are evaluated and every backend returns bit-exact
    agreement; when subsampling is needed the Python dispatcher owns
    the RNG and passes deterministic indices to every non-Rust
    backend, while the Rust path keeps its in-kernel RNG for API
    stability.

    Parameters
    ----------
    trajectory : object
        ``(T, d)`` embedded trajectory.
    epsilons : object
        ``(K,)`` distance thresholds.
    max_pairs : object
        maximum number of pairs to evaluate.
    seed : object
        RNG seed for pair subsampling.

    Returns
    -------
    FloatArray
        ``(K,)`` array of ``C(ε)`` values.
    """
    traj = _validate_trajectory(trajectory)
    t, d = int(traj.shape[0]), int(traj.shape[1])
    eps_sorted = _validate_epsilons(epsilons)
    max_pairs = _validate_int_at_least(max_pairs, name="max_pairs", minimum=1)
    seed = _validate_int_at_least(seed, name="seed", minimum=0)
    pair_result = _prepare_pair_indices(t, max_pairs, seed)
    if pair_result is None:
        return np.zeros(eps_sorted.size, dtype=np.float64)
    idx_i, idx_j = pair_result
    expected = _correlation_integral_exact_reference(traj, idx_i, idx_j, eps_sorted)
    full_pairs = idx_i.size == t * (t - 1) // 2

    backend_fn = _dispatch("ci")
    if backend_fn is not None and ACTIVE_BACKEND == "rust":
        fn_rust = cast(
            "Callable[[FloatArray, int, int, FloatArray, int, int], FloatArray]",
            backend_fn,
        )
        try:
            rust_output = fn_rust(
                np.ascontiguousarray(traj.ravel(), dtype=np.float64),
                t,
                d,
                eps_sorted,
                max_pairs,
                seed,
            )
            if full_pairs:
                return _validate_ci_exact_reference(
                    rust_output,
                    expected=expected,
                    atol=1e-12,
                )
            return _validate_ci_values(
                rust_output,
                expected_size=int(eps_sorted.size),
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    if backend_fn is not None:
        fn = cast(
            "Callable[[FloatArray, int, int, IntArray, IntArray, FloatArray], "
            "FloatArray]",
            backend_fn,
        )
        try:
            return _validate_ci_exact_reference(
                fn(
                    np.ascontiguousarray(traj.ravel(), dtype=np.float64),
                    t,
                    d,
                    idx_i,
                    idx_j,
                    eps_sorted,
                ),
                expected=expected,
                atol=1e-9 if ACTIVE_BACKEND == "mojo" else 1e-12,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    return expected

correlation_dimension

correlation_dimension(
    trajectory: object,
    n_epsilons: object = 30,
    max_pairs: object = 50000,
    seed: object = 42,
) -> CorrelationDimensionResult

Estimate D₂ via a log-log plateau over C(ε).

Parameters

trajectory : object Phase-space trajectory, shape (T, d). n_epsilons : object Number of radii sampled across the log-log range. max_pairs : object Maximum number of point pairs sampled, or None for all. seed : object Seed for the deterministic RNG.

Returns

CorrelationDimensionResult The estimated correlation dimension D₂ result.

Source code in src/scpn_phase_orchestrator/monitor/dimension.py
def correlation_dimension(
    trajectory: object,
    n_epsilons: object = 30,
    max_pairs: object = 50000,
    seed: object = 42,
) -> CorrelationDimensionResult:
    """Estimate ``D₂`` via a log-log plateau over ``C(ε)``.

    Parameters
    ----------
    trajectory : object
        Phase-space trajectory, shape ``(T, d)``.
    n_epsilons : object
        Number of radii sampled across the log-log range.
    max_pairs : object
        Maximum number of point pairs sampled, or ``None`` for all.
    seed : object
        Seed for the deterministic RNG.

    Returns
    -------
    CorrelationDimensionResult
        The estimated correlation dimension ``D₂`` result.
    """
    traj = _validate_trajectory(trajectory)
    n_epsilons = _validate_int_at_least(
        n_epsilons,
        name="n_epsilons",
        minimum=2,
    )
    max_pairs = _validate_int_at_least(max_pairs, name="max_pairs", minimum=1)
    seed = _validate_int_at_least(seed, name="seed", minimum=0)
    diam = _attractor_diameter(traj)
    if diam == 0:
        return CorrelationDimensionResult(
            D2=0.0,
            epsilons=np.array([1.0]),
            C_eps=np.array([1.0]),
            slope=np.array([0.0]),
            scaling_range=(1.0, 1.0),
        )
    upper_epsilon = diam * (1.0 - 8.0 * np.finfo(np.float64).eps)
    epsilons = np.logspace(
        np.log10(diam * 0.01),
        np.log10(upper_epsilon),
        n_epsilons,
    )
    C_eps = correlation_integral(traj, epsilons, max_pairs, seed)

    valid = C_eps > 0
    if valid.sum() < 3:
        return CorrelationDimensionResult(
            D2=0.0,
            epsilons=epsilons,
            C_eps=C_eps,
            slope=np.zeros(len(epsilons) - 1),
            scaling_range=(float(epsilons[0]), float(epsilons[-1])),
        )

    valid_indices = np.flatnonzero(valid)
    first_valid = int(valid_indices[0])
    log_eps = np.log(epsilons[valid])
    log_C = np.log(C_eps[valid])
    slopes = np.diff(log_C) / np.diff(log_eps)
    full_slopes: FloatArray = np.zeros(len(epsilons) - 1, dtype=np.float64)
    full_slopes[first_valid : first_valid + len(slopes)] = slopes

    window = min(5, len(slopes))
    best_var = float(np.inf)
    best_start = 0
    for i in range(len(slopes) - window + 1):
        v = float(np.var(slopes[i : i + window]))
        if v < best_var:
            best_var = v
            best_start = i

    D2 = max(0.0, float(np.mean(slopes[best_start : best_start + window])))
    eps_valid = epsilons[valid]
    scaling_lo = float(eps_valid[best_start])
    scaling_hi = float(eps_valid[min(best_start + window, len(eps_valid) - 1)])

    return CorrelationDimensionResult(
        D2=D2,
        epsilons=epsilons,
        C_eps=C_eps,
        slope=full_slopes,
        scaling_range=(scaling_lo, scaling_hi),
    )

kaplan_yorke_dimension

kaplan_yorke_dimension(
    lyapunov_exponents: FloatArray,
) -> float

Kaplan-Yorke / information dimension from a Lyapunov spectrum.

D_KY = j + (Σ_{i=1}^{j} λ_i) / |λ_{j+1}| where j is the largest index such that the cumulative sum of the first j exponents is non-negative.

Kaplan & Yorke 1979. The Kaplan-Yorke conjecture equates this to the information dimension D₁.

Parameters

lyapunov_exponents : FloatArray (N,) Lyapunov exponents.

Returns

float D_KY. Returns 0.0 if the largest exponent is negative (stable fixed point, zero-dimensional attractor).

Source code in src/scpn_phase_orchestrator/monitor/dimension.py
def kaplan_yorke_dimension(lyapunov_exponents: FloatArray) -> float:
    """Kaplan-Yorke / information dimension from a Lyapunov spectrum.

    ``D_KY = j + (Σ_{i=1}^{j} λ_i) / |λ_{j+1}|`` where ``j`` is the
    largest index such that the cumulative sum of the first ``j``
    exponents is non-negative.

    Kaplan & Yorke 1979. The Kaplan-Yorke conjecture equates this to
    the information dimension ``D₁``.

    Parameters
    ----------
    lyapunov_exponents : FloatArray
        ``(N,)`` Lyapunov exponents.

    Returns
    -------
    float
        ``D_KY``. Returns ``0.0`` if the largest exponent is negative (stable fixed
        point, zero-dimensional attractor).
    """
    le = _validate_spectrum(lyapunov_exponents)
    if le.size == 0:
        return 0.0
    expected = _kaplan_yorke_exact_reference(le)
    backend_fn = _dispatch("ky")
    if backend_fn is not None:
        fn = cast("Callable[[FloatArray], float]", backend_fn)
        le_sorted = np.sort(le)[::-1]
        try:
            return _validate_ky_dimension(
                fn(np.ascontiguousarray(le_sorted, dtype=np.float64)),
                n_exponents=int(le_sorted.size),
                expected=expected,
                atol=1e-9 if ACTIVE_BACKEND == "mojo" else 1e-12,
            )
        except (ImportError, RuntimeError, OSError, KeyError):
            backend_fn = None

    return expected

Poincare Sections

Detects when a trajectory crosses a hyperplane, extracts the crossing points (Poincare map), and computes return time statistics. Return time regularity distinguishes periodic orbits (constant return time) from chaotic ones (fluctuating return times).

Public and direct accelerator contracts reject boolean aliases, numeric-string aliases, complex values, non-finite values, malformed cardinality, and out-of-range crossing counts before section evidence reaches reports. Mojo text output keeps an explicit crossing-count header plus exact raw-line cardinality because stdout is a text transport.

Detailed documentation: Poincare section monitor

Two interfaces:

  • poincare_section(): general hyperplane crossing for any state-space trajectory
  • phase_poincare(): specialized for phase oscillators — detects when one oscillator crosses a reference phase value

Usage:

from scpn_phase_orchestrator.monitor.poincare import (
    poincare_section, phase_poincare,
)

# General hyperplane section
result = poincare_section(trajectory, normal=[1, 0, 0])
print(f"Mean return time: {result.mean_return_time:.1f}")
print(f"Return time std: {result.std_return_time:.3f}")

# Phase-specific section
result = phase_poincare(phases, oscillator_idx=0, section_phase=0.0)

poincare

Poincaré-section crossings with a 5-backend fallback chain.

Detects when a trajectory crosses a hyperplane, extracts the crossing points (Poincaré map), and computes return-time statistics.

For phase oscillators the natural section is the plane where one oscillator's phase crosses a reference value. The module exposes :func:poincare_section for generic hyperplanes and :func:phase_poincare for the phase-specific case.

References

Poincaré 1899, "Les méthodes nouvelles de la mécanique céleste".
Strogatz 2015, "Nonlinear Dynamics and Chaos", Ch. 8.

Classes

PoincareResult dataclass

PoincareResult(
    crossings: FloatArray,
    crossing_times: FloatArray,
    return_times: FloatArray,
    mean_return_time: float,
    std_return_time: float,
)

Poincaré-section output.

Methods:
__post_init__
__post_init__() -> None

Validate crossing arrays and derived return-time statistics.

Source code in src/scpn_phase_orchestrator/monitor/poincare.py
def __post_init__(self) -> None:
    """Validate crossing arrays and derived return-time statistics."""
    crossings = _validate_crossings(self.crossings)
    crossing_times = _validate_crossing_times(
        self.crossing_times,
        expected_count=int(crossings.shape[0]),
    )
    return_times_array = _validate_return_times(
        self.return_times,
        crossing_times=crossing_times,
    )
    mean_return_time = _validate_finite_real(
        self.mean_return_time,
        name="mean_return_time",
    )
    std_return_time = _validate_finite_real(
        self.std_return_time,
        name="std_return_time",
    )
    if mean_return_time < 0.0:
        raise ValueError("mean_return_time must be non-negative")
    if std_return_time < 0.0:
        raise ValueError("std_return_time must be non-negative")
    expected_mean = (
        float(np.mean(return_times_array)) if return_times_array.size else 0.0
    )
    expected_std = (
        float(np.std(return_times_array)) if return_times_array.size else 0.0
    )
    if not np.isclose(mean_return_time, expected_mean, rtol=1e-12, atol=1e-12):
        raise ValueError("mean_return_time must match return_times")
    if not np.isclose(std_return_time, expected_std, rtol=1e-12, atol=1e-12):
        raise ValueError("std_return_time must match return_times")

    self.crossings = crossings
    self.crossing_times = crossing_times
    self.return_times = return_times_array
    self.mean_return_time = mean_return_time
    self.std_return_time = std_return_time

Functions:

poincare_section

poincare_section(
    trajectory: object,
    normal: object,
    offset: object = 0.0,
    direction: str = "positive",
) -> PoincareResult

Hyperplane-crossing Poincaré section.

Parameters

trajectory : object Phase-space trajectory, shape (T, d). normal : object Normal vector defining the Poincaré hyperplane. offset : object Scalar offset of the Poincaré hyperplane. direction : str Crossing direction to record (e.g. positive).

Returns

PoincareResult The Poincaré-section crossing result.

Raises

ValueError If the normal vector or direction is invalid.

Source code in src/scpn_phase_orchestrator/monitor/poincare.py
def poincare_section(
    trajectory: object,
    normal: object,
    offset: object = 0.0,
    direction: str = "positive",
) -> PoincareResult:
    """Hyperplane-crossing Poincaré section.

    Parameters
    ----------
    trajectory : object
        Phase-space trajectory, shape ``(T, d)``.
    normal : object
        Normal vector defining the Poincaré hyperplane.
    offset : object
        Scalar offset of the Poincaré hyperplane.
    direction : str
        Crossing direction to record (e.g. ``positive``).

    Returns
    -------
    PoincareResult
        The Poincaré-section crossing result.

    Raises
    ------
    ValueError
        If the normal vector or direction is invalid.
    """
    traj = _validate_state_history(trajectory, name="trajectory")
    t, d = int(traj.shape[0]), int(traj.shape[1])
    norm_vec = _validate_normal(normal, expected_dim=d)
    offset = _validate_finite_real(offset, name="offset")
    direction_id = _DIRECTION_IDS.get(direction)
    if direction_id is None:
        raise ValueError(
            f"direction must be one of {list(_DIRECTION_IDS)}, got {direction!r}"
        )

    norm_mag = float(np.linalg.norm(norm_vec))
    if norm_mag == 0.0:
        return _assemble_result(np.zeros(t * d), np.zeros(t), 0, d)

    backend_fn = _dispatch("section")
    if backend_fn is not None:
        fn = cast(
            "Callable[[FloatArray, int, int, FloatArray, float, int], "
            "tuple[FloatArray, FloatArray, int]]",
            backend_fn,
        )
        try:
            cr_flat, times, n_cr = fn(
                traj.ravel(),
                t,
                d,
                norm_vec,
                offset,
                int(direction_id),
            )
            return _assemble_result(cr_flat, times, n_cr, d)
        except (ImportError, RuntimeError, OSError, KeyError):
            direction_id = int(direction_id)

    n = norm_vec / norm_mag
    signed_dist = traj @ n - offset

    cr_flat = np.zeros(t * d, dtype=np.float64)
    times = np.zeros(t, dtype=np.float64)
    n_cr = 0
    for i in range(len(signed_dist) - 1):
        d0, d1 = signed_dist[i], signed_dist[i + 1]
        is_cross = (
            (direction == "positive" and d0 < 0 and d1 >= 0)
            or (direction == "negative" and d0 > 0 and d1 <= 0)
            or (direction == "both" and d0 * d1 < 0)
        )
        if not is_cross:
            continue
        alpha = -d0 / (d1 - d0) if abs(d1 - d0) > 1e-15 else 0.5
        pt = traj[i] + alpha * (traj[i + 1] - traj[i])
        cr_flat[n_cr * d : (n_cr + 1) * d] = pt
        times[n_cr] = i + alpha
        n_cr += 1

    return _assemble_result(cr_flat, times, n_cr, d)

return_times

return_times(
    trajectory: object, normal: object, offset: object = 0.0
) -> FloatArray

Shortcut: return only the return-time sequence.

Parameters

trajectory : object Phase-space trajectory, shape (T, d). normal : object Normal vector defining the Poincaré hyperplane. offset : object Scalar offset of the Poincaré hyperplane.

Returns

FloatArray The sequence of return times between crossings.

Source code in src/scpn_phase_orchestrator/monitor/poincare.py
def return_times(
    trajectory: object,
    normal: object,
    offset: object = 0.0,
) -> FloatArray:
    """Shortcut: return only the return-time sequence.

    Parameters
    ----------
    trajectory : object
        Phase-space trajectory, shape ``(T, d)``.
    normal : object
        Normal vector defining the Poincaré hyperplane.
    offset : object
        Scalar offset of the Poincaré hyperplane.

    Returns
    -------
    FloatArray
        The sequence of return times between crossings.
    """
    return poincare_section(
        trajectory,
        normal,
        offset,
        direction="positive",
    ).return_times

phase_poincare

phase_poincare(
    phases: object,
    oscillator_idx: object = 0,
    section_phase: object = 0.0,
) -> PoincareResult

Poincaré section for phase-oscillator trajectories.

Detects when phases[:, oscillator_idx] crosses section_phase (mod 2π).

Parameters

phases : object Oscillator phases in radians, shape (N,). oscillator_idx : object Index of the oscillator whose phase defines the section. section_phase : object Phase value at which to record a crossing.

Returns

PoincareResult The Poincaré-section result for the phase oscillator.

Source code in src/scpn_phase_orchestrator/monitor/poincare.py
def phase_poincare(
    phases: object,
    oscillator_idx: object = 0,
    section_phase: object = 0.0,
) -> PoincareResult:
    """Poincaré section for phase-oscillator trajectories.

    Detects when ``phases[:, oscillator_idx]`` crosses
    ``section_phase (mod 2π)``.

    Parameters
    ----------
    phases : object
        Oscillator phases in radians, shape ``(N,)``.
    oscillator_idx : object
        Index of the oscillator whose phase defines the section.
    section_phase : object
        Phase value at which to record a crossing.

    Returns
    -------
    PoincareResult
        The Poincaré-section result for the phase oscillator.
    """
    phases = _validate_state_history(phases, name="phases")
    t, n = int(phases.shape[0]), int(phases.shape[1])
    oscillator_idx = _validate_oscillator_idx(oscillator_idx, n=n)
    section_phase = _validate_finite_real(section_phase, name="section_phase")

    backend_fn = _dispatch("phase")
    if backend_fn is not None:
        fn = cast(
            "Callable[[FloatArray, int, int, int, float], "
            "tuple[FloatArray, FloatArray, int]]",
            backend_fn,
        )
        try:
            cr_flat, times, n_cr = fn(
                phases.ravel(),
                t,
                n,
                oscillator_idx,
                section_phase,
            )
            return _assemble_result(cr_flat, times, n_cr, n)
        except (ImportError, RuntimeError, OSError, KeyError):
            oscillator_idx = int(oscillator_idx)

    target = np.unwrap(phases[:, oscillator_idx])
    shifted = (target - section_phase) % (2 * np.pi)

    cr_flat = np.zeros(t * n, dtype=np.float64)
    times = np.zeros(t, dtype=np.float64)
    n_cr = 0
    for i in range(t - 1):
        if shifted[i] > np.pi and shifted[i + 1] < np.pi:
            # The wrapped phase advances from shifted[i] up through the 2π≡0
            # section boundary into shifted[i+1]. The fraction of the step at
            # which it reaches the boundary is (2π − shifted[i]) over the
            # wrapped step (2π − shifted[i]) + shifted[i+1].
            denom = (2 * np.pi - shifted[i]) + shifted[i + 1]
            alpha = (2 * np.pi - shifted[i]) / denom if denom > 1e-15 else 0.5
            alpha = min(max(alpha, 0.0), 1.0)
            pt = phases[i] + alpha * (phases[i + 1] - phases[i])
            cr_flat[n_cr * n : (n_cr + 1) * n] = pt
            times[n_cr] = i + alpha
            n_cr += 1

    return _assemble_result(cr_flat, times, n_cr, n)

Sleep Stage Classifier

AASM sleep staging mapped to the Kuramoto order parameter R. Classifies phases into Wake/N1/N2/N3/REM based on R thresholds and a functional desynchronisation flag. Includes ultradian (~90 min) cycle phase estimation. Detailed documentation: Sleep Staging — detailed reference

sleep_staging

Sleep staging helpers derived from validated phase-synchrony time series.

The staging path maps Kuramoto R summaries and ultradian phase estimates into an AASM-like heuristic stage timeline for diagnostics and simulation review. R values, timestamps, and stage labels are validated before use, and the Rust accelerator mirrors the deterministic Python fallback rather than changing classification semantics.

Functions:

classify_sleep_stage

classify_sleep_stage(
    R: float, functional_desync: bool = False
) -> str

Classify sleep stage from Kuramoto order parameter R.

Parameters

R : float order parameter in [0, 1]. functional_desync : bool True when EEG shows desynchronisation pattern characteristic of REM (low-voltage mixed-frequency), as opposed to wakeful desynchronisation.

Returns

str One of "N3", "N2", "N1", "REM", "Wake".

Source code in src/scpn_phase_orchestrator/monitor/sleep_staging.py
def classify_sleep_stage(R: float, functional_desync: bool = False) -> str:
    """Classify sleep stage from Kuramoto order parameter *R*.

    Parameters
    ----------
    R : float
        order parameter in [0, 1].
    functional_desync : bool
        True when EEG shows desynchronisation pattern characteristic of REM (low-voltage
        mixed-frequency), as opposed to wakeful desynchronisation.

    Returns
    -------
    str
        One of ``"N3"``, ``"N2"``, ``"N1"``, ``"REM"``, ``"Wake"``.
    """
    r_value = _validate_order_parameter(R)
    desync = _validate_functional_desync(functional_desync)
    if _HAS_RUST:
        code = _rust_classify(r_value, desync)
        return _validate_stage_code(code)
    if _STAGE_THRESHOLDS["N3"] <= r_value:
        return "N3"
    if _STAGE_THRESHOLDS["N2"] <= r_value:
        return "N2"
    if _STAGE_THRESHOLDS["N1"] <= r_value:
        if desync:
            return "REM"
        return "N1"
    # Below N1 threshold
    if desync and _STAGE_THRESHOLDS["REM"] <= r_value:
        return "REM"
    return "Wake"

ultradian_phase

ultradian_phase(
    timestamps: FloatArray, stage_history: list[str]
) -> float

Estimate position within the ~90-minute ultradian sleep cycle.

Finds the most recent N3 epoch (cycle trough = deepest sleep) and returns the elapsed fraction of a 90-minute period since that point.

Parameters

timestamps : FloatArray monotonic epoch times in seconds, shape (n_epochs,). stage_history : list[str] sleep stage label per epoch, same length as timestamps.

Returns

float Phase in [0, 1) where 0 = cycle start (N3 onset), 0.5 ≈ mid-cycle (REM), wrapping back toward 0. Returns 0.0 if no N3 epoch is found.

Source code in src/scpn_phase_orchestrator/monitor/sleep_staging.py
def ultradian_phase(
    timestamps: FloatArray,
    stage_history: list[str],
) -> float:
    """Estimate position within the ~90-minute ultradian sleep cycle.

    Finds the most recent N3 epoch (cycle trough = deepest sleep) and
    returns the elapsed fraction of a 90-minute period since that point.

    Parameters
    ----------
    timestamps : FloatArray
        monotonic epoch times in seconds, shape (n_epochs,).
    stage_history : list[str]
        sleep stage label per epoch, same length as timestamps.

    Returns
    -------
    float
        Phase in [0, 1) where 0 = cycle start (N3 onset), 0.5 ≈ mid-cycle (REM),
        wrapping back toward 0. Returns 0.0 if no N3 epoch is found.
    """
    ts = _validate_timestamps(timestamps)
    stages = _validate_stage_history(stage_history, expected_n=int(ts.size))
    if ts.size == 0:
        return 0.0
    if _HAS_RUST:
        rust_ts: FloatArray = np.ascontiguousarray(ts, dtype=np.float64)
        codes: StageCodeArray = np.array(
            [_STAGE_CODES[s] for s in stages],
            dtype=np.uint8,
        )
        return _validate_ultradian_phase(_rust_ultradian(rust_ts, codes))
    n = int(ts.size)

    last_n3_idx = -1
    for i in range(n - 1, -1, -1):
        if stages[i] == "N3":
            last_n3_idx = i
            break

    if last_n3_idx < 0:
        return 0.0

    elapsed = float(ts[n - 1] - ts[last_n3_idx])
    return (elapsed % _ULTRADIAN_PERIOD_S) / _ULTRADIAN_PERIOD_S

Hybrid Order Monitoring

Hybrid classical/quantum order-parameter monitors and deterministic example fixtures for review-only cosimulation evidence.

hybrid_order

Classical+quantum co-simulation order monitor.

Computes Kuramoto synchrony and qubit-partition entanglement entropy from either statevectors or density matrices using NumPy only.

Classes

HybridOrderParameterResult dataclass

HybridOrderParameterResult(
    R: float,
    Psi: float,
    entanglement_entropy: float,
    normalised_entanglement_entropy: float,
    participation_ratio: float,
    qubit_count: int,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]],
    backend: str,
    claim_boundary: str,
    non_actuating: bool,
    execution_disabled: bool,
    record_hash: str,
)

Result of a hybrid classical-quantum order-parameter evaluation.

Methods:
__post_init__
__post_init__() -> None

Validate and normalize immutable published evidence.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order.py
def __post_init__(self) -> None:
    """Validate and normalize immutable published evidence."""
    r_value = _finite_real(self.R, name="R")
    if not 0.0 <= r_value <= 1.0:
        raise ValueError("R must be a finite number in [0, 1]")
    psi_value = _finite_real(self.Psi, name="Psi")
    if not 0.0 <= psi_value < 2.0 * np.pi:
        raise ValueError("Psi must be a canonical phase in [0, 2*pi)")
    entropy = _finite_real(self.entanglement_entropy, name="entanglement_entropy")
    if entropy < 0.0:
        raise ValueError("entanglement_entropy must be finite and non-negative")
    normalised = _finite_real(
        self.normalised_entanglement_entropy,
        name="normalised_entanglement_entropy",
    )
    if not 0.0 <= normalised <= 1.0:
        raise ValueError(
            "normalised_entanglement_entropy must be finite and in [0, 1]"
        )
    participation = _finite_real(
        self.participation_ratio, name="participation_ratio"
    )
    qubit_count = _positive_int(self.qubit_count, name="qubit_count")
    partition = _validate_bipartition(
        bipartition=self.bipartition, n_qubits=qubit_count
    )
    max_entropy = float(min(len(partition[0]), len(partition[1])))
    if entropy > max_entropy + 1e-12:
        raise ValueError("entanglement_entropy exceeds the bipartition maximum")
    expected_normalised = entropy / max_entropy
    if not np.isclose(normalised, expected_normalised, rtol=1e-10, atol=1e-12):
        raise ValueError(
            "normalised_entanglement_entropy contradicts entanglement_entropy"
        )
    max_participation = float(1 << len(partition[0]))
    if not 1.0 - 1e-12 <= participation <= max_participation + 1e-12:
        raise ValueError("participation_ratio is outside its bipartition bounds")
    backend = _validate_simulator_backend(self.backend)
    if self.claim_boundary != CLAIM_BOUNDARY:
        raise ValueError("claim_boundary must preserve the no-QPU boundary")
    if self.non_actuating is not True:
        raise ValueError("non_actuating must be exactly True")
    if self.execution_disabled is not True:
        raise ValueError("execution_disabled must be exactly True")

    record = _audit_record_body(
        r_value=r_value,
        psi_value=psi_value,
        entropy=entropy,
        normalised_entropy=normalised,
        participation_ratio=participation,
        qubit_count=qubit_count,
        bipartition=partition,
        backend=backend,
    )
    if not isinstance(self.record_hash, str) or self.record_hash != (
        expected_hash := _deterministic_record_hash(record)
    ):
        raise ValueError("record_hash does not match the canonical evidence")

    object.__setattr__(self, "R", r_value)
    object.__setattr__(self, "Psi", psi_value)
    object.__setattr__(self, "entanglement_entropy", entropy)
    object.__setattr__(self, "normalised_entanglement_entropy", normalised)
    object.__setattr__(self, "participation_ratio", participation)
    object.__setattr__(self, "qubit_count", qubit_count)
    object.__setattr__(self, "bipartition", partition)
    object.__setattr__(self, "backend", backend)
    object.__setattr__(self, "record_hash", expected_hash)
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit record.

Returns

dict[str, object] Return a JSON-safe audit record.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit record.

    Returns
    -------
    dict[str, object]
        Return a JSON-safe audit record.
    """
    record = _audit_record_body(
        r_value=self.R,
        psi_value=self.Psi,
        entropy=self.entanglement_entropy,
        normalised_entropy=self.normalised_entanglement_entropy,
        participation_ratio=self.participation_ratio,
        qubit_count=self.qubit_count,
        bipartition=self.bipartition,
        backend=self.backend,
    )
    record["record_hash"] = self.record_hash
    return record

Functions:

compute_hybrid_entanglement_order_parameter

compute_hybrid_entanglement_order_parameter(
    phases: FloatArray,
    quantum_state: object,
    *,
    qubit_count: int | None = None,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]]
    | None = None,
    simulator_backend: str = BACKEND,
) -> HybridOrderParameterResult

Compute classical R/Psi and the entanglement-aware hybrid order metric.

Parameters

phases : FloatArray Classical phase data. quantum_state : object Vector of length 2**n or density matrix shape (2**n, 2**n). qubit_count : int | None Optional explicit qubit-count override; must match the state. bipartition : tuple[tuple[int, ...], tuple[int, ...]] | None Optional pair of qubit index groups for reduced entropy. simulator_backend : str Explicit local simulator contract. The default accepts either statevector or density-matrix NumPy inputs; "numpy_statevector" and "numpy_density_matrix" require the corresponding payload shape and record that backend explicitly.

Returns

HybridOrderParameterResult HybridOrderParameterResult with a deterministic audit record hash.

Raises

ValueError If the quantum state or bipartition is invalid.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order.py
def compute_hybrid_entanglement_order_parameter(
    phases: FloatArray,
    quantum_state: object,
    *,
    qubit_count: int | None = None,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]] | None = None,
    simulator_backend: str = BACKEND,
) -> HybridOrderParameterResult:
    """Compute classical R/Psi and the entanglement-aware hybrid order metric.

    Parameters
    ----------
    phases : FloatArray
        Classical phase data.
    quantum_state : object
        Vector of length ``2**n`` or density matrix shape ``(2**n, 2**n)``.
    qubit_count : int | None
        Optional explicit qubit-count override; must match the state.
    bipartition : tuple[tuple[int, ...], tuple[int, ...]] | None
        Optional pair of qubit index groups for reduced entropy.
    simulator_backend : str
        Explicit local simulator contract. The default accepts either statevector or
        density-matrix NumPy inputs; ``"numpy_statevector"`` and
        ``"numpy_density_matrix"`` require the corresponding payload shape and record
        that backend explicitly.

    Returns
    -------
    HybridOrderParameterResult
        HybridOrderParameterResult with a deterministic audit record hash.

    Raises
    ------
    ValueError
        If the quantum state or bipartition is invalid.
    """
    phases_clean = _require_finite_float_array(phases, name="phases")
    r_value, psi_value = compute_order_parameter(phases_clean)

    backend = _validate_simulator_backend(simulator_backend)
    n_qubits, density_matrix, state_kind = _validate_quantum_state(quantum_state)
    if backend == "numpy_statevector" and state_kind != "statevector":
        raise ValueError("simulator_backend numpy_statevector requires a statevector")
    if backend == "numpy_density_matrix" and state_kind != "density_matrix":
        raise ValueError(
            "simulator_backend numpy_density_matrix requires a density matrix"
        )

    if qubit_count is None:
        qubit_count = n_qubits
    else:
        qubit_count = _positive_int(qubit_count, name="qubit_count")
        if qubit_count != n_qubits:
            raise ValueError("qubit_count is inconsistent with quantum_state size")

    partition = _validate_bipartition(bipartition=bipartition, n_qubits=qubit_count)
    reduced = _reduced_density_matrix(
        density_matrix=density_matrix,
        subsystem_a=partition[0],
        n_qubits=qubit_count,
    )
    entropy, participation_ratio = _von_neumann_entropy(reduced)
    max_entropy = float(min(len(partition[0]), len(partition[1])))
    normalised_entropy = 0.0 if max_entropy <= 0.0 else entropy / max_entropy
    normalised_entropy = float(np.clip(normalised_entropy, 0.0, 1.0))

    result_payload = _audit_record_body(
        r_value=float(r_value),
        psi_value=float(psi_value),
        entropy=float(entropy),
        normalised_entropy=float(normalised_entropy),
        participation_ratio=float(participation_ratio),
        qubit_count=int(qubit_count),
        bipartition=partition,
        backend=backend,
    )
    result_payload["record_hash"] = _deterministic_record_hash(result_payload)
    return HybridOrderParameterResult(
        R=float(r_value),
        Psi=float(psi_value),
        entanglement_entropy=float(entropy),
        normalised_entanglement_entropy=float(normalised_entropy),
        participation_ratio=float(participation_ratio),
        qubit_count=int(qubit_count),
        bipartition=(tuple(partition[0]), tuple(partition[1])),
        backend=str(result_payload["backend"]),
        claim_boundary=str(result_payload["claim_boundary"]),
        non_actuating=bool(result_payload["non_actuating"]),
        execution_disabled=bool(result_payload["execution_disabled"]),
        record_hash=str(result_payload["record_hash"]),
    )

hybrid_order_examples

Deterministic scenario fixtures for quantum co-simulation audit evidence.

The fixtures model hybrid order-parameter audits (entanglement entropy plus classical synchrony metrics) for non-actuating review workflows.

Classes

HybridStateCandidate dataclass

HybridStateCandidate(
    state_id: str,
    candidate_type: str,
    amplitudes: ComplexArray,
    entanglement_entropy: float,
    order_metric_r: float,
    order_metric_psi: float,
    objective_labels: tuple[str, ...],
    non_actuating: bool = True,
    execution_disabled: bool = True,
    claim_boundary: str = HybridBoundary,
)

Deterministic candidate state description for a scenario.

HybridOrderScenario dataclass

HybridOrderScenario(
    domain: str,
    scenario_id: str,
    phases: FloatArray,
    qubit_count: int,
    bipartition: tuple[tuple[int, ...], tuple[int, ...]],
    state_candidates: tuple[HybridStateCandidate, ...],
    objective_labels: tuple[str, ...],
    non_actuating: bool = True,
    execution_disabled: bool = True,
    claim_boundary: str = HybridBoundary,
    scenario_hash: str = "",
)

One deterministic scenario with review-safe outputs.

Functions:

build_hybrid_order_parameter_scenarios

build_hybrid_order_parameter_scenarios() -> tuple[
    dict[str, object], ...
]

Return deterministic, JSON-safe hybrid order-parameter scenarios.

Returns

tuple[dict[str, object], ...] Return deterministic, JSON-safe hybrid order-parameter scenarios.

Source code in src/scpn_phase_orchestrator/monitor/hybrid_order_examples.py
def build_hybrid_order_parameter_scenarios() -> tuple[dict[str, object], ...]:
    """Return deterministic, JSON-safe hybrid order-parameter scenarios.

    Returns
    -------
    tuple[dict[str, object], ...]
        Return deterministic, JSON-safe hybrid order-parameter scenarios.
    """
    scenarios = (
        _build_scenario(
            domain="quantum_simulation",
            scenario_id="hybrid_order_quantum_simulation_v1",
            qubit_count=2,
            phase_offset=0.15,
            objective_labels=(
                "quantum_cosimulation_validation",
                "entanglement_audit",
                "classical_phase_coherence",
            ),
        ),
        _build_scenario(
            domain="power_grid",
            scenario_id="hybrid_order_power_grid_v1",
            qubit_count=3,
            phase_offset=0.47,
            objective_labels=(
                "islanding_resilience",
                "frequency_lock",
                "quantum_readout_alignment",
            ),
        ),
        _build_scenario(
            domain="cardiac_rhythm",
            scenario_id="hybrid_order_cardiac_rhythm_v1",
            qubit_count=4,
            phase_offset=1.03,
            objective_labels=(
                "phase_stabilisation",
                "entanglement_envelope",
                "rhythm_quality",
            ),
        ),
    )

    records: list[dict[str, object]] = []
    for scenario in scenarios:
        _validate_scenario(scenario)
        scenario.scenario_hash = _compute_scenario_hash(scenario)
        _validate_scenario(scenario)
        records.append(_to_record(scenario))

    return tuple(records)

Information Replay Examples

Domain-specific information replay fixtures for cyber-industrial, infrastructure, and physiology validation paths. Physiology replay records enforce non-actuating audit boundaries, integer sample/bin/oscillator counts, finite non-negative metrics, unit-interval normalised Phi, and minimum partitions free of boolean aliases and object-complex integer aliases before replay corpus relationships are accepted. The corpus is exactly four uniquely named canonical cases with consistent sample/bin geometry; each expected-relationship string is bound to its case and normalised Phi is replayed from Phi and n_bins. Infrastructure replay records apply the same engineering-proxy boundary to power-grid and traffic-corridor replay corpora: sample/bin/oscillator counts are integer-only, metrics are finite real non-negative values, normalised Phi is bounded to the unit interval, and minimum partitions reject boolean aliases plus object-complex integer aliases before the re-synchronisation/recovery ordering contracts are accepted. The corpus is exactly four uniquely named canonical cases with consistent sample/bin geometry; each expected-relationship string is bound to its case and normalised Phi is replayed from Phi and n_bins. Cyber-industrial replay records apply the same boundary to lateral-movement and manufacturing SPC corpora so containment/recovery ordering claims are accepted only after integer-only record counts, finite real metrics, bounded normalised Phi, and minimum partitions free of boolean aliases and object-complex integer aliases pass validation. The corpus is exactly four uniquely named canonical cases with consistent sample/bin geometry; each expected-relationship string is bound to its case and normalised Phi is replayed from Phi and n_bins.

information_replay_cyber_industrial

Deterministic cyber-industrial replay benchmark records (engineering proxy).

These are proxy-only monitors for empirical replay corpora and are not theoretical IIT claims.

Functions:

build_cyber_industrial_integrated_information_replays

build_cyber_industrial_integrated_information_replays(
    *, n_samples: int = 256, n_bins: int = 8
) -> tuple[dict[str, Any], ...]

Build deterministic cyber-industrial replay audit records.

Parameters

n_samples : int Number of time samples in each phase trajectory. Must be at least 32. n_bins : int Bin count passed to integrated_information. Must be an int > 1.

Returns

tuple[dict[str, Any], ...] JSON-safe replay records (one per case).

Raises

ValueError If n_samples or n_bins are invalid.

Source code in src/scpn_phase_orchestrator/monitor/information_replay_cyber_industrial.py
def build_cyber_industrial_integrated_information_replays(
    *,
    n_samples: int = 256,
    n_bins: int = 8,
) -> tuple[dict[str, Any], ...]:
    """Build deterministic cyber-industrial replay audit records.

    Parameters
    ----------
    n_samples : int
        Number of time samples in each phase trajectory. Must be at least 32.
    n_bins : int
        Bin count passed to ``integrated_information``. Must be an int > 1.

    Returns
    -------
    tuple[dict[str, Any], ...]
        JSON-safe replay records (one per case).

    Raises
    ------
    ValueError
        If ``n_samples`` or ``n_bins`` are invalid.
    """
    _validate_replay_parameters(n_samples=n_samples, n_bins=n_bins)

    records = (
        _build_cyber_disruption_case(n_samples=n_samples, n_bins=n_bins),
        _build_cyber_recontainment_case(n_samples=n_samples, n_bins=n_bins),
        _build_spc_fragmentation_case(n_samples=n_samples, n_bins=n_bins),
        _build_spc_recovery_case(n_samples=n_samples, n_bins=n_bins),
    )

    _validate_replay_records(records)

    return records

information_replay_infrastructure

Deterministic infrastructure replay records for the integrated-information proxy.

These records are empirical benchmark proxies over circular phase trajectories and are explicitly not theoretical IIT claims.

Functions:

build_infrastructure_integrated_information_replays

build_infrastructure_integrated_information_replays(
    *, n_samples: int = 256, n_bins: int = 8
) -> tuple[dict[str, Any], ...]

Build deterministic infrastructure replay records.

Parameters

n_samples : int Number of trajectory samples per case. Must be an int >= 32. n_bins : int Histogram bins for integrated_information. Must be an int >= 2.

Returns

tuple[dict[str, Any], ...] JSON-safe infrastructure replay records.

Raises

ValueError If parameters are invalid or the corpus does not satisfy ordering and schema validation.

Source code in src/scpn_phase_orchestrator/monitor/information_replay_infrastructure.py
def build_infrastructure_integrated_information_replays(
    *,
    n_samples: int = 256,
    n_bins: int = 8,
) -> tuple[dict[str, Any], ...]:
    """Build deterministic infrastructure replay records.

    Parameters
    ----------
    n_samples : int
        Number of trajectory samples per case. Must be an int >= 32.
    n_bins : int
        Histogram bins for ``integrated_information``. Must be an int >= 2.

    Returns
    -------
    tuple[dict[str, Any], ...]
        JSON-safe infrastructure replay records.

    Raises
    ------
    ValueError
        If parameters are invalid or the corpus does not satisfy ordering and schema
        validation.
    """
    _validate_replay_parameters(n_samples=n_samples, n_bins=n_bins)

    records = (
        _build_islanding_case(n_samples=n_samples, n_bins=n_bins),
        _build_resynchronisation_case(n_samples=n_samples, n_bins=n_bins),
        _build_traffic_spillback_case(n_samples=n_samples, n_bins=n_bins),
        _build_traffic_recovery_case(n_samples=n_samples, n_bins=n_bins),
    )

    _validate_replay_records(records)
    return records

information_replay_physiology

Deterministic physiology replay benchmark records (engineering proxy).

These records are explicit empirical replay cases used as audit-level indicators, not as theoretical IIT claims.

Functions:

build_physiology_integrated_information_replays

build_physiology_integrated_information_replays(
    *, n_samples: int = 256, n_bins: int = 8
) -> tuple[dict[str, Any], ...]

Build deterministic physiology replay audit records.

Parameters

n_samples : int Number of time samples in each trajectory. Must be at least 32. n_bins : int Number of phase bins used by integrated_information. Must be an integer > 1.

Returns

tuple[dict[str, Any], ...] JSON-safe replay records (one per physiology case).

Raises

ValueError If inputs are invalid or the benchmark ordering cannot be established.

Source code in src/scpn_phase_orchestrator/monitor/information_replay_physiology.py
def build_physiology_integrated_information_replays(
    *,
    n_samples: int = 256,
    n_bins: int = 8,
) -> tuple[dict[str, Any], ...]:
    """Build deterministic physiology replay audit records.

    Parameters
    ----------
    n_samples : int
        Number of time samples in each trajectory. Must be at least 32.
    n_bins : int
        Number of phase bins used by ``integrated_information``. Must be an integer > 1.

    Returns
    -------
    tuple[dict[str, Any], ...]
        JSON-safe replay records (one per physiology case).

    Raises
    ------
    ValueError
        If inputs are invalid or the benchmark ordering cannot be established.
    """
    _validate_replay_parameters(n_samples=n_samples, n_bins=n_bins)

    records = (
        _build_cardiac_respiratory_lock_case(n_samples=n_samples, n_bins=n_bins),
        _build_cardiac_respiratory_recovery_case(n_samples=n_samples, n_bins=n_bins),
        _build_eeg_sleep_spindle_case(n_samples=n_samples, n_bins=n_bins),
        _build_eeg_sleep_baseline_case(n_samples=n_samples, n_bins=n_bins),
    )

    _validate_replay_records(records)

    return records

Self-Model Reconfiguration

Self-model error records and review-only reconfiguration examples. Phase, order-signal, and channel-weight evidence must be finite, real, and non-coercive: boolean, complex, numeric-text, arbitrary conversion objects, and broken array protocols fail before discrepancy arithmetic. Channel labels and domain/scenario identifiers are canonical non-empty strings; labels are also unique. Optional order-specific RMSE and max-absolute thresholds are applied independently and retained in the audit record, falling back to the phase thresholds only when omitted.

Frozen SelfModelErrorResult construction is itself an evidence boundary. It replays channel lengths, aggregate and weighted metric equations, threshold decisions, optional order evidence, non-actuation flags, backend/claim identity, and the canonical record hash. A directly constructed contradictory result therefore cannot be serialised as monitor evidence.

Replay-backed reconfiguration proposals preserve the same custody boundary. Direct construction canonicalises phase vectors to read-only finite real arrays and rejects boolean, complex, numeric-text, arbitrary conversion, and broken array-protocol inputs before circular-error arithmetic. Domain, scenario, proposed-action, blocked-field, boolean safety-gate, positive-threshold, and lowercase SHA-256 identities fail closed. Nested proposal evidence must be strict JSON with string keys and finite values.

Replayed scenario records admit exactly the documented schema. Validation recomputes the canonical scenario hash and independently verifies the derived threshold-safety decision and phase-error summary, so an extra unsigned field or tampering with either derived record cannot survive as review evidence. The records remain operator-review-only and execution-disabled.

self_model

Deterministic self-model discrepancy monitor with auditable evidence.

Computes channel-wise and aggregate errors between observed and predicted phase trajectories, optional order-parameter errors, deterministic breach flags, and a stable evidence hash suitable for non-actuating industrial reporting.

Classes

SelfModelErrorThresholdConfig dataclass

SelfModelErrorThresholdConfig(
    tolerance: float,
    max_abs_tolerance: float,
    order_tolerance: float | None = None,
    order_max_abs_tolerance: float | None = None,
)

Thresholds and optional order-specific thresholds for monitor evaluation.

Methods:
__post_init__
__post_init__() -> None

Validate and canonicalise the frozen threshold configuration.

Source code in src/scpn_phase_orchestrator/monitor/self_model.py
def __post_init__(self) -> None:
    """Validate and canonicalise the frozen threshold configuration."""
    object.__setattr__(
        self,
        "tolerance",
        _require_finite_non_negative_float(self.tolerance, name="tolerance"),
    )
    object.__setattr__(
        self,
        "max_abs_tolerance",
        _require_finite_non_negative_float(
            self.max_abs_tolerance, name="max_abs_tolerance"
        ),
    )
    if self.order_tolerance is not None:
        object.__setattr__(
            self,
            "order_tolerance",
            _require_finite_non_negative_float(
                self.order_tolerance, name="order_tolerance"
            ),
        )
    if self.order_max_abs_tolerance is not None:
        object.__setattr__(
            self,
            "order_max_abs_tolerance",
            _require_finite_non_negative_float(
                self.order_max_abs_tolerance,
                name="order_max_abs_tolerance",
            ),
        )

SelfModelErrorResult dataclass

SelfModelErrorResult(
    domain: str,
    scenario_id: str | None,
    channel_labels: tuple[str, ...],
    channel_count: int,
    sample_count: int,
    overall_rmse: float,
    overall_mae: float,
    overall_max_abs_error: float,
    channel_rmse: tuple[float, ...],
    channel_mae: tuple[float, ...],
    channel_max_abs_error: tuple[float, ...],
    channel_breaches: tuple[bool, ...],
    weighted_rmse: float | None,
    weighted_mae: float | None,
    weighted_max_abs_error: float | None,
    channel_weights: tuple[float, ...] | None,
    tolerance: float,
    max_abs_tolerance: float,
    order_tolerance: float,
    order_max_abs_tolerance: float,
    breached: bool,
    order_rmse: float | None,
    order_mae: float | None,
    order_max_abs_error: float | None,
    order_breached: bool | None,
    claim_boundary: str,
    non_actuating: bool,
    execution_disabled: bool,
    backend: str,
    record_hash: str,
)

Deterministic result of one self-model error monitor invocation.

Methods:
__post_init__
__post_init__() -> None

Replay the frozen result's structural and derived evidence.

Source code in src/scpn_phase_orchestrator/monitor/self_model.py
def __post_init__(self) -> None:
    """Replay the frozen result's structural and derived evidence."""
    _validate_self_model_error_result(self)
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit record for the computed monitor output.

Returns

dict[str, object] Return a JSON-safe audit record for the computed monitor output.

Source code in src/scpn_phase_orchestrator/monitor/self_model.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit record for the computed monitor output.

    Returns
    -------
    dict[str, object]
        Return a JSON-safe audit record for the computed monitor output.
    """
    record = _result_payload(self)
    record["record_hash"] = _deterministic_record_hash(record)
    return record

Functions:

compute_self_model_error

compute_self_model_error(
    observed_phases: object,
    predicted_phases: object,
    *,
    observed_order: object | None = None,
    predicted_order: object | None = None,
    channel_labels: object | None = None,
    channel_weights: object | None = None,
    tolerance: float = 0.0,
    max_abs_tolerance: float = 0.0,
    order_tolerance: float | None = None,
    order_max_abs_tolerance: float | None = None,
    domain: str = "self_model",
    scenario_id: str | None = None,
) -> SelfModelErrorResult

Compute deterministic channel-wise discrepancy metrics for a self-model pair.

Parameters

observed_phases : object Observed phase trajectories shaped (C, T) or (T,). predicted_phases : object Predicted phase trajectories with matching shape. observed_order : object | None Optional observed order signal, shape (C,). predicted_order : object | None Optional predicted order signal, shape (C,). channel_labels : object | None Optional channel names for audit output. channel_weights : object | None Optional positive weights for channels. tolerance : float Global RMSE threshold used for pass/fail decisions. max_abs_tolerance : float Global max-abs threshold used for pass/fail decisions. order_tolerance : float | None Optional order-signal RMSE threshold; defaults to tolerance. order_max_abs_tolerance : float | None Optional order-signal max-abs threshold; defaults to max_abs_tolerance. domain : str Logical monitor domain identifier. scenario_id : str | None Optional scenario identifier for evidence context.

Returns

SelfModelErrorResult SelfModelErrorResult with deterministic hash and audit payload.

Raises

ValueError If the observed or predicted inputs are invalid.

Source code in src/scpn_phase_orchestrator/monitor/self_model.py
def compute_self_model_error(
    observed_phases: object,
    predicted_phases: object,
    *,
    observed_order: object | None = None,
    predicted_order: object | None = None,
    channel_labels: object | None = None,
    channel_weights: object | None = None,
    tolerance: float = 0.0,
    max_abs_tolerance: float = 0.0,
    order_tolerance: float | None = None,
    order_max_abs_tolerance: float | None = None,
    domain: str = "self_model",
    scenario_id: str | None = None,
) -> SelfModelErrorResult:
    """Compute deterministic channel-wise discrepancy metrics for a self-model pair.

    Parameters
    ----------
    observed_phases : object
        Observed phase trajectories shaped ``(C, T)`` or ``(T,)``.
    predicted_phases : object
        Predicted phase trajectories with matching shape.
    observed_order : object | None
        Optional observed order signal, shape ``(C,)``.
    predicted_order : object | None
        Optional predicted order signal, shape ``(C,)``.
    channel_labels : object | None
        Optional channel names for audit output.
    channel_weights : object | None
        Optional positive weights for channels.
    tolerance : float
        Global RMSE threshold used for pass/fail decisions.
    max_abs_tolerance : float
        Global max-abs threshold used for pass/fail decisions.
    order_tolerance : float | None
        Optional order-signal RMSE threshold; defaults to ``tolerance``.
    order_max_abs_tolerance : float | None
        Optional order-signal max-abs threshold; defaults to
        ``max_abs_tolerance``.
    domain : str
        Logical monitor domain identifier.
    scenario_id : str | None
        Optional scenario identifier for evidence context.

    Returns
    -------
    SelfModelErrorResult
        SelfModelErrorResult with deterministic hash and audit payload.

    Raises
    ------
    ValueError
        If the observed or predicted inputs are invalid.
    """
    observed = _coerce_channel_matrix(observed_phases, name="observed_phases")
    predicted = _coerce_channel_matrix(predicted_phases, name="predicted_phases")
    if observed.shape != predicted.shape:
        raise ValueError(
            "observed_phases and predicted_phases must have matching shapes"
        )

    thresholds = SelfModelErrorThresholdConfig(
        tolerance=_require_finite_non_negative_float(tolerance, name="tolerance"),
        max_abs_tolerance=_require_finite_non_negative_float(
            max_abs_tolerance,
            name="max_abs_tolerance",
        ),
        order_tolerance=order_tolerance,
        order_max_abs_tolerance=order_max_abs_tolerance,
    )
    order_rmse_tolerance = (
        thresholds.tolerance
        if thresholds.order_tolerance is None
        else thresholds.order_tolerance
    )
    order_max_tolerance = (
        thresholds.max_abs_tolerance
        if thresholds.order_max_abs_tolerance is None
        else thresholds.order_max_abs_tolerance
    )
    channel_count = int(observed.shape[0])
    sample_count = int(observed.shape[1])

    labels = _coerce_channel_labels(
        channel_labels,
        channel_count=channel_count,
    )
    weights = _coerce_channel_weights(
        channel_weights,
        channel_count=channel_count,
    )
    canonical_domain = _require_canonical_identity(domain, name="domain")
    canonical_scenario = _require_optional_canonical_identity(
        scenario_id, name="scenario_id"
    )

    phase_errors = _wrapped_phase_errors(predicted, observed)
    channel_rmse = tuple(
        float(np.sqrt(np.mean(np.square(errors)))) for errors in phase_errors
    )
    channel_mae = tuple(float(np.mean(np.abs(errors))) for errors in phase_errors)
    channel_max_abs = tuple(float(np.max(np.abs(errors))) for errors in phase_errors)
    channel_breaches = tuple(
        rmse > thresholds.tolerance or max_abs > thresholds.max_abs_tolerance
        for rmse, max_abs in zip(channel_rmse, channel_max_abs, strict=True)
    )

    flattened = phase_errors.ravel()
    overall_rmse = float(np.sqrt(np.mean(np.square(flattened))))
    overall_mae = float(np.mean(np.abs(flattened)))
    overall_max_abs = float(np.max(np.abs(flattened)))

    weighted_rmse: float | None
    weighted_mae: float | None
    weighted_max_abs: float | None
    if weights is None:
        weighted_rmse = None
        weighted_mae = None
        weighted_max_abs = None
        weight_tuple: tuple[float, ...] | None = None
    else:
        normalized = _normalise_positive_weights(weights)
        weight_tuple = tuple(float(w) for w in weights.tolist())
        channel_rmse_array = np.asarray(channel_rmse, dtype=np.float64)
        channel_mae_array = np.asarray(channel_mae, dtype=np.float64)
        channel_max_abs_array = np.asarray(channel_max_abs, dtype=np.float64)
        normalized = normalized / np.sum(normalized)
        weighted_rmse = float(np.sqrt(np.sum(normalized * channel_rmse_array**2)))
        weighted_mae = float(np.sum(normalized * channel_mae_array))
        weighted_max_abs = float(np.max(normalized * channel_max_abs_array))

    breached = overall_rmse > thresholds.tolerance or (
        overall_max_abs > thresholds.max_abs_tolerance
    )

    order_rmse: float | None
    order_mae: float | None
    order_max_abs_error: float | None
    order_breached: bool | None
    if (observed_order is None) ^ (predicted_order is None):
        raise ValueError(
            "both observed_order and predicted_order must be provided together"
        )

    if observed_order is None:
        order_rmse = None
        order_mae = None
        order_max_abs_error = None
        order_breached = None
    else:
        obs_order = _coerce_order_vector(observed_order, name="observed_order")
        pred_order = _coerce_order_vector(predicted_order, name="predicted_order")
        if obs_order.shape != pred_order.shape:
            raise ValueError("observed_order and predicted_order shapes must match")
        if obs_order.shape[0] != channel_count:
            raise ValueError(
                "observed_order shape must match the number of observed phases channels"
            )
        order_errors = pred_order - obs_order
        order_rmse = float(np.sqrt(np.mean(np.square(order_errors))))
        order_mae = float(np.mean(np.abs(order_errors)))
        order_max_abs_error = float(np.max(np.abs(order_errors)))
        order_breached = (
            order_rmse > order_rmse_tolerance
            or order_max_abs_error > order_max_tolerance
        )
        breached = breached or bool(order_breached)

    result_payload: dict[str, object] = {
        "domain": canonical_domain,
        "scenario_id": canonical_scenario,
        "backend": BACKEND,
        "channel_labels": list(labels),
        "channel_count": channel_count,
        "sample_count": sample_count,
        "channel_rmse": list(channel_rmse),
        "channel_mae": list(channel_mae),
        "channel_max_abs_error": list(channel_max_abs),
        "channel_breaches": list(channel_breaches),
        "channel_weights": None if weight_tuple is None else list(weight_tuple),
        "overall_rmse": overall_rmse,
        "overall_mae": overall_mae,
        "overall_max_abs_error": overall_max_abs,
        "weighted_rmse": weighted_rmse,
        "weighted_mae": weighted_mae,
        "weighted_max_abs_error": weighted_max_abs,
        "tolerance": thresholds.tolerance,
        "max_abs_tolerance": thresholds.max_abs_tolerance,
        "order_tolerance": order_rmse_tolerance,
        "order_max_abs_tolerance": order_max_tolerance,
        "breached": breached,
        "order_rmse": order_rmse,
        "order_mae": order_mae,
        "order_max_abs_error": order_max_abs_error,
        "order_breached": order_breached,
        "claim_boundary": CLAIM_BOUNDARY,
        "non_actuating": True,
        "execution_disabled": True,
    }
    record_hash = _deterministic_record_hash(result_payload)

    return SelfModelErrorResult(
        domain=canonical_domain,
        scenario_id=canonical_scenario,
        channel_labels=labels,
        channel_count=channel_count,
        sample_count=sample_count,
        overall_rmse=overall_rmse,
        overall_mae=overall_mae,
        overall_max_abs_error=overall_max_abs,
        channel_rmse=tuple(float(v) for v in channel_rmse),
        channel_mae=tuple(float(v) for v in channel_mae),
        channel_max_abs_error=tuple(float(v) for v in channel_max_abs),
        channel_breaches=tuple(bool(v) for v in channel_breaches),
        weighted_rmse=weighted_rmse,
        weighted_mae=weighted_mae,
        weighted_max_abs_error=weighted_max_abs,
        channel_weights=weight_tuple,
        tolerance=thresholds.tolerance,
        max_abs_tolerance=thresholds.max_abs_tolerance,
        order_tolerance=order_rmse_tolerance,
        order_max_abs_tolerance=order_max_tolerance,
        breached=breached,
        order_rmse=order_rmse,
        order_mae=order_mae,
        order_max_abs_error=order_max_abs_error,
        order_breached=order_breached,
        claim_boundary=CLAIM_BOUNDARY,
        non_actuating=True,
        execution_disabled=True,
        backend=BACKEND,
        record_hash=record_hash,
    )

self_model_examples

Deterministic replay-backed self-model reconfiguration examples.

These fixtures remain review-only and serialisable evidence for industrial control reconfiguration proposals. They intentionally disable execution and require operator review.

Classes

SelfModelErrorResult dataclass

SelfModelErrorResult(
    domain: str,
    scenario_id: str | None,
    channel_labels: tuple[str, ...],
    channel_count: int,
    sample_count: int,
    overall_rmse: float,
    overall_mae: float,
    overall_max_abs_error: float,
    channel_rmse: tuple[float, ...],
    channel_mae: tuple[float, ...],
    channel_max_abs_error: tuple[float, ...],
    channel_breaches: tuple[bool, ...],
    weighted_rmse: float | None,
    weighted_mae: float | None,
    weighted_max_abs_error: float | None,
    channel_weights: tuple[float, ...] | None,
    tolerance: float,
    max_abs_tolerance: float,
    order_tolerance: float,
    order_max_abs_tolerance: float,
    breached: bool,
    order_rmse: float | None,
    order_mae: float | None,
    order_max_abs_error: float | None,
    order_breached: bool | None,
    claim_boundary: str,
    non_actuating: bool,
    execution_disabled: bool,
    backend: str,
    record_hash: str,
)

Deterministic result of one self-model error monitor invocation.

Methods:
__post_init__
__post_init__() -> None

Replay the frozen result's structural and derived evidence.

Source code in src/scpn_phase_orchestrator/monitor/self_model.py
def __post_init__(self) -> None:
    """Replay the frozen result's structural and derived evidence."""
    _validate_self_model_error_result(self)
to_audit_record
to_audit_record() -> dict[str, object]

Return a JSON-safe audit record for the computed monitor output.

Returns

dict[str, object] Return a JSON-safe audit record for the computed monitor output.

Source code in src/scpn_phase_orchestrator/monitor/self_model.py
def to_audit_record(self) -> dict[str, object]:
    """Return a JSON-safe audit record for the computed monitor output.

    Returns
    -------
    dict[str, object]
        Return a JSON-safe audit record for the computed monitor output.
    """
    record = _result_payload(self)
    record["record_hash"] = _deterministic_record_hash(record)
    return record

SelfModelReconfigurationProposal dataclass

SelfModelReconfigurationProposal(
    domain: str,
    scenario_id: str,
    predicted_phase: FloatArray,
    observed_phase: FloatArray,
    error_threshold: float,
    self_model_error: SelfModelErrorResult
    | dict[str, object],
    proposed_reconfiguration_action: str,
    serialisable_evidence: dict[str, Any],
    blocked_live_execution_fields: tuple[str, ...],
    operator_review_required: bool = True,
    execution_disabled: bool = True,
    claim_boundary: str = SelfModelBoundary,
    scenario_hash: str = "",
)

Single replay-backed, review-only self-model reconfiguration scenario.

Methods:
__post_init__
__post_init__() -> None

Validate and canonicalise directly constructed proposal evidence.

Source code in src/scpn_phase_orchestrator/monitor/self_model_examples.py
def __post_init__(self) -> None:
    """Validate and canonicalise directly constructed proposal evidence."""
    domain = _coerce_canonical_string(self.domain, label="domain")
    if domain not in SupportedDomains:
        raise ValueError(f"invalid domain '{domain}'")
    object.__setattr__(self, "domain", domain)
    object.__setattr__(
        self,
        "scenario_id",
        _coerce_canonical_string(self.scenario_id, label="scenario_id"),
    )

    predicted = _coerce_vector(self.predicted_phase, label="predicted_phase")
    observed = _coerce_vector(self.observed_phase, label="observed_phase")
    if predicted.shape != observed.shape:
        raise ValueError("predicted and observed phase vectors must match")
    object.__setattr__(self, "predicted_phase", predicted)
    object.__setattr__(self, "observed_phase", observed)

    threshold = _coerce_scalar(self.error_threshold, label="error_threshold")
    if not math.isfinite(threshold) or threshold <= 0.0:
        raise ValueError("error_threshold must be finite and positive")
    object.__setattr__(self, "error_threshold", threshold)
    object.__setattr__(
        self,
        "proposed_reconfiguration_action",
        _coerce_canonical_string(
            self.proposed_reconfiguration_action,
            label="proposed_reconfiguration_action",
        ),
    )
    object.__setattr__(
        self,
        "serialisable_evidence",
        _canonicalise_json_evidence(
            self.serialisable_evidence,
            label="serialisable_evidence",
        ),
    )

    if not isinstance(self.blocked_live_execution_fields, tuple) or not (
        self.blocked_live_execution_fields
    ):
        raise ValueError("blocked_live_execution_fields must be a non-empty tuple")
    blocked = tuple(
        _coerce_canonical_string(field, label="blocked_live_execution_fields")
        for field in self.blocked_live_execution_fields
    )
    if len(set(blocked)) != len(blocked):
        raise ValueError("blocked_live_execution_fields must be unique")
    object.__setattr__(self, "blocked_live_execution_fields", blocked)

    if (
        _coerce_bool(
            self.operator_review_required,
            label="operator_review_required",
        )
        is not True
    ):
        raise ValueError("operator_review_required must be true")
    if (
        _coerce_bool(self.execution_disabled, label="execution_disabled")
        is not True
    ):
        raise ValueError("execution_disabled must be true")
    if self.claim_boundary != SelfModelBoundary:
        raise ValueError("claim_boundary must preserve the review-only boundary")
    if type(self.scenario_hash) is not str:
        raise ValueError("scenario_hash must be a string")
    if self.scenario_hash and (
        len(self.scenario_hash) != 64
        or self.scenario_hash != self.scenario_hash.lower()
        or any(char not in "0123456789abcdef" for char in self.scenario_hash)
    ):
        raise ValueError(
            "scenario_hash must be 64 lowercase hexadecimal characters"
        )

    _coerce_error_payload(
        self.self_model_error,
        predicted_phase=predicted,
        observed_phase=observed,
        error_threshold=threshold,
    )
    if self.scenario_hash:
        _validate_self_model_reconfiguration_proposal(self)
to_audit_record
to_audit_record() -> dict[str, Any]

Return a deterministic JSON-safe audit record.

Returns

dict[str, Any] Return a deterministic JSON-safe audit record.

Raises

ValueError If the proposal fields are inconsistent.

Source code in src/scpn_phase_orchestrator/monitor/self_model_examples.py
def to_audit_record(self) -> dict[str, Any]:
    """Return a deterministic JSON-safe audit record.

    Returns
    -------
    dict[str, Any]
        Return a deterministic JSON-safe audit record.

    Raises
    ------
    ValueError
        If the proposal fields are inconsistent.
    """
    _validate_self_model_reconfiguration_proposal(self)
    error_payload = _coerce_error_payload(
        self.self_model_error,
        predicted_phase=self.predicted_phase,
        observed_phase=self.observed_phase,
        error_threshold=self.error_threshold,
    )
    diff = np.abs(_circular_error(self.predicted_phase, self.observed_phase))
    unsafe = not error_payload["within_threshold"]
    record = {
        "domain": self.domain,
        "scenario_id": self.scenario_id,
        "claim_boundary": self.claim_boundary,
        "error_threshold": float(self.error_threshold),
        "predicted_phase": [float(v) for v in self.predicted_phase.tolist()],
        "observed_phase": [float(v) for v in self.observed_phase.tolist()],
        "proposed_reconfiguration_action": self.proposed_reconfiguration_action,
        "serialisable_evidence": self.serialisable_evidence,
        "blocked_live_execution_fields": list(self.blocked_live_execution_fields),
        "operator_review_required": self.operator_review_required,
        "execution_disabled": self.execution_disabled,
        "unsafe_due_to_threshold": bool(unsafe),
        "self_model_error": error_payload,
        "phase_error_summary": _error_summary(diff),
        "scenario_hash": "",
    }
    record["scenario_hash"] = _compute_scenario_hash(
        proposal=self, error_payload=error_payload
    )
    return record

Functions:

build_self_model_reconfiguration_examples

build_self_model_reconfiguration_examples() -> tuple[
    dict[str, Any], ...
]

Build deterministic review-only self-model reconfiguration evidence records.

Returns

tuple[dict[str, Any], ...] Build deterministic review-only self-model reconfiguration evidence records.

Source code in src/scpn_phase_orchestrator/monitor/self_model_examples.py
def build_self_model_reconfiguration_examples() -> tuple[dict[str, Any], ...]:
    """Build deterministic review-only self-model reconfiguration evidence records.

    Returns
    -------
    tuple[dict[str, Any], ...]
        Build deterministic review-only self-model reconfiguration evidence records.
    """
    records: list[dict[str, Any]] = []
    for proposal in _build_static_proposals():
        _validate_self_model_reconfiguration_proposal(proposal)
        record = proposal.to_audit_record()
        _validate_scenario_record(record)
        records.append(record)
    return tuple(records)

Early-Warning Detector Suite

Three complementary passive detectors share one alarm contract — a robust (median / MAD) z-score against a leading baseline, a relative-change gate, and a persistence run — so they can be compared, and fused, at a matched false-alarm rate. Each reads a different moment of an approaching synchronisation transition (a seizure onset, a grid coherence collapse): critical slowing down reads the second-moment variance / autocorrelation rise, rising synchronisation reads the first-moment Kuramoto order-parameter rise, and the ordinal-transition-entropy detector reads a regularisation drop. All are passive — they read observables and emit a warning record; they never actuate. A fair head-to-head (bench/early_warning_leadtime.py) established that the detection is a commodity, so the value is the auditable, sealed early-warning evidence around the alarm, not a claim that any one detector warns earlier.

Explosive-sync signals are non-empty, finite, real, and non-coercive. Its published warning record owns read-only array copies and replays the entropy mean, baseline median/MAD, derived scores, window grid, and sustained-breach decision before summary or metric export.

Critical Slowing Down

Rising variance and lag-one autocorrelation of an observable ahead of a critical transition (Scheffer et al. 2009; Dakos et al. 2012) — the classical early-warning baseline, implemented as a passive windowed monitor. Either a rising variance or a lengthening autocorrelation is a valid warning; requiring both understates the classical method.

critical_slowing_down

Critical-slowing-down early warning from rising variance and autocorrelation.

The established generic early-warning framework for an approaching critical transition is critical slowing down: as a system nears a bifurcation its recovery from perturbations lengthens, which shows up as a rising variance and a rising lag-one autocorrelation of the observable ahead of the transition. This module implements that classical indicator as a passive monitor, so it can serve as the literature baseline against the ordinal-transition-entropy detector in monitor/explosive_sync.py.

critical_slowing_down_warning slides a window across a multi-node signal array, computes each window's mean-detrended variance and lag-one autocorrelation per node, aggregates them across nodes, and raises a fail-early alarm when either indicator rises a robust (median / MAD) margin above its leading baseline (a rising variance or a lengthening autocorrelation is each a valid slowing-down warning; requiring both understates the classical method). The alarm logic — robust z-score against a leading baseline, a relative-change gate, and a persistence run — mirrors explosive_sync_warning exactly (sign reversed, since slowing-down is a rise and entropy regularisation is a drop), so a lead-time comparison between the two is a same-alarm, different-indicator test rather than an artefact of differing detector machinery. The monitor is passive: it reads observables and emits a warning record; it never actuates.

References

  • Scheffer et al. 2009, Nature 461, 53 — early-warning signals for critical transitions.
  • Dakos, Carpenter, Brock, Ellison, Guttal, Ives, Kéfi, Livina, Seekell, van Nes & Scheffer 2012, PLoS ONE 7, e41010 — methods for detecting early warning signals of critical transitions in time series.

Classes

CriticalSlowingDownWarning dataclass

CriticalSlowingDownWarning(
    window_starts: IntArray,
    variance_index: FloatArray,
    autocorrelation_index: FloatArray,
    combined_z: FloatArray,
    robust_z_variance: FloatArray,
    robust_z_autocorrelation: FloatArray,
    relative_rise: FloatArray,
    baseline_variance: float,
    baseline_autocorrelation: float,
    baseline_scale_variance: float,
    baseline_scale_autocorrelation: float,
    n_baseline_windows: int,
    warning_triggered: bool,
    warning_window: int | None,
    warning_sample: int | None,
    window: int,
    step: int,
    z_threshold: float,
    rise_threshold: float,
    persistence: int,
)

Result of a critical-slowing-down early-warning sweep.

Attributes

window_starts : IntArray First sample index of each analysis window, shape (W,). variance_index : FloatArray Mean per-node window variance per window, shape (W,). autocorrelation_index : FloatArray Mean per-node lag-one autocorrelation per window, shape (W,). combined_z : FloatArray Per-window rising indicator: the larger of the variance and autocorrelation robust z-scores, shape (W,). Large positive means at least one indicator rose — the sensitive critical-slowing-down signature (either a rising variance or a lengthening autocorrelation is a valid early warning; requiring both agrees less often and understates the classical method). robust_z_variance, robust_z_autocorrelation : FloatArray Median / MAD robust z-scores of each indicator against its baseline, shape (W,). relative_rise : FloatArray Larger of the two indicators' fractional rise above baseline, shape (W,). baseline_variance, baseline_autocorrelation : float Median of each indicator over the leading baseline windows. baseline_scale_variance, baseline_scale_autocorrelation : float Robust scale (1.4826 × MAD) of each baseline. n_baseline_windows : int Number of leading windows used to fit the baseline. warning_triggered : bool Whether a sustained rise crossed both the z and relative gates. warning_window : int | None Index of the first window of the triggering run, or None. warning_sample : int | None Sample index window_starts[warning_window], or None. window, step : int Echoed analysis parameters. z_threshold, rise_threshold : float Echoed alarm gates. persistence : int Echoed number of consecutive breaching windows required to alarm.

Methods:
summary
summary() -> dict[str, float | int | bool | None]

Return a flat scalar summary for logging or metric export.

Returns

dict[str, float | int | bool | None] Window/baseline counts, the peak rising z-score, the maximum relative rise, and the alarm verdict.

Source code in src/scpn_phase_orchestrator/monitor/critical_slowing_down.py
def summary(self) -> dict[str, float | int | bool | None]:
    """Return a flat scalar summary for logging or metric export.

    Returns
    -------
    dict[str, float | int | bool | None]
        Window/baseline counts, the peak rising z-score, the maximum
        relative rise, and the alarm verdict.
    """
    return {
        "n_windows": int(self.combined_z.shape[0]),
        "n_baseline_windows": self.n_baseline_windows,
        "baseline_variance": self.baseline_variance,
        "baseline_autocorrelation": self.baseline_autocorrelation,
        "max_combined_z": float(self.combined_z.max())
        if self.combined_z.size
        else 0.0,
        "max_relative_rise": float(self.relative_rise.max())
        if self.relative_rise.size
        else 0.0,
        "warning_triggered": self.warning_triggered,
        "warning_window": self.warning_window,
        "warning_sample": self.warning_sample,
    }

Functions:

critical_slowing_down_warning

critical_slowing_down_warning(
    signals: FloatArray,
    *,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    z_threshold: float = 3.0,
    rise_threshold: float = 0.1,
    persistence: int = 2,
) -> CriticalSlowingDownWarning

Sweep a multi-node signal for a critical-slowing-down warning.

Parameters

signals : FloatArray Per-node scalar observables, shape (N, T); a one-dimensional array is treated as a single node. window : int Analysis window length in samples; must be at least three to admit a lag-one autocorrelation estimate. step : int Hop between consecutive window starts in samples. baseline_fraction : float Leading fraction of windows used to fit the baseline, in (0, 1). min_baseline_windows : int Lower bound on the number of baseline windows. z_threshold : float Robust z-score magnitude above which a window breaches the rise gate. rise_threshold : float Minimum fractional rise above the baseline median to breach the gate. persistence : int Number of consecutive breaching windows required to raise the alarm.

Returns

CriticalSlowingDownWarning The per-window variance and autocorrelation fields, baseline fit, and the alarm decision.

Raises

ValueError If the inputs are malformed or the window does not fit the series.

Source code in src/scpn_phase_orchestrator/monitor/critical_slowing_down.py
def critical_slowing_down_warning(
    signals: FloatArray,
    *,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    z_threshold: float = 3.0,
    rise_threshold: float = 0.1,
    persistence: int = 2,
) -> CriticalSlowingDownWarning:
    """Sweep a multi-node signal for a critical-slowing-down warning.

    Parameters
    ----------
    signals : FloatArray
        Per-node scalar observables, shape ``(N, T)``; a one-dimensional array
        is treated as a single node.
    window : int
        Analysis window length in samples; must be at least three to admit a
        lag-one autocorrelation estimate.
    step : int
        Hop between consecutive window starts in samples.
    baseline_fraction : float
        Leading fraction of windows used to fit the baseline, in ``(0, 1)``.
    min_baseline_windows : int
        Lower bound on the number of baseline windows.
    z_threshold : float
        Robust z-score magnitude above which a window breaches the rise gate.
    rise_threshold : float
        Minimum fractional rise above the baseline median to breach the gate.
    persistence : int
        Number of consecutive breaching windows required to raise the alarm.

    Returns
    -------
    CriticalSlowingDownWarning
        The per-window variance and autocorrelation fields, baseline fit, and
        the alarm decision.

    Raises
    ------
    ValueError
        If the inputs are malformed or the window does not fit the series.
    """
    array = _validate_signals(signals)
    window = _validate_positive_int(window, "window")
    step = _validate_positive_int(step, "step")
    min_baseline_windows = _validate_positive_int(
        min_baseline_windows, "min_baseline_windows"
    )
    baseline_fraction = _validate_unit_fraction(baseline_fraction, "baseline_fraction")
    z_threshold = _validate_non_negative_real(z_threshold, "z_threshold")
    rise_threshold = _validate_non_negative_real(rise_threshold, "rise_threshold")
    persistence = _validate_positive_int(persistence, "persistence")

    n_nodes, n_samples = int(array.shape[0]), int(array.shape[1])
    if window < 3:
        raise ValueError(f"window {window} must be at least 3 for autocorrelation")
    if window > n_samples:
        raise ValueError(f"window {window} exceeds the series length {n_samples}")

    starts = list(range(0, n_samples - window + 1, step))
    n_windows = len(starts)
    window_starts = np.asarray(starts, dtype=np.int64)
    variance_per_node = np.empty((n_windows, n_nodes), dtype=np.float64)
    autocorr_per_node = np.empty((n_windows, n_nodes), dtype=np.float64)
    for w, start in enumerate(starts):
        segment = array[:, start : start + window]
        for node in range(n_nodes):
            variance_per_node[w, node], autocorr_per_node[w, node] = _window_indicators(
                segment[node]
            )
    variance_index = variance_per_node.mean(axis=1)
    autocorrelation_index = autocorr_per_node.mean(axis=1)

    n_baseline = min(
        n_windows,
        max(min_baseline_windows, int(np.ceil(baseline_fraction * n_windows))),
    )
    z_var, base_var, scale_var = _robust_rise(variance_index, n_baseline)
    z_ac, base_ac, scale_ac = _robust_rise(autocorrelation_index, n_baseline)
    combined_z = np.maximum(z_var, z_ac)
    rise_var = _relative_rise(variance_index, base_var)
    rise_ac = _relative_rise(autocorrelation_index, base_ac)
    relative_rise = np.maximum(rise_var, rise_ac)

    breaches = (
        (np.arange(n_windows) >= n_baseline)
        & (combined_z >= z_threshold)
        & (relative_rise >= rise_threshold)
    )
    warning_window = _first_sustained_breach(breaches, persistence)
    warning_triggered = warning_window is not None
    warning_sample = (
        int(window_starts[warning_window]) if warning_window is not None else None
    )

    return CriticalSlowingDownWarning(
        window_starts=window_starts,
        variance_index=np.ascontiguousarray(variance_index, dtype=np.float64),
        autocorrelation_index=np.ascontiguousarray(
            autocorrelation_index, dtype=np.float64
        ),
        combined_z=np.ascontiguousarray(combined_z, dtype=np.float64),
        robust_z_variance=np.ascontiguousarray(z_var, dtype=np.float64),
        robust_z_autocorrelation=np.ascontiguousarray(z_ac, dtype=np.float64),
        relative_rise=np.ascontiguousarray(relative_rise, dtype=np.float64),
        baseline_variance=base_var,
        baseline_autocorrelation=base_ac,
        baseline_scale_variance=scale_var,
        baseline_scale_autocorrelation=scale_ac,
        n_baseline_windows=n_baseline,
        warning_triggered=warning_triggered,
        warning_window=warning_window,
        warning_sample=warning_sample,
        window=window,
        step=step,
        z_threshold=z_threshold,
        rise_threshold=rise_threshold,
        persistence=persistence,
    )

critical_slowing_down_multiscale_warning

critical_slowing_down_multiscale_warning(
    signals: FloatArray,
    *,
    windows: Sequence[int] | None = None,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    z_threshold: float = 3.0,
    rise_threshold: float = 0.1,
    persistence: int = 2,
    aggregation: str = "max",
) -> CriticalSlowingDownWarning

Multi-scale critical-slowing-down warning.

Variance and lag-one autocorrelation are computed at every window start for each of the supplied window lengths. The per-scale indices are then aggregated across scales on the shared window grid before the robust-rise alarm rule is applied. This lets the detector respond to precursors that emerge at horizons shorter or longer than a single fixed window.

Parameters

signals : FloatArray Per-node scalar observables, shape (N, T); a one-dimensional array is treated as a single node. windows : sequence of int or None Window lengths to combine. Defaults to (64, 128, 256). step : int Hop between consecutive window starts in samples; shared by all scales. baseline_fraction : float Leading fraction of windows used to fit the baseline. min_baseline_windows : int Lower bound on the number of baseline windows. z_threshold : float Robust z-score gate applied to the aggregated combined score. rise_threshold : float Minimum fractional rise above the baseline median. persistence : int Number of consecutive breaching windows required to raise the alarm. aggregation : str How to aggregate scales: "max" (recommended) takes the strongest scale per window; "mean" averages scales.

Returns

CriticalSlowingDownWarning The aggregated per-window fields, baseline fit, and alarm decision.

Raises

ValueError If windows contains duplicate lengths, if aggregation is neither "max" nor "mean", if any window is smaller than three samples, or if the largest window exceeds the series length. Parameter validation also raises ValueError for non-positive integers or fractions outside the unit interval.

Source code in src/scpn_phase_orchestrator/monitor/critical_slowing_down.py
def critical_slowing_down_multiscale_warning(
    signals: FloatArray,
    *,
    windows: Sequence[int] | None = None,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    z_threshold: float = 3.0,
    rise_threshold: float = 0.1,
    persistence: int = 2,
    aggregation: str = "max",
) -> CriticalSlowingDownWarning:
    """Multi-scale critical-slowing-down warning.

    Variance and lag-one autocorrelation are computed at every window start for
    each of the supplied window lengths. The per-scale indices are then
    aggregated across scales on the shared window grid before the robust-rise
    alarm rule is applied. This lets the detector respond to precursors that
    emerge at horizons shorter or longer than a single fixed window.

    Parameters
    ----------
    signals : FloatArray
        Per-node scalar observables, shape ``(N, T)``; a one-dimensional array
        is treated as a single node.
    windows : sequence of int or None
        Window lengths to combine. Defaults to ``(64, 128, 256)``.
    step : int
        Hop between consecutive window starts in samples; shared by all scales.
    baseline_fraction : float
        Leading fraction of windows used to fit the baseline.
    min_baseline_windows : int
        Lower bound on the number of baseline windows.
    z_threshold : float
        Robust z-score gate applied to the aggregated combined score.
    rise_threshold : float
        Minimum fractional rise above the baseline median.
    persistence : int
        Number of consecutive breaching windows required to raise the alarm.
    aggregation : str
        How to aggregate scales: ``"max"`` (recommended) takes the strongest
        scale per window; ``"mean"`` averages scales.

    Returns
    -------
    CriticalSlowingDownWarning
        The aggregated per-window fields, baseline fit, and alarm decision.

    Raises
    ------
    ValueError
        If ``windows`` contains duplicate lengths, if ``aggregation`` is neither
        ``"max"`` nor ``"mean"``, if any window is smaller than three samples,
        or if the largest window exceeds the series length. Parameter validation
        also raises ``ValueError`` for non-positive integers or fractions
        outside the unit interval.
    """
    array = _validate_signals(signals)
    if windows is None:
        windows = (64, 128, 256)
    elif isinstance(windows, (str, bytes)) or not isinstance(windows, Sequence):
        raise ValueError("windows must be a non-empty sequence of integers")
    windows_tuple = tuple(_validate_positive_int(w, "windows") for w in windows)
    if not windows_tuple:
        raise ValueError("windows must be a non-empty sequence of integers")
    if len(set(windows_tuple)) != len(windows_tuple):
        raise ValueError("windows must be unique")
    step = _validate_positive_int(step, "step")
    min_baseline_windows = _validate_positive_int(
        min_baseline_windows, "min_baseline_windows"
    )
    baseline_fraction = _validate_unit_fraction(baseline_fraction, "baseline_fraction")
    z_threshold = _validate_non_negative_real(z_threshold, "z_threshold")
    rise_threshold = _validate_non_negative_real(rise_threshold, "rise_threshold")
    persistence = _validate_positive_int(persistence, "persistence")
    if aggregation not in {"max", "mean"}:
        raise ValueError(f"aggregation must be 'max' or 'mean', got {aggregation!r}")

    n_nodes, n_samples = int(array.shape[0]), int(array.shape[1])
    max_window = max(windows_tuple)
    if max_window < 3:
        raise ValueError("all windows must be at least 3 for autocorrelation")
    if max_window > n_samples:
        raise ValueError(
            f"largest window {max_window} exceeds the series length {n_samples}"
        )

    starts = list(range(0, n_samples - max_window + 1, step))
    n_windows = len(starts)
    window_starts = np.asarray(starts, dtype=np.int64)

    n_scales = len(windows_tuple)
    variance_scales = np.empty((n_scales, n_windows, n_nodes), dtype=np.float64)
    autocorr_scales = np.empty((n_scales, n_windows, n_nodes), dtype=np.float64)
    for scale_idx, window_len in enumerate(windows_tuple):
        for w_idx, start in enumerate(starts):
            segment = array[:, start : start + window_len]
            for node in range(n_nodes):
                (
                    variance_scales[scale_idx, w_idx, node],
                    autocorr_scales[scale_idx, w_idx, node],
                ) = _window_indicators(segment[node])

    n_baseline = min(
        n_windows,
        max(min_baseline_windows, int(np.ceil(baseline_fraction * n_windows))),
    )

    # Normalise each scale with its own robust baseline, then aggregate the
    # oriented scores. This prevents large-window raw variance from dominating
    # small-window autocorrelation signals.
    combined_z_scales = np.empty((n_scales, n_windows), dtype=np.float64)
    relative_rise_scales = np.empty((n_scales, n_windows), dtype=np.float64)
    for scale_idx in range(n_scales):
        variance_index = variance_scales[scale_idx].mean(axis=1)
        autocorrelation_index = autocorr_scales[scale_idx].mean(axis=1)
        z_var, base_var, _ = _robust_rise(variance_index, n_baseline)
        z_ac, base_ac, _ = _robust_rise(autocorrelation_index, n_baseline)
        combined_z_scales[scale_idx] = np.maximum(z_var, z_ac)
        rise_var = _relative_rise(variance_index, base_var)
        rise_ac = _relative_rise(autocorrelation_index, base_ac)
        relative_rise_scales[scale_idx] = np.maximum(rise_var, rise_ac)

    if aggregation == "max":
        combined_z = combined_z_scales.max(axis=0)
        relative_rise = relative_rise_scales.max(axis=0)
    else:  # aggregation == "mean"
        combined_z = combined_z_scales.mean(axis=0)
        relative_rise = relative_rise_scales.mean(axis=0)

    # Report the aggregated indices for introspection.
    variance_index = variance_scales.mean(axis=0).mean(axis=1)
    autocorrelation_index = autocorr_scales.mean(axis=0).mean(axis=1)
    z_var, base_var, scale_var = _robust_rise(variance_index, n_baseline)
    z_ac, base_ac, scale_ac = _robust_rise(autocorrelation_index, n_baseline)

    breaches = (
        (np.arange(n_windows) >= n_baseline)
        & (combined_z >= z_threshold)
        & (relative_rise >= rise_threshold)
    )
    warning_window = _first_sustained_breach(breaches, persistence)
    warning_triggered = warning_window is not None
    warning_sample = (
        int(window_starts[warning_window]) if warning_window is not None else None
    )

    return CriticalSlowingDownWarning(
        window_starts=window_starts,
        variance_index=np.ascontiguousarray(variance_index, dtype=np.float64),
        autocorrelation_index=np.ascontiguousarray(
            autocorrelation_index, dtype=np.float64
        ),
        combined_z=np.ascontiguousarray(combined_z, dtype=np.float64),
        robust_z_variance=np.ascontiguousarray(z_var, dtype=np.float64),
        robust_z_autocorrelation=np.ascontiguousarray(z_ac, dtype=np.float64),
        relative_rise=np.ascontiguousarray(relative_rise, dtype=np.float64),
        baseline_variance=base_var,
        baseline_autocorrelation=base_ac,
        baseline_scale_variance=scale_var,
        baseline_scale_autocorrelation=scale_ac,
        n_baseline_windows=n_baseline,
        warning_triggered=warning_triggered,
        warning_window=warning_window,
        warning_sample=warning_sample,
        window=max_window,
        step=step,
        z_threshold=z_threshold,
        rise_threshold=rise_threshold,
        persistence=persistence,
    )

surrogate_score_threshold

surrogate_score_threshold(
    signals: FloatArray,
    *,
    n_surrogates: int = 200,
    percentile: float = 95.0,
    block_length: int | None = None,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    persistence: int = 2,
    rng: int | Generator | None = None,
) -> float

Return a false-alarm threshold from a block-bootstrap null distribution.

The order-parameter series is resampled by circular block bootstrap. For each surrogate, the critical-slowing-down combined score is computed with a zero z-threshold and the maximum post-baseline score is recorded. The returned threshold is the requested percentile of those maxima and can be passed as z_threshold to :func:critical_slowing_down_warning to control the empirical false-alarm probability.

Parameters

signals : FloatArray Per-node scalar observables, shape (N, T) or one-dimensional. n_surrogates : int Number of bootstrap surrogates to draw. percentile : float Percentile of the surrogate max-score distribution to return as the threshold; e.g. 95.0 targets roughly a 5% false-alarm rate. block_length : int or None Bootstrap block length in samples; defaults to max(1, T // 20). window, step : int Analysis window length and hop passed to the underlying warning sweep. baseline_fraction : float Baseline fraction passed to the underlying warning sweep. min_baseline_windows : int Minimum baseline windows passed to the underlying warning sweep. persistence : int Persistence passed to the underlying warning sweep. rng : int or np.random.Generator or None Seed or generator for reproducible bootstrapping.

Returns

float A positive threshold on the combined score.

Raises

ValueError If percentile exceeds 100, or if any count or length parameter is not a positive integer.

Source code in src/scpn_phase_orchestrator/monitor/critical_slowing_down.py
def surrogate_score_threshold(
    signals: FloatArray,
    *,
    n_surrogates: int = 200,
    percentile: float = 95.0,
    block_length: int | None = None,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    persistence: int = 2,
    rng: int | np.random.Generator | None = None,
) -> float:
    """Return a false-alarm threshold from a block-bootstrap null distribution.

    The order-parameter series is resampled by circular block bootstrap. For each
    surrogate, the critical-slowing-down combined score is computed with a zero
    z-threshold and the maximum post-baseline score is recorded. The returned
    threshold is the requested percentile of those maxima and can be passed as
    ``z_threshold`` to :func:`critical_slowing_down_warning` to control the
    empirical false-alarm probability.

    Parameters
    ----------
    signals : FloatArray
        Per-node scalar observables, shape ``(N, T)`` or one-dimensional.
    n_surrogates : int
        Number of bootstrap surrogates to draw.
    percentile : float
        Percentile of the surrogate max-score distribution to return as the
        threshold; e.g. ``95.0`` targets roughly a 5% false-alarm rate.
    block_length : int or None
        Bootstrap block length in samples; defaults to ``max(1, T // 20)``.
    window, step : int
        Analysis window length and hop passed to the underlying warning sweep.
    baseline_fraction : float
        Baseline fraction passed to the underlying warning sweep.
    min_baseline_windows : int
        Minimum baseline windows passed to the underlying warning sweep.
    persistence : int
        Persistence passed to the underlying warning sweep.
    rng : int or np.random.Generator or None
        Seed or generator for reproducible bootstrapping.

    Returns
    -------
    float
        A positive threshold on the combined score.

    Raises
    ------
    ValueError
        If ``percentile`` exceeds 100, or if any count or length parameter is
        not a positive integer.
    """
    array = _validate_signals(signals)
    n_surrogates = _validate_positive_int(n_surrogates, "n_surrogates")
    percentile = _validate_non_negative_real(percentile, "percentile")
    if percentile > 100.0:
        raise ValueError(f"percentile must be <= 100, got {percentile}")
    window = _validate_positive_int(window, "window")
    step = _validate_positive_int(step, "step")
    persistence = _validate_positive_int(persistence, "persistence")
    if isinstance(rng, (bool, np.bool_)) or not isinstance(
        rng, (Integral, np.random.Generator, type(None))
    ):
        raise ValueError("rng must be a non-negative integer, Generator, or None")
    if isinstance(rng, Integral):
        seed = int(rng)
        if seed < 0:
            raise ValueError("rng must be a non-negative integer, Generator, or None")
        rng = seed
    rng = np.random.default_rng(rng)

    n_samples = int(array.shape[1])
    if block_length is None:
        block_length = max(1, n_samples // 20)
    else:
        block_length = _validate_positive_int(block_length, "block_length")

    max_scores = np.empty(n_surrogates, dtype=np.float64)
    for surrogate_idx in range(n_surrogates):
        surrogate = _block_bootstrap(array, block_length, rng)
        warning = critical_slowing_down_warning(
            surrogate,
            window=window,
            step=step,
            baseline_fraction=baseline_fraction,
            min_baseline_windows=min_baseline_windows,
            z_threshold=0.0,
            rise_threshold=0.0,
            persistence=persistence,
        )
        post = warning.combined_z[warning.n_baseline_windows :]
        max_scores[surrogate_idx] = float(post.max()) if post.size else 0.0
    return float(np.percentile(max_scores, percentile))

Critical-slowing-down signal ingress requires a non-empty finite real one- or two-dimensional array and rejects boolean, complex, and text-coercible aliases. Multiscale windows are a non-empty integer sequence. Surrogate RNG custody accepts only a non-negative Python/NumPy integer seed, an explicit NumPy Generator, or None; boolean seeds are rejected before bootstrap.

Rising Synchronisation

A sustained rise in the windowed Kuramoto order parameter R(t) = |⟨e^{iθ}⟩|, the first-moment coherence precursor complementary to the slowing-down and entropy indicators.

synchronisation

Rising-synchronisation early warning from the Kuramoto order parameter.

A synchronisation transition — the abrupt collective phase-locking behind a seizure onset or a grid coherence collapse — is preceded by the population's phase coherence climbing toward the locked state. The Kuramoto order parameter R(t) = |⟨e^{iθ}⟩| measures that coherence directly, so a sustained rise in its windowed level is a first-moment early-warning signal complementary to the second-moment critical-slowing-down indicators (monitor/critical_slowing_down.py, which read a variance/autocorrelation rise) and to the ordinal-transition- entropy detector (monitor/explosive_sync.py, which reads a regularisation drop). On a real scalp-EEG seizure the order parameter is the signal that carries the leading precursor, which is why it is a first-class member of the early-warning detector suite.

synchronisation_warning computes the instantaneous order parameter across the per-node phases, averages it within each sliding window, and raises a fail-early alarm when the windowed coherence rises a robust (median / MAD) margin above its leading baseline. The alarm logic — robust z-score against a leading baseline, a relative-change gate, and a persistence run — is the suite's shared contract, so this detector is directly comparable with the others at a matched false-alarm rate. The monitor is passive: it reads phases and emits a warning record; it never actuates.

References

  • Kuramoto 1984, Chemical Oscillations, Waves, and Turbulence — the order parameter of coupled phase oscillators.
  • Scheffer et al. 2009, Nature 461, 53 — early-warning signals for critical transitions (the framework this contributes a synchrony indicator to).

Classes

SynchronisationWarning dataclass

SynchronisationWarning(
    window_starts: IntArray,
    synchrony_index: FloatArray,
    robust_z: FloatArray,
    relative_rise: FloatArray,
    baseline_median: float,
    baseline_scale: float,
    n_baseline_windows: int,
    warning_triggered: bool,
    warning_window: int | None,
    warning_sample: int | None,
    window: int,
    step: int,
    z_threshold: float,
    rise_threshold: float,
    persistence: int,
)

Result of a rising-synchronisation early-warning sweep.

Attributes

window_starts : IntArray First sample index of each analysis window, shape (W,). synchrony_index : FloatArray Mean Kuramoto order parameter within each window, shape (W,); the headline coherence level in [0, 1]. robust_z : FloatArray Median / MAD robust z-score of synchrony_index against the baseline, shape (W,). Strongly positive means a sharp coherence rise. relative_rise : FloatArray Fractional rise of synchrony_index above the baseline median, shape (W,). baseline_median : float Median synchrony index over the leading baseline windows. baseline_scale : float Robust scale (1.4826 × MAD) of the baseline windows. n_baseline_windows : int Number of leading windows used to fit the baseline. warning_triggered : bool Whether a sustained rise crossed both the z and relative-rise gates. warning_window : int | None Index of the first window of the triggering run, or None. warning_sample : int | None Sample index window_starts[warning_window], or None. window, step : int Echoed analysis parameters. z_threshold, rise_threshold : float Echoed alarm gates. persistence : int Echoed number of consecutive breaching windows required to alarm.

Methods:
summary
summary() -> dict[str, float | int | bool | None]

Return a flat scalar summary for logging or metric export.

Returns

dict[str, float | int | bool | None] Window/baseline counts, the baseline coherence, the peak rising z-score, the maximum relative rise, and the alarm verdict.

Source code in src/scpn_phase_orchestrator/monitor/synchronisation.py
def summary(self) -> dict[str, float | int | bool | None]:
    """Return a flat scalar summary for logging or metric export.

    Returns
    -------
    dict[str, float | int | bool | None]
        Window/baseline counts, the baseline coherence, the peak rising
        z-score, the maximum relative rise, and the alarm verdict.
    """
    return {
        "n_windows": int(self.synchrony_index.shape[0]),
        "n_baseline_windows": self.n_baseline_windows,
        "baseline_median": self.baseline_median,
        "max_synchrony_index": float(self.synchrony_index.max())
        if self.synchrony_index.size
        else 0.0,
        "max_robust_z": float(self.robust_z.max()) if self.robust_z.size else 0.0,
        "max_relative_rise": float(self.relative_rise.max())
        if self.relative_rise.size
        else 0.0,
        "warning_triggered": self.warning_triggered,
        "warning_window": self.warning_window,
        "warning_sample": self.warning_sample,
    }

Functions:

synchronisation_warning

synchronisation_warning(
    phases: FloatArray,
    *,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    z_threshold: float = 3.0,
    rise_threshold: float = 0.1,
    persistence: int = 2,
) -> SynchronisationWarning

Sweep per-node phases for a rising-synchronisation warning.

Parameters

phases : FloatArray Per-node instantaneous phases in radians, shape (N, T) with at least two nodes (synchrony is undefined for a single oscillator). window : int Analysis window length in samples; must be at least one. step : int Hop between consecutive window starts in samples. baseline_fraction : float Leading fraction of windows used to fit the baseline, in (0, 1). min_baseline_windows : int Lower bound on the number of baseline windows. z_threshold : float Robust z-score magnitude above which a window breaches the rise gate. rise_threshold : float Minimum fractional rise above the baseline median to breach the gate. persistence : int Number of consecutive breaching windows required to raise the alarm.

Returns

SynchronisationWarning The per-window coherence field, baseline fit, and the alarm decision.

Raises

ValueError If the inputs are malformed or the window does not fit the series.

Source code in src/scpn_phase_orchestrator/monitor/synchronisation.py
def synchronisation_warning(
    phases: FloatArray,
    *,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    min_baseline_windows: int = 3,
    z_threshold: float = 3.0,
    rise_threshold: float = 0.1,
    persistence: int = 2,
) -> SynchronisationWarning:
    """Sweep per-node phases for a rising-synchronisation warning.

    Parameters
    ----------
    phases : FloatArray
        Per-node instantaneous phases in radians, shape ``(N, T)`` with at least
        two nodes (synchrony is undefined for a single oscillator).
    window : int
        Analysis window length in samples; must be at least one.
    step : int
        Hop between consecutive window starts in samples.
    baseline_fraction : float
        Leading fraction of windows used to fit the baseline, in ``(0, 1)``.
    min_baseline_windows : int
        Lower bound on the number of baseline windows.
    z_threshold : float
        Robust z-score magnitude above which a window breaches the rise gate.
    rise_threshold : float
        Minimum fractional rise above the baseline median to breach the gate.
    persistence : int
        Number of consecutive breaching windows required to raise the alarm.

    Returns
    -------
    SynchronisationWarning
        The per-window coherence field, baseline fit, and the alarm decision.

    Raises
    ------
    ValueError
        If the inputs are malformed or the window does not fit the series.
    """
    array = _validate_phases(phases)
    window = _validate_positive_int(window, "window")
    step = _validate_positive_int(step, "step")
    min_baseline_windows = _validate_positive_int(
        min_baseline_windows, "min_baseline_windows"
    )
    baseline_fraction = _validate_unit_fraction(baseline_fraction, "baseline_fraction")
    z_threshold = _validate_non_negative_real(z_threshold, "z_threshold")
    rise_threshold = _validate_non_negative_real(rise_threshold, "rise_threshold")
    persistence = _validate_positive_int(persistence, "persistence")

    n_samples = int(array.shape[1])
    if window > n_samples:
        raise ValueError(f"window {window} exceeds the series length {n_samples}")

    coherence = np.abs(np.mean(np.exp(1j * array), axis=0))
    starts = list(range(0, n_samples - window + 1, step))
    window_starts = np.asarray(starts, dtype=np.int64)
    synchrony_index = np.asarray(
        [float(np.mean(coherence[start : start + window])) for start in starts],
        dtype=np.float64,
    )

    n_windows = synchrony_index.shape[0]
    n_baseline = min(
        n_windows,
        max(min_baseline_windows, int(np.ceil(baseline_fraction * n_windows))),
    )
    baseline = synchrony_index[:n_baseline]
    baseline_median = float(np.median(baseline))
    mad = float(np.median(np.abs(baseline - baseline_median)))
    baseline_scale = _MAD_TO_STD * mad
    guarded_scale = max(baseline_scale, _SCALE_FLOOR)

    robust_z = (synchrony_index - baseline_median) / guarded_scale
    if baseline_median > _SCALE_FLOOR:
        relative_rise = (synchrony_index - baseline_median) / baseline_median
    else:
        # A near-zero baseline coherence (e.g. an anti-phase population) makes a
        # fractional rise undefined, so the relative gate is left unarmed rather
        # than dividing by an epsilon.
        relative_rise = np.zeros_like(synchrony_index)

    breaches = (
        (np.arange(n_windows) >= n_baseline)
        & (robust_z >= z_threshold)
        & (relative_rise >= rise_threshold)
    )
    warning_window = _first_sustained_breach(breaches, persistence)
    warning_triggered = warning_window is not None
    warning_sample = (
        int(window_starts[warning_window]) if warning_window is not None else None
    )

    return SynchronisationWarning(
        window_starts=window_starts,
        synchrony_index=np.ascontiguousarray(synchrony_index, dtype=np.float64),
        robust_z=np.ascontiguousarray(robust_z, dtype=np.float64),
        relative_rise=np.ascontiguousarray(relative_rise, dtype=np.float64),
        baseline_median=baseline_median,
        baseline_scale=baseline_scale,
        n_baseline_windows=n_baseline,
        warning_triggered=warning_triggered,
        warning_window=warning_window,
        warning_sample=warning_sample,
        window=window,
        step=step,
        z_threshold=z_threshold,
        rise_threshold=rise_threshold,
        persistence=persistence,
    )

Ensemble Fusion

Fuses the suite over one window grid: a weighted rule (weighted mean of the members' oriented z-scores against a scalar threshold, calibratable to a matched false-alarm rate) and a vote rule (at least min_votes members breach their own gate). The gain from fusion must be reported as an improvement in matched-false-alarm lead time, never as a raw detection rate — an OR of the members trivially raises the rate by spending the false-alarm budget.

ensemble_warning

Ensemble early warning that fuses the detector suite into one decision.

The early-warning suite carries three complementary passive indicators of an approaching synchronisation transition, each on its own observable: critical slowing down (:mod:~scpn_phase_orchestrator.monitor.critical_slowing_down, rising variance / autocorrelation), rising synchronisation (:mod:~scpn_phase_orchestrator.monitor.synchronisation, the Kuramoto order parameter) and ordinal-transition entropy (:mod:~scpn_phase_orchestrator.monitor.explosive_sync, a regularisation drop). A fair head-to-head (bench/early_warning_leadtime.py) showed no single indicator dominates, so the integration question is how to combine them without cheating the false-alarm budget.

ensemble_warning fuses the members' per-window oriented evidence — each member's robust z-score re-signed so that larger always means more anomalous — that share a common window grid. Two rules are offered:

  • weighted — a weighted mean of the oriented z-scores crossed against a single scalar fused_threshold. The threshold is continuous, so it can be calibrated to a matched false-alarm rate on a no-transition null exactly like a single detector; this is the rule to use for a fair lead-time comparison.
  • vote — an alarm when at least min_votes members individually breach their own gate. Interpretable and conservative, but its knob is discrete (one operating point per vote count), so it calibrates coarsely.

Both rules require a sustained persistence run over the post-baseline windows (the fused baseline is the widest of the members', so no member alarms while still inside its baseline). The gain from fusion must be shown as an improvement in matched-false-alarm lead time, never as a raw detection rate: an OR of the members (min_votes = 1) trivially raises the detection rate by spending the false-alarm budget, which is not an advantage. The monitor is passive: it reads the members' warnings and emits a fused record; it never actuates.

References

  • Scheffer et al. 2009, Nature 461, 53 — generic early-warning signals for critical transitions (the framework the fused indicators contribute to).
  • Kittler, Hatef, Duin & Matas 1998, IEEE TPAMI 20, 226 — combining classifiers (the sum / vote fusion rules this monitor specialises to z-score evidence).

Classes

MemberEvidence dataclass

MemberEvidence(
    name: str,
    native_direction: str,
    window_starts: IntArray,
    oriented_z: FloatArray,
    native_robust_z: FloatArray,
    breaches: BoolArray,
    baseline_median: float,
    z_threshold: float,
    n_baseline_windows: int,
)

One suite member's per-window evidence, aligned on the shared grid.

Attributes

name : str Member label, e.g. critical_slowing_down. native_direction : str :data:RISE or :data:DROP — the member's own alarm direction, kept so the fused record can report each contribution with its native sign. window_starts : IntArray First sample index of each window; must match across members. oriented_z : FloatArray The member's robust z-score re-signed so larger means more anomalous (native_robust_z for a rise, its negation for a drop). native_robust_z : FloatArray The member's own signed robust z-score. breaches : BoolArray The member's own per-window gate decision (its z and relative gates and the post-baseline mask), used by the vote rule. baseline_median : float Median of the member's raw indicator over its baseline windows (for the multi-indicator critical-slowing-down member this is the variance indicator's baseline). z_threshold : float The member's robust z-score gate. n_baseline_windows : int Number of leading windows the member fitted its baseline on.

MemberContribution dataclass

MemberContribution(
    name: str,
    direction: str,
    robust_z: float,
    baseline_median: float,
    z_threshold: float,
    breached: bool,
)

A member's snapshot at the reported window of a fused alarm.

Attributes

name : str Member label. direction : str The member's native alarm direction. robust_z : float The member's signed robust z-score at the reported window. baseline_median : float Median of the member's raw indicator over its baseline windows. z_threshold : float The member's robust z-score gate. breached : bool Whether the member's own gate held at the reported window.

EnsembleWarning dataclass

EnsembleWarning(
    window_starts: IntArray,
    fused_score: FloatArray,
    vote_count: IntArray,
    rule: str,
    fused_threshold: float,
    min_votes: int,
    persistence: int,
    n_baseline_windows: int,
    member_names: tuple[str, ...],
    contributions: tuple[MemberContribution, ...],
    warning_triggered: bool,
    warning_window: int | None,
    warning_sample: int | None,
)

Result of a fused ensemble early-warning sweep.

Attributes

window_starts : IntArray First sample index of each analysis window, shape (W,). fused_score : FloatArray Weighted mean of the members' oriented z-scores per window, shape (W,); the headline combined evidence (always computed, both rules). vote_count : IntArray Number of members breaching their own gate per window, shape (W,). rule : str :data:WEIGHTED_RULE or :data:VOTE_RULE. fused_threshold : float Scalar gate on fused_score used by the weighted rule. min_votes : int Vote count required by the vote rule. persistence : int Consecutive breaching windows required to alarm. n_baseline_windows : int Fused baseline boundary (the widest member baseline); no window before it may alarm. member_names : tuple[str, ...] Fused member labels, in order. contributions : tuple[MemberContribution, ...] Each member's snapshot at the reported window (the alarm window when triggered, else the closest fused approach). warning_triggered : bool Whether a sustained fused breach was found. warning_window : int | None Index of the first window of the triggering run, or None. warning_sample : int | None Sample index window_starts[warning_window], or None.

Methods:
summary
summary() -> dict[str, float | int | bool | str | None]

Return a flat scalar summary for logging or metric export.

Returns

dict[str, float | int | bool | str | None] The fusion rule, window count, the peak fused score, the peak vote count, and the alarm verdict.

Source code in src/scpn_phase_orchestrator/monitor/ensemble_warning.py
def summary(self) -> dict[str, float | int | bool | str | None]:
    """Return a flat scalar summary for logging or metric export.

    Returns
    -------
    dict[str, float | int | bool | str | None]
        The fusion rule, window count, the peak fused score, the peak vote
        count, and the alarm verdict.
    """
    return {
        "rule": self.rule,
        "n_windows": int(self.fused_score.shape[0]),
        "n_baseline_windows": self.n_baseline_windows,
        "max_fused_score": float(self.fused_score.max())
        if self.fused_score.size
        else 0.0,
        "max_vote_count": int(self.vote_count.max()) if self.vote_count.size else 0,
        "warning_triggered": self.warning_triggered,
        "warning_window": self.warning_window,
        "warning_sample": self.warning_sample,
    }

Functions:

ensemble_warning

ensemble_warning(
    members: list[MemberEvidence]
    | tuple[MemberEvidence, ...],
    *,
    rule: str = WEIGHTED_RULE,
    weights: list[float] | tuple[float, ...] | None = None,
    fused_threshold: float = 3.0,
    min_votes: int = 2,
    persistence: int = 2,
) -> EnsembleWarning

Fuse aligned suite-member evidence into one early-warning decision.

Parameters

members : sequence of MemberEvidence At least one member, all sharing an identical window_starts grid. rule : str :data:WEIGHTED_RULE (weighted-mean oriented z against fused_threshold) or :data:VOTE_RULE (at least min_votes members breach). weights : sequence of float or None Per-member weights for the weighted rule; defaults to equal weights. Each must be a positive finite real and the length must match members. fused_threshold : float Scalar gate on the weighted-mean oriented z-score; must be non-negative. min_votes : int Members that must breach for the vote rule; 1 ≤ min_votes ≤ len(members). persistence : int Consecutive breaching windows required to alarm.

Returns

EnsembleWarning The fused score, vote count, per-member contributions, and the alarm decision.

Raises

ValueError If the members are empty or misaligned, the rule is unknown, the weights are malformed, or a control is out of range.

Source code in src/scpn_phase_orchestrator/monitor/ensemble_warning.py
def ensemble_warning(
    members: list[MemberEvidence] | tuple[MemberEvidence, ...],
    *,
    rule: str = WEIGHTED_RULE,
    weights: list[float] | tuple[float, ...] | None = None,
    fused_threshold: float = 3.0,
    min_votes: int = 2,
    persistence: int = 2,
) -> EnsembleWarning:
    """Fuse aligned suite-member evidence into one early-warning decision.

    Parameters
    ----------
    members : sequence of MemberEvidence
        At least one member, all sharing an identical ``window_starts`` grid.
    rule : str
        :data:`WEIGHTED_RULE` (weighted-mean oriented z against ``fused_threshold``)
        or :data:`VOTE_RULE` (at least ``min_votes`` members breach).
    weights : sequence of float or None
        Per-member weights for the weighted rule; defaults to equal weights. Each
        must be a positive finite real and the length must match ``members``.
    fused_threshold : float
        Scalar gate on the weighted-mean oriented z-score; must be non-negative.
    min_votes : int
        Members that must breach for the vote rule; ``1 ≤ min_votes ≤ len(members)``.
    persistence : int
        Consecutive breaching windows required to alarm.

    Returns
    -------
    EnsembleWarning
        The fused score, vote count, per-member contributions, and the alarm
        decision.

    Raises
    ------
    ValueError
        If the members are empty or misaligned, the rule is unknown, the weights
        are malformed, or a control is out of range.
    """
    sealed = _validate_members(members)
    rule = _validate_rule(rule)
    fused_threshold = _validate_non_negative_real(fused_threshold, "fused_threshold")
    persistence = _validate_positive_int(persistence, "persistence")
    min_votes = (
        _validate_min_votes(min_votes, len(sealed))
        if rule == VOTE_RULE
        else _validate_positive_int(min_votes, "min_votes")
    )
    weight_array = _validate_weights(weights, len(sealed))

    window_starts = sealed[0].window_starts
    n_windows = int(window_starts.shape[0])
    oriented = np.vstack([member.oriented_z for member in sealed])
    fused_score = np.asarray(
        weight_array @ oriented / float(weight_array.sum()), dtype=np.float64
    )
    breach_matrix = np.vstack([member.breaches for member in sealed])
    vote_count = breach_matrix.sum(axis=0).astype(np.int64)

    boundary = max(member.n_baseline_windows for member in sealed)
    post_baseline = np.arange(n_windows) >= boundary
    if rule == WEIGHTED_RULE:
        breaches = post_baseline & (fused_score >= fused_threshold)
    else:
        breaches = post_baseline & (vote_count >= min_votes)

    warning_window = _first_sustained_breach(breaches, persistence)
    warning_triggered = warning_window is not None
    warning_sample = (
        int(window_starts[warning_window]) if warning_window is not None else None
    )
    report = _report_window(fused_score, boundary, warning_window)
    contributions = tuple(_contribution(member, report) for member in sealed)

    return EnsembleWarning(
        window_starts=np.ascontiguousarray(window_starts, dtype=np.int64),
        fused_score=np.ascontiguousarray(fused_score, dtype=np.float64),
        vote_count=np.ascontiguousarray(vote_count, dtype=np.int64),
        rule=rule,
        fused_threshold=fused_threshold,
        min_votes=min_votes,
        persistence=persistence,
        n_baseline_windows=boundary,
        member_names=tuple(member.name for member in sealed),
        contributions=contributions,
        warning_triggered=warning_triggered,
        warning_window=warning_window,
        warning_sample=warning_sample,
    )

member_from_critical_slowing_down

member_from_critical_slowing_down(
    warning: object,
) -> MemberEvidence

Adapt a critical-slowing-down warning into fused member evidence.

The member's oriented z-score is the detector's combined_z (already a rise), and its gate reconstructs the detector's own per-window breach mask.

Parameters

warning : object A :class:~scpn_phase_orchestrator.monitor.critical_slowing_down.CriticalSlowingDownWarning to align onto the shared fusion grid.

Returns

MemberEvidence The oriented per-window evidence, with combined_z as the oriented z-score and the detector's own per-window breach mask reconstructed.

Raises

ValueError If warning is not a :class:~scpn_phase_orchestrator.monitor.critical_slowing_down.CriticalSlowingDownWarning.

Source code in src/scpn_phase_orchestrator/monitor/ensemble_warning.py
def member_from_critical_slowing_down(warning: object) -> MemberEvidence:
    """Adapt a critical-slowing-down warning into fused member evidence.

    The member's oriented z-score is the detector's ``combined_z`` (already a
    rise), and its gate reconstructs the detector's own per-window breach mask.

    Parameters
    ----------
    warning : object
        A
        :class:`~scpn_phase_orchestrator.monitor.critical_slowing_down.CriticalSlowingDownWarning`
        to align onto the shared fusion grid.

    Returns
    -------
    MemberEvidence
        The oriented per-window evidence, with ``combined_z`` as the oriented
        z-score and the detector's own per-window breach mask reconstructed.

    Raises
    ------
    ValueError
        If ``warning`` is not a
        :class:`~scpn_phase_orchestrator.monitor.critical_slowing_down.CriticalSlowingDownWarning`.
    """
    from scpn_phase_orchestrator.monitor.critical_slowing_down import (
        CriticalSlowingDownWarning,
    )

    if not isinstance(warning, CriticalSlowingDownWarning):
        raise ValueError("warning must be a CriticalSlowingDownWarning")
    breaches = _rise_breaches(
        warning.combined_z,
        warning.relative_rise,
        warning.n_baseline_windows,
        warning.z_threshold,
        warning.rise_threshold,
    )
    return MemberEvidence(
        name="critical_slowing_down",
        native_direction=RISE,
        window_starts=warning.window_starts,
        oriented_z=warning.combined_z,
        native_robust_z=warning.combined_z,
        breaches=breaches,
        baseline_median=warning.baseline_variance,
        z_threshold=warning.z_threshold,
        n_baseline_windows=warning.n_baseline_windows,
    )

member_from_synchronisation

member_from_synchronisation(
    warning: object,
) -> MemberEvidence

Adapt a rising-synchronisation warning into fused member evidence.

Parameters

warning : object A :class:~scpn_phase_orchestrator.monitor.synchronisation.SynchronisationWarning to align onto the shared fusion grid.

Returns

MemberEvidence The oriented per-window evidence, with the order-parameter robust z-score as the oriented score and the detector's own breach mask.

Raises

ValueError If warning is not a :class:~scpn_phase_orchestrator.monitor.synchronisation.SynchronisationWarning.

Source code in src/scpn_phase_orchestrator/monitor/ensemble_warning.py
def member_from_synchronisation(warning: object) -> MemberEvidence:
    """Adapt a rising-synchronisation warning into fused member evidence.

    Parameters
    ----------
    warning : object
        A
        :class:`~scpn_phase_orchestrator.monitor.synchronisation.SynchronisationWarning`
        to align onto the shared fusion grid.

    Returns
    -------
    MemberEvidence
        The oriented per-window evidence, with the order-parameter robust
        z-score as the oriented score and the detector's own breach mask.

    Raises
    ------
    ValueError
        If ``warning`` is not a
        :class:`~scpn_phase_orchestrator.monitor.synchronisation.SynchronisationWarning`.
    """
    from scpn_phase_orchestrator.monitor.synchronisation import SynchronisationWarning

    if not isinstance(warning, SynchronisationWarning):
        raise ValueError("warning must be a SynchronisationWarning")
    breaches = _rise_breaches(
        warning.robust_z,
        warning.relative_rise,
        warning.n_baseline_windows,
        warning.z_threshold,
        warning.rise_threshold,
    )
    return MemberEvidence(
        name="synchronisation",
        native_direction=RISE,
        window_starts=warning.window_starts,
        oriented_z=warning.robust_z,
        native_robust_z=warning.robust_z,
        breaches=breaches,
        baseline_median=warning.baseline_median,
        z_threshold=warning.z_threshold,
        n_baseline_windows=warning.n_baseline_windows,
    )

member_from_transition_entropy

member_from_transition_entropy(
    warning: object,
) -> MemberEvidence

Adapt an ordinal-transition-entropy warning into fused member evidence.

The member warns on a drop, so its oriented z-score is the negation of the detector's signed robust_z.

Parameters

warning : object An :class:~scpn_phase_orchestrator.monitor.explosive_sync.ExplosiveSyncWarning to align onto the shared fusion grid.

Returns

MemberEvidence The oriented per-window evidence; because the member warns on a drop, the oriented z-score is the negation of the detector's signed robust_z.

Raises

ValueError If warning is not an :class:~scpn_phase_orchestrator.monitor.explosive_sync.ExplosiveSyncWarning.

Source code in src/scpn_phase_orchestrator/monitor/ensemble_warning.py
def member_from_transition_entropy(warning: object) -> MemberEvidence:
    """Adapt an ordinal-transition-entropy warning into fused member evidence.

    The member warns on a *drop*, so its oriented z-score is the negation of the
    detector's signed ``robust_z``.

    Parameters
    ----------
    warning : object
        An
        :class:`~scpn_phase_orchestrator.monitor.explosive_sync.ExplosiveSyncWarning`
        to align onto the shared fusion grid.

    Returns
    -------
    MemberEvidence
        The oriented per-window evidence; because the member warns on a *drop*,
        the oriented z-score is the negation of the detector's signed
        ``robust_z``.

    Raises
    ------
    ValueError
        If ``warning`` is not an
        :class:`~scpn_phase_orchestrator.monitor.explosive_sync.ExplosiveSyncWarning`.
    """
    from scpn_phase_orchestrator.monitor.explosive_sync import ExplosiveSyncWarning

    if not isinstance(warning, ExplosiveSyncWarning):
        raise ValueError("warning must be an ExplosiveSyncWarning")
    n_windows = int(warning.window_starts.shape[0])
    breaches = (
        (np.arange(n_windows) >= warning.n_baseline_windows)
        & (warning.robust_z <= -warning.z_threshold)
        & (warning.relative_drop >= warning.drop_threshold)
    )
    return MemberEvidence(
        name="transition_entropy",
        native_direction=DROP,
        window_starts=warning.window_starts,
        oriented_z=-warning.robust_z,
        native_robust_z=warning.robust_z,
        breaches=breaches,
        baseline_median=warning.baseline_median,
        z_threshold=warning.z_threshold,
        n_baseline_windows=warning.n_baseline_windows,
    )

Each MemberEvidence record owns immutable copies of one non-empty, strictly increasing window grid and aligned finite-real/boolean vectors. Member names are unique, native direction is canonical, and oriented z-scores must equal the native score for rising alarms or its negation for dropping alarms. Weighted fusion does not apply the vote-only quorum upper bound; vote fusion still requires min_votes <= member_count.

Domain-Adaptable Suite

Runs the three members and the weighted fusion over one neutral observable bundle (SuiteObservables: per-node phases, their sin(phase) projection, and the cross-node order parameter), so a scalp-EEG seizure, a grid coherence collapse, and a cardiac arrhythmia are screened by the same suite. Each is a synchronisation transition in a population of coupled oscillators; the only per-domain work is a DomainObservableAdapter that turns that domain's raw signals into the bundle. The suite itself is domain-neutral — it never learns where the observables came from.

early_warning_suite

Domain-adaptable early-warning suite over a neutral phase-observable contract.

The early-warning detectors — critical slowing down (:mod:~scpn_phase_orchestrator.monitor.critical_slowing_down), rising synchronisation (:mod:~scpn_phase_orchestrator.monitor.synchronisation), and ordinal-transition entropy (:mod:~scpn_phase_orchestrator.monitor.explosive_sync) — read generic arrays, not any one domain. The reason a scalp-EEG seizure, a grid coherence collapse, and a cardiac arrhythmia can all be screened by the same suite is that each is a synchronisation transition in a population of coupled oscillators; the only per-domain work is turning that domain's raw signals into three phase observables. This module makes that the explicit contract.

:class:SuiteObservables is the neutral bundle every detector reads: the per-node instantaneous phases (rising synchronisation), their projection sin(phase) (ordinal-transition entropy), and the cross-node Kuramoto order parameter R(t) = |⟨e^{iφ}⟩| (critical slowing down). A :class:DomainObservableAdapter is anything that turns a domain's raw signal block into that bundle — the scalp-EEG band-pass/Hilbert/decimation pipeline is one adapter; a cardiac ECG or grid PMU pipeline is another. Given the bundle, :func:run_early_warning_suite runs all three members and the weighted fusion under one alarm contract, returning a :class:SuiteWarnings, with no knowledge of where the observables came from.

The suite is passive: it reads observables and emits warning records; it never actuates. Sealing an alarm into auditable evidence is :mod:~scpn_phase_orchestrator.assurance.early_warning_evidence; calibrating a matched false-alarm threshold and measuring lead time on a labelled corpus is a validation harness, not this module.

References

  • Scheffer et al. 2009, Nature 461, 53 — generic early-warning signals for critical transitions.
  • Kuramoto 1984, Chemical Oscillations, Waves, and Turbulence — the order parameter of coupled phase oscillators.

Classes

SuiteObservables dataclass

SuiteObservables(
    phases: FloatArray,
    phase_field: FloatArray,
    order_parameter: FloatArray,
    sampling_rate_hz: float,
)

The neutral phase observables every early-warning detector reads.

Attributes

phases : FloatArray Per-node instantaneous phase in radians, shape (N, T) with at least two nodes; the rising-synchronisation input. phase_field : FloatArray Per-node projection sin(phase), shape (N, T); the ordinal-transition-entropy input. order_parameter : FloatArray Cross-node Kuramoto order parameter R(t) = |⟨e^{iφ}⟩| in [0, 1], shape (T,); the critical-slowing-down input. sampling_rate_hz : float Sampling rate of the observables, in hertz; converts a sample lead into seconds when an alarm is sealed.

Attributes
n_nodes property
n_nodes: int

Number of nodes in the observable field.

n_samples property
n_samples: int

Number of samples per node.

Methods:
__post_init__
__post_init__() -> None

Validate the observable shapes and ranges are mutually consistent.

Source code in src/scpn_phase_orchestrator/monitor/early_warning_suite.py
def __post_init__(self) -> None:
    """Validate the observable shapes and ranges are mutually consistent."""
    phases = _validate_field(self.phases, "phases")
    field_ = _validate_field(self.phase_field, "phase_field")
    order = _validate_series(self.order_parameter, "order_parameter")
    if phases.shape[0] < 2:
        raise ValueError("phases must have at least two nodes for synchrony")
    if field_.shape != phases.shape:
        raise ValueError("phase_field must share the shape of phases")
    if order.shape[0] != phases.shape[1]:
        raise ValueError("order_parameter length must match the phase length")
    if np.any(order < -_SCALE_FLOOR) or np.any(order > 1.0 + _SCALE_FLOOR):
        raise ValueError("order_parameter must lie in [0, 1]")
    expected_field = np.sin(phases)
    if not np.allclose(field_, expected_field, rtol=0.0, atol=1e-12):
        raise ValueError("phase_field must equal sin(phases)")
    expected_order = np.abs(np.mean(np.exp(1j * phases), axis=0))
    if not np.allclose(order, expected_order, rtol=0.0, atol=1e-12):
        raise ValueError("order_parameter must match phases")
    sampling_rate = _positive_real(self.sampling_rate_hz, "sampling_rate_hz")
    object.__setattr__(self, "phases", phases)
    object.__setattr__(self, "phase_field", field_)
    object.__setattr__(self, "order_parameter", np.clip(order, 0.0, 1.0))
    object.__setattr__(self, "sampling_rate_hz", sampling_rate)

SuiteWarnings dataclass

SuiteWarnings(
    critical_slowing_down: CriticalSlowingDownWarning,
    synchronisation: SynchronisationWarning,
    transition_entropy: ExplosiveSyncWarning,
    ensemble: EnsembleWarning,
)

The four early-warning records the suite emits over one observable bundle.

Attributes

critical_slowing_down : CriticalSlowingDownWarning The variance / autocorrelation rise on the order parameter. synchronisation : SynchronisationWarning The order-parameter rise on the per-node phases. transition_entropy : ExplosiveSyncWarning The ordinal-transition-entropy drop on the sin(phase) field. ensemble : EnsembleWarning The weighted fusion of the three members.

Methods:
triggered
triggered() -> dict[str, bool]

Return each detector's alarm verdict keyed by :data:SUITE_DETECTORS.

Returns

dict[str, bool] One label -> warning_triggered entry per detector, in :data:SUITE_DETECTORS order — the three members then the fusion.

Source code in src/scpn_phase_orchestrator/monitor/early_warning_suite.py
def triggered(self) -> dict[str, bool]:
    """Return each detector's alarm verdict keyed by :data:`SUITE_DETECTORS`.

    Returns
    -------
    dict[str, bool]
        One ``label -> warning_triggered`` entry per detector, in
        :data:`SUITE_DETECTORS` order — the three members then the fusion.
    """
    return {
        CRITICAL_SLOWING_DOWN: self.critical_slowing_down.warning_triggered,
        SYNCHRONISATION: self.synchronisation.warning_triggered,
        TRANSITION_ENTROPY: self.transition_entropy.warning_triggered,
        ENSEMBLE_WEIGHTED: self.ensemble.warning_triggered,
    }

DomainObservableAdapter

Bases: Protocol

A domain's bridge from raw signals to :class:SuiteObservables.

An adapter names its domain and turns a raw per-channel signal block into the neutral observable bundle the suite reads. The scalp-EEG band-pass / Hilbert / decimation pipeline is one adapter; a cardiac ECG or grid PMU pipeline is another. Adapters carry their own domain configuration, so the suite stays ignorant of the domain.

Attributes
domain property
domain: str

Return the domain label, e.g. scalp_eeg or cardiac_ecg.

Methods:
observables
observables(raw: FloatArray) -> SuiteObservables

Return the neutral observable bundle for one raw recording.

Parameters

raw : FloatArray One raw per-channel recording block in the adapter's native domain units, e.g. band-passed scalp-EEG samples or PMU frequency traces.

Returns

SuiteObservables The neutral phase-observable bundle the suite reads.

Source code in src/scpn_phase_orchestrator/monitor/early_warning_suite.py
def observables(self, raw: FloatArray) -> SuiteObservables:
    """Return the neutral observable bundle for one raw recording.

    Parameters
    ----------
    raw : FloatArray
        One raw per-channel recording block in the adapter's native domain
        units, e.g. band-passed scalp-EEG samples or PMU frequency traces.

    Returns
    -------
    SuiteObservables
        The neutral phase-observable bundle the suite reads.
    """
    ...

Functions:

observables_from_phases

observables_from_phases(
    phases: FloatArray, *, sampling_rate_hz: float
) -> SuiteObservables

Build the neutral observable bundle from a per-node phase field.

Most adapters end at a reconstructed per-node phase; this derives the remaining two observables — the sin(phase) projection and the cross-node order parameter — so an adapter need only supply phases and a rate.

Parameters

phases : FloatArray Per-node phase in radians, shape (N, T) with at least two nodes. sampling_rate_hz : float Sampling rate of the phases, in hertz.

Returns

SuiteObservables The phases, their sin projection, and the order parameter.

Raises

ValueError If the phase field is malformed or has fewer than two nodes.

Source code in src/scpn_phase_orchestrator/monitor/early_warning_suite.py
def observables_from_phases(
    phases: FloatArray, *, sampling_rate_hz: float
) -> SuiteObservables:
    """Build the neutral observable bundle from a per-node phase field.

    Most adapters end at a reconstructed per-node phase; this derives the
    remaining two observables — the ``sin(phase)`` projection and the cross-node
    order parameter — so an adapter need only supply phases and a rate.

    Parameters
    ----------
    phases : FloatArray
        Per-node phase in radians, shape ``(N, T)`` with at least two nodes.
    sampling_rate_hz : float
        Sampling rate of the phases, in hertz.

    Returns
    -------
    SuiteObservables
        The phases, their ``sin`` projection, and the order parameter.

    Raises
    ------
    ValueError
        If the phase field is malformed or has fewer than two nodes.
    """
    field_ = _validate_field(phases, "phases")
    if field_.shape[0] < 2:
        raise ValueError("phases must have at least two nodes for synchrony")
    order = np.abs(np.mean(np.exp(1j * field_), axis=0))
    return SuiteObservables(
        phases=field_,
        phase_field=np.ascontiguousarray(np.sin(field_), dtype=np.float64),
        order_parameter=np.ascontiguousarray(order, dtype=np.float64),
        sampling_rate_hz=sampling_rate_hz,
    )

run_early_warning_suite

run_early_warning_suite(
    observables: SuiteObservables,
    *,
    thresholds: Mapping[str, float],
    relative_gate: float = 0.05,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    persistence: int = 2,
) -> SuiteWarnings

Run the three members and the weighted fusion over one observable bundle.

Each detector reads the observable it is designed for at the supplied threshold: critical slowing down the order parameter, rising synchronisation the per-node phases, ordinal-transition entropy the sin(phase) field. The fusion is a weighted mean of the members' oriented z-scores. This is the domain-neutral core — it does not know which domain produced observables.

Parameters

observables : SuiteObservables The neutral observable bundle. thresholds : Mapping[str, float] Robust z-score (fused-score for the ensemble) gate per label in :data:SUITE_DETECTORS. relative_gate : float Minimum fractional change gate shared by the three members. window, step : int Analysis window length and hop, in samples. baseline_fraction : float Leading fraction of windows used to fit each detector's baseline. persistence : int Consecutive breaching windows required to raise an alarm.

Returns

SuiteWarnings The four warning records, aligned on one window grid.

Raises

KeyError If thresholds is missing a detector label. ValueError If an analysis control is out of range for a detector.

Source code in src/scpn_phase_orchestrator/monitor/early_warning_suite.py
def run_early_warning_suite(
    observables: SuiteObservables,
    *,
    thresholds: Mapping[str, float],
    relative_gate: float = 0.05,
    window: int = 128,
    step: int = 16,
    baseline_fraction: float = 0.25,
    persistence: int = 2,
) -> SuiteWarnings:
    """Run the three members and the weighted fusion over one observable bundle.

    Each detector reads the observable it is designed for at the supplied
    threshold: critical slowing down the order parameter, rising synchronisation
    the per-node phases, ordinal-transition entropy the ``sin(phase)`` field. The
    fusion is a weighted mean of the members' oriented z-scores. This is the
    domain-neutral core — it does not know which domain produced ``observables``.

    Parameters
    ----------
    observables : SuiteObservables
        The neutral observable bundle.
    thresholds : Mapping[str, float]
        Robust z-score (fused-score for the ensemble) gate per label in
        :data:`SUITE_DETECTORS`.
    relative_gate : float
        Minimum fractional change gate shared by the three members.
    window, step : int
        Analysis window length and hop, in samples.
    baseline_fraction : float
        Leading fraction of windows used to fit each detector's baseline.
    persistence : int
        Consecutive breaching windows required to raise an alarm.

    Returns
    -------
    SuiteWarnings
        The four warning records, aligned on one window grid.

    Raises
    ------
    KeyError
        If ``thresholds`` is missing a detector label.
    ValueError
        If an analysis control is out of range for a detector.
    """
    if not isinstance(observables, SuiteObservables):
        raise TypeError("observables must be SuiteObservables")
    if not isinstance(thresholds, Mapping):
        raise TypeError("thresholds must be a mapping")
    missing = [label for label in SUITE_DETECTORS if label not in thresholds]
    if missing:
        raise KeyError(f"thresholds missing detector labels: {missing!r}")
    validated_thresholds = {
        label: _non_negative_real(thresholds[label], f"thresholds[{label!r}]")
        for label in SUITE_DETECTORS
    }
    critical = critical_slowing_down_warning(
        observables.order_parameter[np.newaxis, :],
        window=window,
        step=step,
        baseline_fraction=baseline_fraction,
        z_threshold=validated_thresholds[CRITICAL_SLOWING_DOWN],
        rise_threshold=relative_gate,
        persistence=persistence,
    )
    synchrony = synchronisation_warning(
        observables.phases,
        window=window,
        step=step,
        baseline_fraction=baseline_fraction,
        z_threshold=validated_thresholds[SYNCHRONISATION],
        rise_threshold=relative_gate,
        persistence=persistence,
    )
    entropy = explosive_sync_warning(
        observables.phase_field,
        window=window,
        step=step,
        baseline_fraction=baseline_fraction,
        z_threshold=validated_thresholds[TRANSITION_ENTROPY],
        drop_threshold=relative_gate,
        persistence=persistence,
    )
    fusion = ensemble_warning(
        [
            member_from_critical_slowing_down(critical),
            member_from_synchronisation(synchrony),
            member_from_transition_entropy(entropy),
        ],
        rule=WEIGHTED_RULE,
        fused_threshold=validated_thresholds[ENSEMBLE_WEIGHTED],
        persistence=persistence,
    )
    return SuiteWarnings(
        critical_slowing_down=critical,
        synchronisation=synchrony,
        transition_entropy=entropy,
        ensemble=fusion,
    )

SuiteObservables is a coherent evidence bundle, not three independent arrays: phase_field must equal sin(phases) and order_parameter must equal the cross-node Kuramoto magnitude derived from those same phases. Arrays must be finite real and non-coercive; valid numeric-object inputs normalise to contiguous float64. Suite thresholds are prevalidated as a complete mapping of non-negative finite real values before any detector executes.