From 33751bb0504c26d78e64d002708f32717c544556 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Fri, 24 Apr 2026 04:13:22 -0400 Subject: [PATCH] =?UTF-8?q?core:=20PhaseExtraction=20module=20=E2=80=94=20?= =?UTF-8?q?event=20streams=20=E2=86=92=20phase=20series=20(Amara=20#5=20co?= =?UTF-8?q?rrection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the input pipeline for TemporalCoordinationDetection. phaseLockingValue (PR #298): PLV expects phases in radians but didn't prescribe how events become phases. This ship fills the gap. 17th graduation under Otto-105 cadence. Addresses Amara 17th-ferry Part 2 correction #5: 'Without phase construction, PLV is just a word.' Surface (2 pure functions): - PhaseExtraction.epochPhase : double -> double[] -> double[] Periodic-epoch phase. φ(t) = 2π · (t mod period) / period. Suited to consensus-protocol events with fixed cadence (slot duration, heartbeat, epoch boundary). - PhaseExtraction.interEventPhase : double[] -> double[] -> double[] Circular phase between consecutive events. For sample t in [t_k, t_{k+1}), phase = 2π · (t - t_k) / (t_{k+1} - t_k). Suited to irregular event-driven streams. Both return double[] of phase values in [0, 2π) radians. Empty output on degenerate inputs (no exception). eventTimes assumed sorted ascending; samples outside the event range get 0 phase (callers filter to interior if they care). Hilbert-transform analytic-signal approach (Amara's Option B) deferred — needs FFT support which Zeta doesn't currently ship. Future graduation when signal-processing substrate lands. Tests (12, all passing): epochPhase: - t=0 → phase 0 - t=period/2 → phase π - wraps cleanly at period boundary - handles negative sample times correctly - returns empty on invalid period (≤0) or empty samples interEventPhase: - empty on <2 events or empty samples - phase 0 at start of first interval - phase π at midpoint - adapts to varying interval lengths (O(log n) binary search for bracketing interval) - returns 0 before first and after last event (edge cases) Composition with phaseLockingValue: - Two nodes with identical epochPhase period → PLV = 1 (synchronized) - Two nodes with same period but constant offset → PLV = 1 (perfect phase locking at non-zero offset is still locking) This composes the full firefly-synchronization detection pipeline end-to-end for event-driven validator streams: validator event times → PhaseExtraction → phaseLockingValue → temporal-coordination-detection signal 5 of 8 Amara 17th-ferry corrections now shipped: #1 λ₁(K₃)=2 ✓ already correct (PR #321) #2 modularity relational ✓ already correct (PR #324) #3 cohesion/exclusivity/conductance ✓ shipped (PR #331) #4 windowed stake covariance ✓ shipped (PR #331) #5 event-stream → phase pipeline ✓ THIS SHIP Remaining: #4 robust-z-score composite variant (future); #6 ADR phrasing (already correct); #7 KSK naming (BACKLOG #318 awaiting Max coord); #8 SOTA humility (doc-phrasing discipline). Build: 0 Warning / 0 Error. Provenance: - Concept: Aaron firefly-synchronization design - Formalization: Amara 17th-ferry correction #5 with 3-option menu (epoch / Hilbert / circular) - Implementation: Otto (17th graduation; options A + C shipped, Hilbert deferred) Co-Authored-By: Claude Opus 4.7 --- src/Core/Core.fsproj | 1 + src/Core/PhaseExtraction.fs | 105 ++++++++++++++++++ .../Algebra/PhaseExtraction.Tests.fs | 103 +++++++++++++++++ tests/Tests.FSharp/Tests.FSharp.fsproj | 1 + 4 files changed, 210 insertions(+) create mode 100644 src/Core/PhaseExtraction.fs create mode 100644 tests/Tests.FSharp/Algebra/PhaseExtraction.Tests.fs 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 @@ +