Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Core/Core.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<Compile Include="TemporalCoordinationDetection.fs" />
<Compile Include="Veridicality.fs" />
<Compile Include="Graph.fs" />
<Compile Include="PhaseExtraction.fs" />
<Compile Include="Window.fs" />
<Compile Include="Advanced.fs" />
<Compile Include="Fusion.fs" />
Expand Down
105 changes: 105 additions & 0 deletions src/Core/PhaseExtraction.fs
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.
Comment on lines +22 to +26
Copy link

Copilot AI Apr 24, 2026

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 as t_1 < t_2 < … (strictly increasing), but the function docs only require “sorted ascending”, and the implementation explicitly handles interval <= 0.0 by 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).

Copilot uses AI. Check for mistakes.
///
/// 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
103 changes: 103 additions & 0 deletions tests/Tests.FSharp/Algebra/PhaseExtraction.Tests.fs
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
1 change: 1 addition & 0 deletions tests/Tests.FSharp/Tests.FSharp.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<Compile Include="Algebra/TemporalCoordinationDetection.Tests.fs" />
<Compile Include="Algebra/Veridicality.Tests.fs" />
<Compile Include="Algebra/Graph.Tests.fs" />
<Compile Include="Algebra/PhaseExtraction.Tests.fs" />

<!-- Circuit/ -->
<Compile Include="Circuit/Circuit.Tests.fs" />
Expand Down
Loading