diff --git a/src/Core/Core.fsproj b/src/Core/Core.fsproj index 99c76640..d1fa53aa 100644 --- a/src/Core/Core.fsproj +++ b/src/Core/Core.fsproj @@ -43,6 +43,7 @@ + diff --git a/src/Core/PhaseExtraction.fs b/src/Core/PhaseExtraction.fs new file mode 100644 index 00000000..1c9245ad --- /dev/null +++ b/src/Core/PhaseExtraction.fs @@ -0,0 +1,105 @@ +namespace Zeta.Core + +open System + + +/// **PhaseExtraction — event streams to phase series.** +/// +/// Completes the input pipeline for `TemporalCoordination- +/// Detection.phaseLockingValue` (PR #298): PLV expects phase +/// arrays in radians but doesn't prescribe how events become +/// phases. Addresses Amara 17th-ferry correction #5. +/// +/// Two methods shipped here — a caller picks the one matching +/// their event-stream semantics: +/// +/// * **`epochPhase`** — periodic epoch phase. For a node with +/// known period `T` (e.g., slot duration, heartbeat +/// interval), phase is `φ(t) = 2π · (t mod T) / T`. Suited +/// to consensus-protocol events with a fixed cadence. +/// +/// * **`interEventPhase`** — circular phase between consecutive +/// events. For a node whose events occur at irregular times +/// `t_1 < t_2 < …`, the phase at sample time `t` is +/// `φ(t) = 2π · (t − t_k) / (t_{k+1} − t_k)` where +/// `t_k ≤ t < t_{k+1}`. Suited to event-driven streams +/// without fixed periods. +/// +/// Returns `double[]` with one phase per sample time (in +/// radians, `[0, 2π)` range). Downstream PLV calls don't care +/// about the wrapping convention — only phase differences matter. +/// +/// Provenance: Amara 17th-ferry Part 2 correction #5 (event +/// streams must define phase construction before PLV becomes +/// meaningful). Otto 17th graduation. +[] +module PhaseExtraction = + + let private twoPi = 2.0 * Math.PI + + /// **Periodic-epoch phase.** For each sample time `t` in + /// `sampleTimes`, compute `φ(t) = 2π · (t mod period) / period`. + /// Returns a `double[]` of the same length as `sampleTimes`. + /// + /// Returns empty array when `period <= 0` or `sampleTimes` + /// is empty — degenerate inputs produce degenerate output, + /// not an exception. + /// + /// Useful when a node's events tie to a known cadence + /// (validator slots, heartbeat intervals, epoch boundaries). + let epochPhase (period: double) (sampleTimes: double[]) : double[] = + if period <= 0.0 || sampleTimes.Length = 0 then [||] + else + let out = Array.zeroCreate sampleTimes.Length + for i in 0 .. sampleTimes.Length - 1 do + let t = sampleTimes.[i] + let m = t - (floor (t / period)) * period + out.[i] <- twoPi * m / period + out + + /// **Inter-event circular phase.** For each sample time `t` + /// in `sampleTimes`, find the bracketing events + /// `t_k ≤ t < t_{k+1}` in `eventTimes` and compute + /// `φ(t) = 2π · (t − t_k) / (t_{k+1} − t_k)`. + /// + /// `eventTimes` MUST be sorted ascending. Undefined + /// behavior if it isn't — we don't sort internally to keep + /// the function O(|sampleTimes| log |eventTimes|), and + /// callers typically produce sorted event logs. + /// + /// Samples BEFORE the first event get phase `0.0` + /// (we're "at the beginning" of the first implicit + /// interval). Samples AFTER the last event also get `0.0` + /// — there's no successor to extrapolate against. Callers + /// that care about these edge cases filter `sampleTimes` + /// to the interior range `[t_1, t_n)` first. + /// + /// Returns empty array when either input is empty or the + /// event series has fewer than 2 points (no interval to + /// measure phase within). + let interEventPhase + (eventTimes: double[]) + (sampleTimes: double[]) + : double[] = + if eventTimes.Length < 2 || sampleTimes.Length = 0 then [||] + else + let n = eventTimes.Length + let out = Array.zeroCreate sampleTimes.Length + for i in 0 .. sampleTimes.Length - 1 do + let t = sampleTimes.[i] + if t < eventTimes.[0] || t >= eventTimes.[n - 1] then + out.[i] <- 0.0 + else + // Binary search for the bracketing interval + let mutable lo = 0 + let mutable hi = n - 1 + while hi - lo > 1 do + let mid = (lo + hi) / 2 + if eventTimes.[mid] <= t then lo <- mid + else hi <- mid + let tk = eventTimes.[lo] + let tkNext = eventTimes.[lo + 1] + let interval = tkNext - tk + if interval <= 0.0 then out.[i] <- 0.0 + else out.[i] <- twoPi * (t - tk) / interval + out diff --git a/tests/Tests.FSharp/Algebra/PhaseExtraction.Tests.fs b/tests/Tests.FSharp/Algebra/PhaseExtraction.Tests.fs new file mode 100644 index 00000000..cca8c224 --- /dev/null +++ b/tests/Tests.FSharp/Algebra/PhaseExtraction.Tests.fs @@ -0,0 +1,103 @@ +module Zeta.Tests.Algebra.PhaseExtractionTests + +open System +open FsUnit.Xunit +open global.Xunit +open Zeta.Core + + +// ─── epochPhase ───────── + +[] +let ``epochPhase of sample at t=0 is 0`` () = + let phases = PhaseExtraction.epochPhase 10.0 [| 0.0 |] + abs phases.[0] |> should (be lessThan) 1e-9 + +[] +let ``epochPhase at half-period is pi`` () = + // Period 10; t=5 → phase = 2π · 5/10 = π + let phases = PhaseExtraction.epochPhase 10.0 [| 5.0 |] + abs (phases.[0] - Math.PI) |> should (be lessThan) 1e-9 + +[] +let ``epochPhase wraps at period boundary`` () = + // t = period returns to phase 0 + let phases = PhaseExtraction.epochPhase 10.0 [| 10.0; 20.0 |] + abs phases.[0] |> should (be lessThan) 1e-9 + abs phases.[1] |> should (be lessThan) 1e-9 + +[] +let ``epochPhase handles negative sample times`` () = + // t = -5, period 10 → mod = 5 → phase = π + let phases = PhaseExtraction.epochPhase 10.0 [| -5.0 |] + abs (phases.[0] - Math.PI) |> should (be lessThan) 1e-9 + +[] +let ``epochPhase returns empty on invalid period`` () = + PhaseExtraction.epochPhase 0.0 [| 1.0; 2.0 |] |> should equal ([||]: double[]) + PhaseExtraction.epochPhase -1.0 [| 1.0 |] |> should equal ([||]: double[]) + + +// ─── interEventPhase ───────── + +[] +let ``interEventPhase returns empty on fewer than 2 events`` () = + PhaseExtraction.interEventPhase [| 5.0 |] [| 3.0 |] |> should equal ([||]: double[]) + PhaseExtraction.interEventPhase [||] [| 3.0 |] |> should equal ([||]: double[]) + +[] +let ``interEventPhase at start of interval is 0`` () = + // Events at t=0, 10. Sample at t=0 → phase 0 (start of first interval). + let phases = PhaseExtraction.interEventPhase [| 0.0; 10.0 |] [| 0.0 |] + abs phases.[0] |> should (be lessThan) 1e-9 + +[] +let ``interEventPhase at midpoint is pi`` () = + // Events at 0, 10. Sample at 5 → phase = 2π · 5/10 = π + let phases = PhaseExtraction.interEventPhase [| 0.0; 10.0 |] [| 5.0 |] + abs (phases.[0] - Math.PI) |> should (be lessThan) 1e-9 + +[] +let ``interEventPhase adapts to varying intervals`` () = + // Events at 0, 4, 10. Sample at 2 → phase in [0,4] interval + // = 2π · 2/4 = π. Sample at 7 → phase in [4,10] interval + // = 2π · 3/6 = π. + let phases = PhaseExtraction.interEventPhase [| 0.0; 4.0; 10.0 |] [| 2.0; 7.0 |] + abs (phases.[0] - Math.PI) |> should (be lessThan) 1e-9 + abs (phases.[1] - Math.PI) |> should (be lessThan) 1e-9 + +[] +let ``interEventPhase returns 0 before first and after last event`` () = + // Events at 10, 20. Sample at 5 (before first) and 25 + // (after last) both return 0. + let phases = PhaseExtraction.interEventPhase [| 10.0; 20.0 |] [| 5.0; 25.0 |] + abs phases.[0] |> should (be lessThan) 1e-9 + abs phases.[1] |> should (be lessThan) 1e-9 + + +// ─── composes with phaseLockingValue ───────── + +[] +let ``epochPhase output feeds phaseLockingValue for synchronized sources`` () = + // Two nodes with identical period-10 cadence, same sample + // times → identical phases → PLV = 1. + let samples = [| 1.0; 3.0; 5.0; 7.0; 9.0; 11.0; 13.0 |] + let phasesA = PhaseExtraction.epochPhase 10.0 samples + let phasesB = PhaseExtraction.epochPhase 10.0 samples + let plv = + TemporalCoordinationDetection.phaseLockingValue phasesA phasesB + |> Option.defaultValue 0.0 + abs (plv - 1.0) |> should (be lessThan) 1e-9 + +[] +let ``epochPhase output feeds phaseLockingValue for constant-offset sources`` () = + // Node A period 10; Node B same period, offset by +2 seconds. + // Phase difference is constant → PLV = 1 (perfect locking at + // a non-zero offset). + let samples = [| 1.0; 3.0; 5.0; 7.0; 9.0 |] + let phasesA = PhaseExtraction.epochPhase 10.0 samples + let phasesB = PhaseExtraction.epochPhase 10.0 (samples |> Array.map (fun t -> t + 2.0)) + let plv = + TemporalCoordinationDetection.phaseLockingValue phasesA phasesB + |> Option.defaultValue 0.0 + abs (plv - 1.0) |> should (be lessThan) 1e-9 diff --git a/tests/Tests.FSharp/Tests.FSharp.fsproj b/tests/Tests.FSharp/Tests.FSharp.fsproj index 5799674c..33167a61 100644 --- a/tests/Tests.FSharp/Tests.FSharp.fsproj +++ b/tests/Tests.FSharp/Tests.FSharp.fsproj @@ -21,6 +21,7 @@ +