diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 6150b2af..1199b560 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4318,6 +4318,19 @@ Landed in task #261 bundle. ## P2 — hygiene refinements (Otto-254/255/256/260/263) +- [ ] **`StakeCovariance` file split** — move the + `StakeCovariance` module out of `src/Core/Graph.fs` + and into its own `src/Core/StakeCovariance.fs`. + Per-concept-per-file convention matches the rest + of `src/Core/` (e.g. `RobustStats.fs`, + `TemporalCoordinationDetection.fs`). PR #331 dropped + `[]` on the module to mitigate the + immediate `Zeta.Core` namespace-pollution concern; + the file split is the cleaner long-term layout. + See PR #331 thread 2 (`PRRT_kwDOSF9kNM59VZ22`) and + `docs/pr-preservation/331-drain-log.md`. + Effort: S refactor. + - [ ] **Roll-forward default (Otto-254) standing reminder** — revert is the exception (credential leak, destructive prod break, PII, maintainer directive); diff --git a/docs/pr-preservation/331-drain-log.md b/docs/pr-preservation/331-drain-log.md new file mode 100644 index 00000000..07ed05f8 --- /dev/null +++ b/docs/pr-preservation/331-drain-log.md @@ -0,0 +1,238 @@ +# PR #331 drain log — Graph cohesion + StakeCovariance windowed acceleration + +PR: +Branch: `feat/graph-cohesion-conductance-plus-windowed-stake-covariance` +Drain session: 2026-04-24 +Thread count at drain start: 9 unresolved + +Full per-thread record with verbatim reviewer text, outcome, +verbatim reply, and resolution commit per the +PR-comment-preservation directive. + +Resolution commit for all FIX outcomes: `59f3397`. + +--- + +## Thread 1 — `src/Core/Graph.fs:727` — `aggregateAcceleration` hardcoded `int * int` + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59VZ1v` + +### Original comment (verbatim) + +> `aggregateAcceleration` hard-codes `Map`, which makes the StakeCovariance API inconsistent with the rest of Core's generic node/key handling (e.g., `Graph<'N>`). Consider making this generic over the key type (e.g., `Map<'N * 'N, double>` with `'N : comparison`) or just accepting a sequence of acceleration values if the keys aren't used. +> ```suggestion +> let aggregateAcceleration<'N when 'N : comparison> +> (pairAccelerations: Map<'N * 'N, double>) +> ``` + +### Outcome + +FIX — signature now `aggregateAcceleration<'N when 'N : comparison> (pairAccelerations: Map<'N * 'N, double>)`. Body also moved to a single-pass `Map.fold` (closes thread 8 on the same function). + +### Reply (verbatim) + +> Fixed in 59f3397. `aggregateAcceleration` is now generic over `'N when 'N : comparison` so the StakeCovariance API matches `Graph<'N>`. The implementation is also a single-pass `Map.fold` now, dropping the intermediate `Seq.toArray` allocation (this also closes the thread 8 P2 on the same function). Resolved. + +--- + +## Thread 2 — `src/Core/Graph.fs:678` — second `[]` module in Graph.fs + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59VZ22` + +### Original comment (verbatim) + +> `Graph.fs` now defines a second `[]` module (`StakeCovariance`). Most Core modules appear to be one concept per file (e.g., `src/Core/RobustStats.fs`, `src/Core/TemporalCoordinationDetection.fs`), and auto-opening additional APIs from `Graph.fs` can unexpectedly pollute `Zeta.Core`. Consider moving `StakeCovariance` into its own `StakeCovariance.fs` (and only marking it `AutoOpen` if you really want those identifiers imported everywhere). + +### Outcome + +NARROW+BACKLOG — dropped `[]` on `StakeCovariance` so it no longer pollutes `Zeta.Core`. Splitting into its own file is the cleaner long-term move and is backlogged. + +### Reply (verbatim) + +> Narrowed in 59f3397: dropped `[]` on the `StakeCovariance` module, so opening `Zeta.Core` no longer auto-imports its identifiers. The test file already opened `Zeta.Core.StakeCovariance` explicitly, so call sites stay clean. The full file split (`src/Core/StakeCovariance.fs`) is backlogged for a follow-up — the no-AutoOpen change closes the immediate Core-pollution concern this thread flagged, while the file move is a structural rearrangement worth its own small PR. Resolved. + +--- + +## Thread 3 — `tests/Tests.FSharp/Algebra/Graph.Tests.fs:500` — mid-file `open` directive + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59VZ3o` + +### Original comment (verbatim) + +> In this test suite, `open` directives are consistently kept at the top of the file (e.g., `Algebra/RobustStats.Tests.fs`, `Algebra/TemporalCoordinationDetection.Tests.fs`). Adding `open Zeta.Core.StakeCovariance` mid-file is inconsistent; consider moving it to the top-level opens or qualifying the calls instead. + +### Outcome + +FIX — relocated `open Zeta.Core.StakeCovariance` to the top of the test file alongside the other opens. + +### Reply (verbatim) + +> Fixed in 59f3397. The `open Zeta.Core.StakeCovariance` directive moved to the top of `Graph.Tests.fs` next to the other opens, matching the convention in `RobustStats.Tests.fs` / `TemporalCoordinationDetection.Tests.fs`. Resolved. + +--- + +## Thread 4 — `src/Core/Graph.fs:637` — `conductance` full-subset detection by count + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59VZ4J` + +### Original comment (verbatim) + +> P0: `conductance` treats a subset as "full" based on `subset.Count = allNodes.Count`, which can return `None` for non-full subsets that happen to have the same cardinality (e.g., `{1;2;4}` vs graph nodes `{1;2;3}`). Compare sets (or intersect with `nodes g` first) rather than comparing counts. + +### Outcome + +FIX — full-subset check now intersects `subset` with `nodes g` and compares `inGraph.Count = allNodes.Count`. A subset like `{1;2;4}` over a graph with `{1;2;3}` no longer trips the early-`None` exit. + +### Reply (verbatim) + +> Fixed in 59f3397. `conductance` now computes `let inGraph = Set.intersect subset allNodes` and tests `inGraph.Count = allNodes.Count` for the full-subset early-exit. The pathological `{1;2;4}` vs `{1;2;3}` case (equal cardinality, distinct sets) now falls through to the cut/volume computation. Doc comment updated to spell out why count equality alone is not set equality. Resolved. + +--- + +## Thread 5 — `src/Core/Graph.fs:706` — `windowedDeltaCovariance` doc says None for zero-variance + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59VZ4g` + +### Original comment (verbatim) + +> `windowedDeltaCovariance` doc says it returns `None` for "zero variance / insufficient samples", but the implementation always returns `Some` when `n >= windowSize` (including constant/zero-variance windows, where covariance is well-defined and should be 0.0). Either implement the documented degeneracy checks or update the comment to match the actual behavior (and clarify whether you intend population vs sample covariance, since you divide by `windowSize`). + +### Outcome + +FIX — kept the well-defined `Some 0.0` semantics for constant / zero-covariance windows (covariance IS defined there, the prior doc was wrong). Doc comment now states the actual `None` conditions (length mismatch, `windowSize < 2`, insufficient samples) and explicitly documents the `windowSize` divisor as population covariance. + +### Reply (verbatim) + +> Fixed in 59f3397. Doc comment rewritten to match the implementation: `None` only when `windowSize < 2`, when the two series have different lengths (see thread 6 fix), or when either series has fewer than `windowSize` points. Constant or zero-covariance windows return `Some 0.0` because covariance is well-defined and zero in those cases, not undefined. Population vs sample is also called out explicitly — the `/ windowSize` divisor is intentional population covariance, treating the window as the population at that time index rather than a sample drawn from a larger one. Resolved. + +--- + +## Thread 6 — `src/Core/Graph.fs:706` — `windowedDeltaCovariance` length mismatch + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59VZ4y` + +### Original comment (verbatim) + +> `windowedDeltaCovariance` uses `n = min deltasA.Length deltasB.Length` and then takes the trailing window ending at `n`. If the two series have different lengths, this silently drops the tail of the longer series and can misalign time indices. Consider requiring equal lengths (return `None`/error otherwise) or explicitly documenting the alignment/truncation semantics. +> ```suggestion +> /// trailing `windowSize` values. Returns None when the +> /// series lengths differ, when either series has fewer +> /// than `windowSize` points, or when a degenerate case +> /// (zero variance, insufficient samples) makes covariance +> /// undefined. Equal lengths are required so the trailing +> /// window is aligned by time index in both series. +> let windowedDeltaCovariance +> (windowSize: int) +> (deltasA: double[]) +> (deltasB: double[]) +> : double option = +> if deltasA.Length <> deltasB.Length then None +> else +> let n = deltasA.Length +> if windowSize < 2 || n < windowSize then None +> else +> let start = n - windowSize +> let mutable meanA = 0.0 +> let mutable meanB = 0.0 +> for i in 0 .. windowSize - 1 do +> meanA <- meanA + deltasA.[start + i] +> meanB <- meanB + deltasB.[start + i] +> meanA <- meanA / double windowSize +> meanB <- meanB / double windowSize +> let mutable cov = 0.0 +> for i in 0 .. windowSize - 1 do +> cov <- cov + (deltasA.[start + i] - meanA) * +> (deltasB.[start + i] - meanB) +> Some (cov / double windowSize) +> ``` + +### Outcome + +FIX — adopted the suggested equal-length precondition (returns `None` on mismatch). Doc comment integrated with the thread 5 / 7 doc fix to describe the actual `None` conditions. + +### Reply (verbatim) + +> Fixed in 59f3397. `windowedDeltaCovariance` now returns `None` when `deltasA.Length <> deltasB.Length` rather than silently truncating the longer series — equal lengths are required so the trailing window aligns by time index in both series. Doc comment updated to call this out explicitly (combined with thread 5 / 7 doc reconciliation in the same commit). Resolved. + +--- + +## Thread 7 — `src/Core/Graph.fs:685` — `windowedDeltaCovariance` doc duplicate + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59adLZ` + +### Original comment (verbatim) + +> P2: The doc comment for `windowedDeltaCovariance` says it returns `None` for degenerate cases like zero variance, but the implementation always returns `Some` (often `0.0`) whenever `windowSize >= 2` and enough samples exist. Either implement the stated degeneracy checks or update the comment to reflect the actual behavior. +> ```suggestion +> /// trailing `windowSize` values. Returns None only when +> /// `windowSize < 2` or when either series has fewer than +> /// `windowSize` aligned points. Otherwise returns the +> /// trailing-window covariance, which may be `0.0` for +> /// constant windows or other zero-covariance inputs. +> ``` + +### Outcome + +FIX — same root cause as thread 5; one doc comment rewrite resolves both. The new doc text incorporates the spirit of the suggested wording plus the equal-length requirement from thread 6 and the population-covariance clarification. + +### Reply (verbatim) + +> Fixed in 59f3397 — same fix as thread 5 (one doc rewrite covers both reviews of the same comment). The new wording captures the points the suggestion raised: `None` only when `windowSize < 2`, lengths differ, or fewer than `windowSize` aligned points; constant / zero-covariance windows return `Some 0.0` because covariance is well-defined there. Resolved. + +--- + +## Thread 8 — `src/Core/Graph.fs:733` — `aggregateAcceleration` allocation + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59adMP` + +### Original comment (verbatim) + +> P2: `aggregateAcceleration` materializes the map values into an array (`Seq.toArray` + `Array.sum`) just to compute an average. This adds an avoidable allocation; a `Map.fold`/`Seq.fold` that tracks `(sum, count)` would compute the mean in one pass without extra memory. +> ```suggestion +> let sum, count = +> pairAccelerations +> |> Map.fold (fun (sum, count) _ acceleration -> sum + acceleration, count + 1) (0.0, 0) +> +> Some (sum / double count) +> ``` + +### Outcome + +FIX — adopted the `Map.fold` single-pass `(sum, count)` accumulator, dropping the intermediate `Seq.toArray` allocation. Combined with the thread 1 generic-key fix in the same edit. + +### Reply (verbatim) + +> Fixed in 59f3397. Switched to the suggested single-pass `Map.fold` accumulator — `(sum, count)` tracked together, no intermediate `Seq.toArray`. The combined fix here also closes thread 1 (`aggregateAcceleration` is now generic over `'N when 'N : comparison`). Resolved. + +--- + +## Thread 9 — `src/Core/Graph.fs:607` — `internalDensity` self-loop / denominator mismatch + +- Reviewer: `copilot-pull-request-reviewer` +- Thread ID: `PRRT_kwDOSF9kNM59adMi` + +### Original comment (verbatim) + +> P1: `internalDensity` counts self-loops in the numerator (because it only checks `subset.Contains s && subset.Contains t`) but the denominator uses `|S|(|S|-1)` which assumes ordered pairs with `s <> t`. Since this graph type explicitly allows self-loops, either exclude `s = t` edges from the numerator or change the denominator to include self-pairs (e.g., `|S|^2`). + +### Outcome + +FIX — chose the exclude-self-loops branch: numerator now requires `s <> t` so it matches the `|S|·(|S|-1)` ordered-distinct-pair denominator. Doc comment updated to call this out. + +### Reply (verbatim) + +> Fixed in 59f3397. `internalDensity` now requires `s <> t` in the inner predicate, so self-loops are excluded from the numerator and the metric matches the `|S|·(|S|-1)` ordered-distinct-pair denominator. Doc comment updated to call out the self-loop exclusion explicitly. Resolved. + +--- + +## Backlog rows owed + +- StakeCovariance file split — move the module out of `src/Core/Graph.fs` into its own `src/Core/StakeCovariance.fs`. Per-concept-per-file convention; closes the structural concern from thread 2 fully (the immediate Core-pollution risk is already mitigated by dropping `[]`). diff --git a/src/Core/Graph.fs b/src/Core/Graph.fs index 0c7b66e9..4d592713 100644 --- a/src/Core/Graph.fs +++ b/src/Core/Graph.fs @@ -587,3 +587,170 @@ module Graph = | Some zLambda, Some zQ -> Some (alpha * zLambda + beta * zQ) | _ -> None + + /// **Internal density of a node subset S.** Ratio of + /// internal edge weight to max ordered-pair count + /// `|S|·(|S|-1)`, which counts ordered distinct pairs + /// only. Self-loops (`s = t`) are excluded from the + /// numerator to keep the metric consistent with that + /// denominator. Returns None when |S| < 2. + let internalDensity (subset: Set<'N>) (g: Graph<'N>) : double option = + let size = subset.Count + if size < 2 then None + else + let mutable acc = 0.0 + let span = g.Edges.AsSpan() + for k in 0 .. span.Length - 1 do + let entry = span.[k] + let (s, t) = entry.Key + if s <> t && subset.Contains s && subset.Contains t then + acc <- acc + double entry.Weight + let pairs = double size * double (size - 1) + Some (acc / pairs) + + /// **Exclusivity of a node subset S.** Internal / total- + /// outgoing weight. Near 1 = cartel isolated. Returns None + /// on empty S or zero outgoing weight. + let exclusivity (subset: Set<'N>) (g: Graph<'N>) : double option = + if subset.Count = 0 then None + else + let mutable internalWeight = 0.0 + let mutable totalWeight = 0.0 + let span = g.Edges.AsSpan() + for k in 0 .. span.Length - 1 do + let entry = span.[k] + let (s, t) = entry.Key + let w = double entry.Weight + if subset.Contains s then + totalWeight <- totalWeight + w + if subset.Contains t then + internalWeight <- internalWeight + w + if totalWeight = 0.0 then None + else Some (internalWeight / totalWeight) + + /// **Conductance of a node subset S.** cut(S, V\S) / + /// min(vol(S), vol(V\S)). Low = tight isolation. Returns + /// None on empty, full (S ⊇ V), or zero-volume degenerate + /// cases. The full-subset check intersects S with the + /// graph's node set rather than comparing cardinalities, + /// because S may contain nodes that don't appear in any + /// edge — count equality alone is not set equality. + let conductance (subset: Set<'N>) (g: Graph<'N>) : double option = + if subset.Count = 0 then None + else + let allNodes = nodes g + let inGraph = Set.intersect subset allNodes + if inGraph.Count = allNodes.Count then None + else + let mutable cut = 0.0 + let mutable volS = 0.0 + let mutable volRest = 0.0 + let span = g.Edges.AsSpan() + for k in 0 .. span.Length - 1 do + let entry = span.[k] + let (s, t) = entry.Key + let w = double entry.Weight + let sIn = subset.Contains s + let tIn = subset.Contains t + if sIn then volS <- volS + w + if tIn then volS <- volS + w + if not sIn then volRest <- volRest + w + if not tIn then volRest <- volRest + w + if sIn <> tIn then cut <- cut + w + let denom = min volS volRest + if denom <= 0.0 then None + else Some (cut / denom) + + +/// **StakeCovariance — windowed pairwise stake-motion +/// covariance + acceleration.** +/// +/// Cross-sectional covariance `C(t) = Cov({s_i(t)}, +/// {s_j(t)})` is undefined at a single timepoint. The +/// well-defined formulation uses the stake-delta series +/// `Δs_i(t) = s_i(t) - s_i(t-1)` and computes covariance over +/// a sliding window: +/// +/// ``` +/// C_ij(t) = Cov_{τ ∈ [t-w+1, t]}(Δs_i(τ), Δs_j(τ)) +/// A_ij(t) = C_ij(t) - 2·C_ij(t-1) + C_ij(t-2) (2nd diff) +/// A_S(t) = mean over pairs (i, j) ⊂ S of A_ij(t) +/// ``` +/// +/// Cartel-detection use: synchronized stake-motion (all-bond +/// or all-unbond simultaneously) produces a sharp positive +/// acceleration in pairwise covariance, catching cartels that +/// coordinate economically even when their graph structure +/// looks ordinary. +module StakeCovariance = + + /// Pairwise (population) covariance of stake-delta series + /// over the trailing `windowSize` values. Divides by + /// `windowSize` (population covariance) — appropriate when + /// the window IS the population for that point in time, not + /// a sample drawn from a larger one. + /// + /// Returns None only when `windowSize < 2`, when the two + /// series have different lengths, or when either series has + /// fewer than `windowSize` points. Equal lengths are + /// required so the trailing window aligns by time index in + /// both series; mismatched lengths are an alignment error, + /// not a silent-truncate. + /// + /// Constant or otherwise zero-covariance windows return + /// `Some 0.0` — covariance is well-defined and zero in + /// those cases, not undefined. + let windowedDeltaCovariance + (windowSize: int) + (deltasA: double[]) + (deltasB: double[]) + : double option = + if deltasA.Length <> deltasB.Length then None + else + let n = deltasA.Length + if windowSize < 2 || n < windowSize then None + else + let start = n - windowSize + let mutable meanA = 0.0 + let mutable meanB = 0.0 + for i in 0 .. windowSize - 1 do + meanA <- meanA + deltasA.[start + i] + meanB <- meanB + deltasB.[start + i] + meanA <- meanA / double windowSize + meanB <- meanB / double windowSize + let mutable cov = 0.0 + for i in 0 .. windowSize - 1 do + cov <- cov + (deltasA.[start + i] - meanA) * + (deltasB.[start + i] - meanB) + Some (cov / double windowSize) + + /// 2nd-difference acceleration `A_ij(t) = C(t) - 2·C(t-1) + C(t-2)` + /// given three consecutive covariance values. Returns None when + /// any input is None (can't compute acceleration across a + /// missing measurement). + let covarianceAcceleration + (cNow: double option) + (cPrev: double option) + (cPrevPrev: double option) + : double option = + match cNow, cPrev, cPrevPrev with + | Some c0, Some c1, Some c2 -> Some (c0 - 2.0 * c1 + c2) + | _ -> None + + /// Aggregate pairwise acceleration across a candidate subset + /// using the symmetric mean `A_S(t) = (2 / |S|(|S|-1)) · Σ_{i`. Returns None when the input + /// map is empty. + let aggregateAcceleration<'N when 'N : comparison> + (pairAccelerations: Map<'N * 'N, double>) + : double option = + if pairAccelerations.IsEmpty then None + else + let sum, count = + pairAccelerations + |> Map.fold + (fun (s, c) _ value -> s + value, c + 1) + (0.0, 0) + Some (sum / double count) diff --git a/tests/Tests.FSharp/Algebra/Graph.Tests.fs b/tests/Tests.FSharp/Algebra/Graph.Tests.fs index 918011f0..bec18452 100644 --- a/tests/Tests.FSharp/Algebra/Graph.Tests.fs +++ b/tests/Tests.FSharp/Algebra/Graph.Tests.fs @@ -3,6 +3,7 @@ module Zeta.Tests.Algebra.GraphTests open FsUnit.Xunit open global.Xunit open Zeta.Core +open Zeta.Core.StakeCovariance // ─── empty + basic accessors ───────── @@ -449,3 +450,94 @@ let ``coordinationRiskScoreRobust returns None when baselines empty`` () = let g = Graph.fromEdgeSeq [ (1, 2, 1L); (2, 1, 1L) ] Graph.coordinationRiskScoreRobust 0.5 0.5 1e-9 200 30 [||] [||] g |> should equal (None: double option) + + +// ─── internalDensity / exclusivity / conductance ───────── + +[] +let ``internalDensity returns None for subset of size < 2`` () = + let g = Graph.fromEdgeSeq [ (1, 2, 1L); (2, 1, 1L) ] + Graph.internalDensity (Set.singleton 1) g |> should equal (None: double option) + +[] +let ``internalDensity of K3 clique is high`` () = + let edges = [ + (1, 2, 10L); (2, 1, 10L) + (2, 3, 10L); (3, 2, 10L) + (3, 1, 10L); (1, 3, 10L) + ] + let g = Graph.fromEdgeSeq edges + let density = + Graph.internalDensity (Set.ofList [1; 2; 3]) g + |> Option.defaultValue 0.0 + abs (density - 10.0) |> should (be lessThan) 1e-9 + +[] +let ``exclusivity is 1 for isolated K3`` () = + let edges = [ + (1, 2, 5L); (2, 1, 5L); (2, 3, 5L); (3, 2, 5L) + (3, 1, 5L); (1, 3, 5L) + ] + let g = Graph.fromEdgeSeq edges + let e = Graph.exclusivity (Set.ofList [1; 2; 3]) g |> Option.defaultValue 0.0 + abs (e - 1.0) |> should (be lessThan) 1e-9 + +[] +let ``conductance is low for well-isolated subset`` () = + let edges = [ + (1, 2, 10L); (2, 1, 10L); (2, 3, 10L); (3, 2, 10L); (3, 1, 10L); (1, 3, 10L) + (4, 5, 10L); (5, 4, 10L); (5, 6, 10L); (6, 5, 10L); (6, 4, 10L); (4, 6, 10L) + (3, 4, 1L); (4, 3, 1L) + ] + let g = Graph.fromEdgeSeq edges + let c = Graph.conductance (Set.ofList [1; 2; 3]) g |> Option.defaultValue nan + c |> should (be lessThan) 0.1 + + +// ─── StakeCovariance ───────── + +[] +let ``windowedDeltaCovariance returns None on too-small series`` () = + windowedDeltaCovariance 5 [| 1.0; 2.0 |] [| 1.0; 2.0 |] + |> should equal (None: double option) + +[] +let ``windowedDeltaCovariance detects synchronized motion`` () = + // Two nodes moving stakes in perfect lockstep should show + // positive covariance near the variance of each. + let a = [| 1.0; -1.0; 1.0; -1.0; 1.0 |] + let b = [| 1.0; -1.0; 1.0; -1.0; 1.0 |] // identical + let cov = windowedDeltaCovariance 5 a b |> Option.defaultValue 0.0 + cov |> should (be greaterThan) 0.5 + +[] +let ``windowedDeltaCovariance detects anti-correlated motion`` () = + // One moving up, other moving down in lockstep: negative + // covariance. + let a = [| 1.0; -1.0; 1.0; -1.0; 1.0 |] + let b = [| -1.0; 1.0; -1.0; 1.0; -1.0 |] + let cov = windowedDeltaCovariance 5 a b |> Option.defaultValue 0.0 + cov |> should (be lessThan) -0.5 + +[] +let ``covarianceAcceleration = 2nd difference of covariance series`` () = + // Covariances 0.0 → 0.5 → 2.0 gives acceleration + // 2.0 - 2*0.5 + 0.0 = 1.0 + let a = covarianceAcceleration (Some 2.0) (Some 0.5) (Some 0.0) + a |> Option.defaultValue 0.0 |> should equal 1.0 + +[] +let ``covarianceAcceleration returns None when any input missing`` () = + covarianceAcceleration (Some 1.0) None (Some 0.0) + |> should equal (None: double option) + +[] +let ``aggregateAcceleration averages across pairs`` () = + let pairs = + Map.ofList [ + ((1, 2), 1.0) + ((1, 3), 3.0) + ((2, 3), 5.0) + ] + let agg = aggregateAcceleration pairs |> Option.defaultValue 0.0 + abs (agg - 3.0) |> should (be lessThan) 1e-9