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
78 changes: 78 additions & 0 deletions src/Core/Graph.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +482 to +490
///
/// **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
Comment on lines +498 to +503
(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)
Comment on lines +518 to +527
| _ -> None
46 changes: 46 additions & 0 deletions tests/Tests.FSharp/Algebra/Graph.Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────────

[<Fact>]
let ``coordinationRiskScore returns None on empty-input pair`` () =
let empty : Graph<int> = Graph.empty
Graph.coordinationRiskScore 0.5 0.5 1e-9 200 50 empty empty
|> should equal (None: double option)

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

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