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
129 changes: 120 additions & 9 deletions src/Core/TemporalCoordinationDetection.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
128 changes: 128 additions & 0 deletions tests/Tests.FSharp/Algebra/TemporalCoordinationDetection.Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────

[<Fact>]
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)

[<Fact>]
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)))

[<Fact>]
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)

[<Fact>]
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)

[<Fact>]
let ``meanPhaseOffset of empty series is None`` () =
TemporalCoordinationDetection.meanPhaseOffset [] []
|> should equal (None: double option)

[<Fact>]
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 ─────────

[<Fact>]
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"

[<Fact>]
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"

[<Fact>]
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"

[<Fact>]
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 ─────────

[<Fact>]
Expand Down
Loading