diff --git a/src/Core/TemporalCoordinationDetection.fs b/src/Core/TemporalCoordinationDetection.fs index 9cf6d3aa..14909663 100644 --- a/src/Core/TemporalCoordinationDetection.fs +++ b/src/Core/TemporalCoordinationDetection.fs @@ -127,13 +127,32 @@ module TemporalCoordinationDetection = /// reveal themselves through preserved phase structure, and /// vice versa. Detectors compose both. /// - /// Provenance: primitive from Aaron's differentiable firefly - /// network design, formalized in Amara's 11th courier ferry - /// (`docs/aurora/2026-04-24-amara-temporal-coordination- - /// detection-cartel-graph-influence-surface-11th-ferry.md`, - /// §1 Signal model). Third graduation under the Otto-105 - /// cadence. - let phaseLockingValue (phasesA: double seq) (phasesB: double seq) : double option = + /// Provenance: primitive from the human maintainer's + /// differentiable firefly-network design, formalized in an + /// external AI collaborator's 11th courier ferry (§1 Signal + /// model; ferry content tracked in the Otto-105 operationalize + /// queue, see `memory/MEMORY.md` "Amara's 11th ferry"). Third + /// graduation under the Otto-105 cadence. + /// Shared epsilon floor for phase-difference mean-vector + /// magnitude. Used ONLY by `meanPhaseOffset` + + /// `phaseLockingWithOffset` to decide when the offset + /// (angle of the mean vector) is mathematically + /// undefined. `phaseLockingValue` does not apply any + /// floor — it returns magnitude arbitrarily close to 0 + /// as normal output. Value `1e-12` chosen well below + /// any physically-meaningful PLV magnitude. + let private phasePairEpsilon : double = 1e-12 + + /// Shared single-pass accumulation of sin/cos of phase + /// differences. Returns `None` on empty / mismatched + /// input, otherwise `Some (meanCos, meanSin, n)`. All + /// three phase-pair primitives (`phaseLockingValue`, + /// `meanPhaseOffset`, `phaseLockingWithOffset`) route + /// through this to avoid accumulation-logic drift. + let private meanPhaseDiffVector + (phasesA: double seq) + (phasesB: double seq) + : struct (double * double * int) option = let aArr = Seq.toArray phasesA let bArr = Seq.toArray phasesB if aArr.Length = 0 || aArr.Length <> bArr.Length then None @@ -145,10 +164,102 @@ module TemporalCoordinationDetection = sumCos <- sumCos + cos d sumSin <- sumSin + sin d let n = double aArr.Length - let meanCos = sumCos / n - let meanSin = sumSin / n + Some (struct (sumCos / n, sumSin / n, aArr.Length)) + + let phaseLockingValue (phasesA: double seq) (phasesB: double seq) : double option = + match meanPhaseDiffVector phasesA phasesB with + | None -> None + | Some (struct (meanCos, meanSin, _)) -> Some (sqrt (meanCos * meanCos + meanSin * meanSin)) + /// **Mean phase offset between two phase series** — the + /// argument (angle) of the same mean complex phase- + /// difference vector whose magnitude is the PLV. Returns + /// a value in `[-pi, pi]` (the full `System.Math.Atan2` + /// range, which includes both endpoints under IEEE-754 + /// signed-zero semantics) when defined, or `None` when + /// input sequences are empty, of unequal length, or + /// when the mean vector has effectively zero magnitude + /// (direction is undefined). The epsilon floor + /// (`phasePairEpsilon`, 1e-12) applies ONLY to this + /// offset decision — `phaseLockingValue` does not + /// apply any floor and will return magnitude values + /// arbitrarily close to zero as normal output. + /// + /// Why this complements `phaseLockingValue`: PLV + /// magnitude alone cannot distinguish "same-time + /// locking" (offset near 0) from "anti-phase locking" + /// (offset near +/- pi) or "lead-lag locking" (offset + /// between). Both extremes return magnitude 1.0. Per + /// Amara 18th-ferry correction #6: cartel-detection + /// that relies on "PLV = 1 means synchronized action" + /// misreads anti-phase coordinators as same-time + /// coordinators. Downstream detectors should consume + /// BOTH magnitude (coherence of locking) and offset + /// (nature of locking: in-phase, anti-phase, lead-lag). + /// + /// Computation routes through the shared + /// `meanPhaseDiffVector` helper (single-pass sin/cos + /// accumulation). The `phasePairEpsilon` floor guards + /// `atan2` against reading a spurious argument from a + /// mean vector that is mathematically zero-length + /// (happens when phase differences are uniformly + /// distributed around the unit circle). + /// + /// Provenance: external AI collaborator's 18th + /// courier ferry, Part 2 correction #6 (§"Ten + /// required corrections"). The ferry absorb doc + /// lives under `docs/aurora/` when landed; at primitive- + /// ship time the substance is captured in session + /// memory + the related 19th-ferry DST-audit absorb + /// at `docs/aurora/2026-04-24-amara-dst-audit-deep- + /// research-plus-5-5-corrections-19th-ferry.md`. + /// Complements the original PLV primitive from the + /// 11th ferry without changing its contract. + /// Downstream score vectors (see + /// `Graph.coordinationRiskScore*`) consuming PLV + /// should add a separate offset-based term rather than + /// collapsing both into one scalar. + let meanPhaseOffset (phasesA: double seq) (phasesB: double seq) : double option = + match meanPhaseDiffVector phasesA phasesB with + | None -> None + | Some (struct (meanCos, meanSin, _)) -> + let magnitude = sqrt (meanCos * meanCos + meanSin * meanSin) + if magnitude < phasePairEpsilon then None + else Some (atan2 meanSin meanCos) + + /// **Phase locking + offset together** — returns both the + /// PLV magnitude and the mean phase offset as a single + /// option-tuple, sharing the cos/sin accumulation pass + /// for callers that want both. Returns `None` under the + /// same input conditions as `phaseLockingValue` (empty + /// or length-mismatched series). + /// + /// When the mean complex vector has effectively zero + /// magnitude the offset field is set to `nan` rather + /// than `None`; the magnitude will itself be `< 1e-12` + /// which is the caller's reliable "offset is undefined" + /// signal. This keeps the return type flat for + /// downstream composition. + /// + /// Prefer this over separate `phaseLockingValue` + + /// `meanPhaseOffset` calls when you need both, to avoid + /// traversing the sequences twice. Use the individual + /// primitives when you only need one quantity, to keep + /// call sites honest about what they consume. + let phaseLockingWithOffset + (phasesA: double seq) + (phasesB: double seq) + : struct (double * double) option = + match meanPhaseDiffVector phasesA phasesB with + | None -> None + | Some (struct (meanCos, meanSin, _)) -> + let magnitude = sqrt (meanCos * meanCos + meanSin * meanSin) + let offset = + if magnitude < phasePairEpsilon then nan + else atan2 meanSin meanCos + Some (struct (magnitude, offset)) + /// **Significant lags from a correlation profile.** Returns the /// subset of lags from a `crossCorrelationProfile` where the /// absolute correlation meets or exceeds `threshold`. `None` diff --git a/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs b/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs index 8fc99d6f..efa27e18 100644 --- a/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs +++ b/tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs @@ -181,6 +181,134 @@ let ``phaseLockingValue handles single-element series`` () = TemporalCoordinationDetection.phaseLockingValue a b |> Option.map (fun v -> Math.Round(v, 9)) |> should equal (Some 1.0) + +// ─── meanPhaseOffset ───────── + +[] +let ``meanPhaseOffset of identical phase series is 0`` () = + let phases = [ 0.0; 0.5; 1.0; 1.5; 2.0 ] + TemporalCoordinationDetection.meanPhaseOffset phases phases + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some 0.0) + +[] +let ``meanPhaseOffset with constant pi/4 offset returns -pi/4`` () = + // b = a + pi/4, so phase difference a - b = -pi/4. + // atan2 reads the mean vector angle; the sign is the + // *difference* direction, consistent with PLV's + // a-minus-b convention. + let a = [ 0.0; 0.3; 0.6; 0.9; 1.2 ] + let offset = Math.PI / 4.0 + let b = a |> List.map (fun x -> x + offset) + TemporalCoordinationDetection.meanPhaseOffset a b + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some (Math.Round(-offset, 9))) + +[] +let ``meanPhaseOffset distinguishes anti-phase from in-phase`` () = + // This is the 18th-ferry correction #6 regression test. + // Two series with pi offset have PLV = 1 (perfectly + // locked), but the offset tells us they are ANTI-phase, + // not same-time. Downstream detectors that rely on + // PLV = 1 => synchronized would misclassify this. + let a = [ 0.0; 0.5; 1.0; 1.5 ] + let b = a |> List.map (fun x -> x + Math.PI) + let antiPhaseOffset = + TemporalCoordinationDetection.meanPhaseOffset a b + |> Option.defaultValue 0.0 + // a - b = -pi for every element; atan2(0, -1) = pi + Math.Round(abs antiPhaseOffset, 6) |> should equal (Math.Round(Math.PI, 6)) + + // Same-time locking has offset near 0 — contrast case + let c = a |> List.map id + TemporalCoordinationDetection.meanPhaseOffset a c + |> Option.map (fun v -> Math.Round(v, 9)) + |> should equal (Some 0.0) + +[] +let ``meanPhaseOffset is None when mean vector has zero magnitude`` () = + // Uniformly-distributed phase differences sum to the + // zero vector; direction is undefined, so None. + let n = 360 + let a = [ for _ in 0 .. n - 1 -> 0.0 ] + let b = [ for i in 0 .. n - 1 -> 2.0 * Math.PI * double i / double n ] + TemporalCoordinationDetection.meanPhaseOffset a b + |> should equal (None: double option) + +[] +let ``meanPhaseOffset of empty series is None`` () = + TemporalCoordinationDetection.meanPhaseOffset [] [] + |> should equal (None: double option) + +[] +let ``meanPhaseOffset on mismatched-length series is None`` () = + let a = [ 0.0; 0.5; 1.0 ] + let b = [ 0.0; 0.5 ] + TemporalCoordinationDetection.meanPhaseOffset a b + |> should equal (None: double option) + +// ─── phaseLockingWithOffset ───────── + +[] +let ``phaseLockingWithOffset returns magnitude and offset together`` () = + let a = [ 0.0; 0.3; 0.6; 0.9; 1.2 ] + let offset = Math.PI / 6.0 + let b = a |> List.map (fun x -> x + offset) + match TemporalCoordinationDetection.phaseLockingWithOffset a b with + | Some (struct (magnitude, observedOffset)) -> + Math.Round(magnitude, 9) |> should equal 1.0 + Math.Round(observedOffset, 9) |> should equal (Math.Round(-offset, 9)) + | None -> + failwith "expected Some tuple" + +[] +let ``phaseLockingWithOffset magnitude matches phaseLockingValue`` () = + // Consistency property: the magnitude field must be + // identical (within FP rounding) to the standalone + // phaseLockingValue result. If the two primitives + // disagree, downstream score vectors will silently + // carry inconsistent values depending on which primitive + // the caller happened to invoke. + let a = [ 0.1; 0.4; 0.9; 1.3; 1.7 ] + let b = [ 0.2; 0.5; 0.95; 1.4; 1.8 ] + // Explicit pattern-match + fail on None rather than + // using sentinel -1.0 with Option.defaultValue. Prior + // sentinel form would silently pass the equality + // assertion if BOTH primitives returned None, masking + // a real regression. Per reviewer thread 59WGi9: + // report the actual regression, don't paper over it. + match TemporalCoordinationDetection.phaseLockingWithOffset a b, + TemporalCoordinationDetection.phaseLockingValue a b with + | Some (struct (magnitudeFromPair, _)), Some magnitudeFromPlv -> + Math.Round(magnitudeFromPair, 12) + |> should equal (Math.Round(magnitudeFromPlv, 12)) + | None, _ -> + failwith "phaseLockingWithOffset returned None on valid input" + | _, None -> + failwith "phaseLockingValue returned None on valid input" + +[] +let ``phaseLockingWithOffset flags zero-magnitude with nan offset`` () = + // Zero-magnitude mean vector: magnitude near 0, offset = nan. + // Caller's reliable "offset is undefined" signal is the + // near-zero magnitude, not the nan per se. + let n = 360 + let a = [ for _ in 0 .. n - 1 -> 0.0 ] + let b = [ for i in 0 .. n - 1 -> 2.0 * Math.PI * double i / double n ] + match TemporalCoordinationDetection.phaseLockingWithOffset a b with + | Some (struct (magnitude, offset)) -> + magnitude |> should (be lessThan) 1e-9 + System.Double.IsNaN(offset) |> should equal true + | None -> + failwith "expected Some tuple with nan offset on zero-magnitude mean" + +[] +let ``phaseLockingWithOffset returns None on empty or mismatched inputs`` () = + TemporalCoordinationDetection.phaseLockingWithOffset [] [] + |> should equal (None: struct (double * double) option) + TemporalCoordinationDetection.phaseLockingWithOffset [ 0.0 ] [ 0.0; 1.0 ] + |> should equal (None: struct (double * double) option) + // ─── significantLags ───────── []