-
Notifications
You must be signed in to change notification settings - Fork 1
core: PhaseExtraction — event streams → phases (17th graduation, Amara #5 correction) #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| [<AutoOpen>] | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| module Zeta.Tests.Algebra.PhaseExtractionTests | ||
|
|
||
| open System | ||
| open FsUnit.Xunit | ||
| open global.Xunit | ||
| open Zeta.Core | ||
|
|
||
|
|
||
| // ─── epochPhase ───────── | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 ───────── | ||
|
|
||
| [<Fact>] | ||
| 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[]) | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 ───────── | ||
|
|
||
| [<Fact>] | ||
| 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 | ||
|
|
||
| [<Fact>] | ||
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1:
interEventPhase’s contract is internally inconsistent: the module docs describe irregular events ast_1 < t_2 < …(strictly increasing), but the function docs only require “sorted ascending”, and the implementation explicitly handlesinterval <= 0.0by returning phase 0.0. Please align the API contract and behavior (e.g., require strictly increasing eventTimes and validate/return empty on violation, or document duplicate/non-increasing handling and recommend pre-dedup/filtering).