diff --git a/src/Core/Graph.fs b/src/Core/Graph.fs index 64f4ab3c..12227054 100644 --- a/src/Core/Graph.fs +++ b/src/Core/Graph.fs @@ -448,3 +448,81 @@ module Graph = for i in 0 .. n - 1 do result <- Map.add nodeArr.[i] labels.[i] result result + + /// **Coordination risk score (composite).** + /// + /// Combines multiple detection signals into a single + /// scalar risk score for an `attacked` graph relative to + /// a `baseline` graph. Higher scores indicate stronger + /// evidence of coordinated structure that wasn't present + /// in the baseline. + /// + /// Composite formula (MVP): + /// ``` + /// risk = alpha * Δλ₁_rel + beta * ΔQ + /// ``` + /// where: + /// * `Δλ₁_rel = (λ₁(attacked) - λ₁(baseline)) / max(λ₁(baseline), eps)` + /// — relative growth of principal eigenvalue + /// * `ΔQ = Q(attacked, LP(attacked)) - Q(baseline, LP(baseline))` + /// — modularity gain with label-propagation-derived + /// partitions on each graph independently + /// + /// Both signals fire when a dense subgraph (cartel clique) + /// is injected into a previously sparse baseline: + /// `λ₁` grows because the cartel adjacency has a high + /// leading eigenvalue; `Q` grows because LP finds the + /// cartel as its own community and modularity evaluates + /// that partition highly. + /// + /// Returns `None` when any underlying computation is + /// undefined (empty graphs, iteration failure, degenerate + /// cases). Returns `Some score` otherwise. + /// + /// **Calibration note (per Amara Otto-132 Part 2 + /// correction #4 — robust statistics):** this MVP uses + /// simple linear weighting over raw differences. A full + /// `CoordinationRiskScore` (per Amara's 17th-ferry + /// corrected composite) uses robust z-scores + /// `(x - median(baseline)) / (1.4826 * MAD(baseline))` over + /// each metric, combined with tunable weights. That version + /// is a future graduation once baseline-null-distribution + /// calibration machinery ships. + /// + /// **Weight defaults (per Amara 17th-ferry initial priors):** + /// * `alpha = 0.5` — spectral growth half-weight + /// * `beta = 0.5` — modularity-shift half-weight + /// Callers override when composite weighting is tuned + /// against labelled examples. + /// + /// Provenance: 12th + 13th + 14th + 17th-ferry composite + /// score formulations. Otto's 14th graduation — first + /// full integration ship using four Graph primitives + /// (`largestEigenvalue` + `labelPropagation` + + /// `modularityScore` + this composer). + let coordinationRiskScore + (alpha: double) + (beta: double) + (eigenTol: double) + (eigenIter: int) + (lpIter: int) + (baseline: Graph<'N>) + (attacked: Graph<'N>) + : double option = + let lambdaBaseline = largestEigenvalue eigenTol eigenIter baseline + let lambdaAttacked = largestEigenvalue eigenTol eigenIter attacked + match lambdaBaseline, lambdaAttacked with + | Some lb, Some la when lb > 1e-12 || la > 1e-12 -> + let partitionBaseline = labelPropagation lpIter baseline + let partitionAttacked = labelPropagation lpIter attacked + let qBaseline = + modularityScore partitionBaseline baseline + |> Option.defaultValue 0.0 + let qAttacked = + modularityScore partitionAttacked attacked + |> Option.defaultValue 0.0 + let eps = 1e-12 + let spectralGrowth = (la - lb) / (max lb eps) + let modularityShift = qAttacked - qBaseline + Some (alpha * spectralGrowth + beta * modularityShift) + | _ -> None diff --git a/tests/Tests.FSharp/Algebra/Graph.Tests.fs b/tests/Tests.FSharp/Algebra/Graph.Tests.fs index e5ea473c..bbc1e214 100644 --- a/tests/Tests.FSharp/Algebra/Graph.Tests.fs +++ b/tests/Tests.FSharp/Algebra/Graph.Tests.fs @@ -335,3 +335,49 @@ let ``labelPropagation produces partition consumable by modularityScore`` () = let partition = Graph.labelPropagation 50 g let q = Graph.modularityScore partition g |> Option.defaultValue 0.0 q |> should (be greaterThan) 0.3 + + +// ─── coordinationRiskScore (composite) ───────── + +[] +let ``coordinationRiskScore returns None on empty-input pair`` () = + let empty : Graph = Graph.empty + Graph.coordinationRiskScore 0.5 0.5 1e-9 200 50 empty empty + |> should equal (None: double option) + +[] +let ``coordinationRiskScore is high when cartel is injected`` () = + // Baseline: sparse 5-node graph. + // Attacked: baseline + K4 clique among new nodes. + let baselineEdges = [ + (1, 2, 1L); (2, 1, 1L) + (3, 4, 1L); (4, 3, 1L) + (2, 5, 1L); (5, 2, 1L) + ] + let cartelEdges = [ + for s in [6; 7; 8; 9] do + for t in [6; 7; 8; 9] do + if s <> t then yield (s, t, 10L) + ] + let baseline = Graph.fromEdgeSeq baselineEdges + let attacked = Graph.fromEdgeSeq (List.append baselineEdges cartelEdges) + let score = + Graph.coordinationRiskScore 0.5 0.5 1e-9 500 50 baseline attacked + |> Option.defaultValue 0.0 + // Composite should be clearly positive — both signals fire. + score |> should (be greaterThan) 1.0 + +[] +let ``coordinationRiskScore is near zero when attacked == baseline`` () = + // If the "attacked" graph is identical to the baseline, no + // new structure was added; composite should be near zero. + let edges = [ + (1, 2, 1L); (2, 1, 1L) + (3, 4, 1L); (4, 3, 1L) + (2, 5, 1L); (5, 2, 1L) + ] + let g = Graph.fromEdgeSeq edges + let score = + Graph.coordinationRiskScore 0.5 0.5 1e-9 500 50 g g + |> Option.defaultValue nan + abs score |> should (be lessThan) 0.2