diff --git a/src/Core/TemporalCoordinationDetection.fs b/src/Core/TemporalCoordinationDetection.fs index fb250f57..e9e27e29 100644 --- a/src/Core/TemporalCoordinationDetection.fs +++ b/src/Core/TemporalCoordinationDetection.fs @@ -94,3 +94,57 @@ module TemporalCoordinationDetection = else [| for tau in -maxLag .. maxLag -> tau, crossCorrelation xs ys tau |] + + /// **Phase-locking value (PLV)** — the magnitude of the mean + /// complex phase-difference vector between two phase series. + /// Returns a value in `[0.0, 1.0]`: + /// + /// * `1.0` — perfect phase locking (the phase difference + /// `φ_a[k] - φ_b[k]` is constant across the series; two + /// actors whose events always arrive at the same phase + /// offset look like this, which is the classical firefly- + /// synchronization signature) + /// * `0.0` — phase differences uniformly spread around the + /// unit circle (the null hypothesis of independent timing) + /// * in between — partial coordination + /// + /// Returns `None` when the input sequences are empty or of + /// unequal length; PLV is undefined for mismatched pairs + /// and silently truncating would invite a subtle detection + /// bug downstream. + /// + /// Input phases `phasesA` and `phasesB` are expected in + /// radians; the computation uses the Euler identity + /// `e^{iθ} = cos θ + i sin θ` and only depends on the + /// phase *difference*, so any consistent wrapping convention + /// (`[-π, π]` or `[0, 2π]`) works — callers do not need to + /// pre-unwrap. + /// + /// Complementary to `crossCorrelation`: cross-correlation + /// answers "do amplitudes move together?"; PLV answers "do + /// the events fire at matching phases?". Cartels that flatten + /// amplitude cross-correlation by adding noise may still + /// reveal themselves through preserved phase structure, and + /// vice versa. Detectors compose both. + /// + /// Provenance: primitive from Aaron's differentiable firefly + /// network design, formalized in Amara's 11th courier ferry + /// (`docs/aurora/2026-04-24-amara-temporal-coordination- + /// detection-cartel-graph-influence-surface-11th-ferry.md`, + /// §1 Signal model). Third graduation under the Otto-105 + /// cadence. + let phaseLockingValue (phasesA: double seq) (phasesB: double seq) : double option = + let aArr = Seq.toArray phasesA + let bArr = Seq.toArray phasesB + if aArr.Length = 0 || aArr.Length <> bArr.Length then None + else + let mutable sumCos = 0.0 + let mutable sumSin = 0.0 + for i in 0 .. aArr.Length - 1 do + let d = aArr.[i] - bArr.[i] + sumCos <- sumCos + cos d + sumSin <- sumSin + sin d + let n = double aArr.Length + let meanCos = sumCos / n + let meanSin = sumSin / n + Some (sqrt (meanCos * meanCos + meanSin * meanSin)) diff --git a/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs b/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs index f8acce01..265fa28c 100644 --- a/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs +++ b/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs @@ -99,3 +99,85 @@ let ``crossCorrelationProfile with negative maxLag returns empty array`` () = let xs = [ 1.0; 2.0; 3.0 ] let ys = [ 1.0; 2.0; 3.0 ] TemporalCoordinationDetection.crossCorrelationProfile xs ys -1 |> should equal ([||]: (int * double option) array) + + +// ─── phaseLockingValue ───────── + +[] +let ``phaseLockingValue of identical phase series is 1`` () = + let phases = [ 0.0; 0.5; 1.0; 1.5; 2.0 ] + TemporalCoordinationDetection.phaseLockingValue phases phases + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some 1.0) + +[] +let ``phaseLockingValue with constant phase offset is 1 (perfect locking)`` () = + // Constant offset of pi/4 — the complex phase-difference + // vector is the same unit vector every step, so magnitude = 1. + let a = [ 0.0; 0.3; 0.6; 0.9; 1.2 ] + let offset = Math.PI / 4.0 + let b = a |> List.map (fun x -> x + offset) + TemporalCoordinationDetection.phaseLockingValue a b + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some 1.0) + +[] +let ``phaseLockingValue of empty series is None`` () = + TemporalCoordinationDetection.phaseLockingValue [] [] |> should equal (None: double option) + +[] +let ``phaseLockingValue on mismatched-length series is None`` () = + // PLV is undefined for mismatched pairs. Silently truncating + // would mask a caller bug; None surfaces it. + let a = [ 0.0; 0.5; 1.0 ] + let b = [ 0.0; 0.5 ] + TemporalCoordinationDetection.phaseLockingValue a b |> should equal (None: double option) + +[] +let ``phaseLockingValue of anti-phase series is 1 (locking at pi offset)`` () = + // Two phase series that differ by exactly pi every step are + // perfectly anti-phase-locked; PLV measures the magnitude of + // the mean complex vector, which is 1 when the offset is + // constant (regardless of offset value). + let a = [ 0.0; 0.5; 1.0; 1.5 ] + let b = a |> List.map (fun x -> x + Math.PI) + TemporalCoordinationDetection.phaseLockingValue a b + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some 1.0) + +[] +let ``phaseLockingValue of uniformly-distributed differences is near 0`` () = + // Evenly-spaced phase differences spanning [0, 2*pi); the + // complex vectors sum to approximately zero by symmetry. + // Large N for the cancellation to be numerically clean. + let n = 360 + let a = [ for _ in 0 .. n - 1 -> 0.0 ] + let b = [ for i in 0 .. n - 1 -> 2.0 * Math.PI * double i / double n ] + let plv = + TemporalCoordinationDetection.phaseLockingValue a b + |> Option.defaultValue -1.0 + plv |> should (be lessThan) 1e-9 + +[] +let ``phaseLockingValue is commutative`` () = + // Swapping arguments flips the sign of every phase difference, + // which negates sin but leaves cos unchanged; the magnitude of + // the mean complex vector is invariant. + let a = [ 0.0; 0.4; 0.8; 1.2; 1.6 ] + let b = [ 0.1; 0.3; 0.7; 1.4; 1.5 ] + let ab = TemporalCoordinationDetection.phaseLockingValue a b + let ba = TemporalCoordinationDetection.phaseLockingValue b a + ab |> Option.map (fun v -> Math.Round(v, 12)) + |> should equal (ba |> Option.map (fun v -> Math.Round(v, 12))) + +[] +let ``phaseLockingValue handles single-element series`` () = + // N=1 is a degenerate case: the single complex vector has + // magnitude 1 regardless of phase. Not useful as a detector + // at that size (no statistical power), but the function + // must not crash and must return a defined value. + let a = [ 0.0 ] + let b = [ 0.0 ] + TemporalCoordinationDetection.phaseLockingValue a b + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some 1.0)