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 @@
+