From 5e9bd4e05b45fe4a112195984fcfcfe06d319f2b Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Fri, 24 Apr 2026 03:58:53 -0400 Subject: [PATCH] =?UTF-8?q?core:=20Graph.coordinationRiskScore=20=E2=80=94?= =?UTF-8?q?=2014th=20graduation=20(composite=20cartel=20detector)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First full integration of the Graph detection pipeline: combines largestEigenvalue (spectral growth) + labelPropagation (community partition) + modularityScore (partition evaluation) into a single scalar risk score. Surface: Graph.coordinationRiskScore (alpha: double) (beta: double) (eigenTol: double) (eigenIter: int) (lpIter: int) (baseline: Graph<'N>) (attacked: Graph<'N>) : double option Composite formula (MVP): risk = alpha * Δλ₁_rel + beta * ΔQ where: - Δλ₁_rel = (λ₁(attacked) - λ₁(baseline)) / max(λ₁(baseline), eps) - ΔQ = Q(attacked, LP(attacked)) - Q(baseline, LP(baseline)) Both signals fire when a dense subgraph is injected: λ₁ grows because the cartel adjacency has high leading eigenvalue; Q grows because LP finds the cartel as its own community and Newman Q evaluates that partition highly. Weight defaults per Amara 17th-ferry initial priors: - alpha = 0.5 spectral growth - beta = 0.5 modularity shift Tests (3 new, 34 total in GraphTests, all passing): - Empty graphs -> None - Cartel injection -> composite > 1.0 (both signals fire) - attacked == baseline -> composite near 0 (|score| < 0.2) Calibration deferred (Amara Otto-132 Part 2 correction #4 — robust statistics via median + MAD): this MVP uses raw linear weighting over differences. Full CoordinationRiskScore with robust z-scores over baseline null-distribution is a future graduation once baseline-calibration machinery ships. RobustStats.robustAggregate (PR #295) already provides the median-MAD machinery; just needs a calibration harness to use it. 14th graduation under Otto-105 cadence. First full integration ship using 4 Graph primitives composed together (λ₁ + LP + modularity + composer). Build: 0 Warning / 0 Error. Provenance: - Concept: Aaron (firefly network + trivial-cartel-detect) + Amara's composite-score formulations across 12th/13th/14th/ 17th ferries - Implementation: Otto (14th graduation) Composes with: - Graph.largestEigenvalue (PR #321) - Graph.labelPropagation (PR #326) - Graph.modularityScore (PR #324) - RobustStats.robustAggregate (PR #295) — for future robust variant Co-Authored-By: Claude Opus 4.7 --- src/Core/Graph.fs | 78 +++++++++++++++++++++++ tests/Tests.FSharp/Algebra/Graph.Tests.fs | 46 +++++++++++++ 2 files changed, 124 insertions(+) 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