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
61 changes: 61 additions & 0 deletions src/Core/Graph.fs
Original file line number Diff line number Diff line change
Expand Up @@ -526,3 +526,64 @@ module Graph =
let modularityShift = qAttacked - qBaseline
Some (alpha * spectralGrowth + beta * modularityShift)
| _ -> None

/// **Robust-z-score variant of coordinationRiskScore.**
///
/// Upgrades the MVP composite from raw linear differences
/// (per PR #328) to robust standardized scores per Amara
/// 17th-ferry correction #4 (robust statistics for
/// adversarial data).
///
/// Formula:
/// ```
/// risk = alpha * Z(λ₁_attacked; baselineLambdas)
/// + beta * Z(Q_attacked; baselineQs)
/// ```
/// where `Z(x; baseline) = (x - median(baseline)) /
/// (1.4826 * MAD(baseline))`.
///
/// Caller provides `baselineLambdas` + `baselineQs` —
/// sequences of metric values computed across many
/// known-null baseline samples. The `double seq` type
/// is materialized once inside `robustZScore` (see
/// RobustStats), so callers may pass arrays, lists,
/// or any `seq` form without re-enumeration cost. The
/// distributions calibrate thresholds from data rather
/// than hard-coding them.
Comment on lines +547 to +552
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This doc claims callers can pass any seq “without re-enumeration cost” because it’s materialized once inside robustZScore, but robustZScore currently still triggers additional Seq.toArray copies via median/mad. Either adjust this doc to match reality, or change robustZScore to truly operate on the single materialized array/span.

Suggested change
/// known-null baseline samples. The `double seq` type
/// is materialized once inside `robustZScore` (see
/// RobustStats), so callers may pass arrays, lists,
/// or any `seq` form without re-enumeration cost. The
/// distributions calibrate thresholds from data rather
/// than hard-coding them.
/// known-null baseline samples. Callers may pass
/// arrays, lists, or any other `seq` form; the
/// baseline distributions are consumed by
/// `robustZScore` to calibrate thresholds from data
/// rather than hard-coding them.

Copilot uses AI. Check for mistakes.
///
/// Returns `None` when any underlying computation is
/// undefined (empty baselines, iteration failure, etc.).
///
/// Future expansion: the full 6-term CoordinationRiskScore
/// from Amara's 17th ferry adds Sync_S + Exclusivity_S +
/// Influence_S terms. This MVP covers λ₁ + Q — the two
/// signals with shipped primitives. Additional terms land
/// as their primitives mature.
///
/// Provenance: external AI collaborator's 17th
/// courier ferry Part 2 correction #4 (robust
/// z-scores for adversarial data) plus the corrected
/// composite-score formula. Eighteenth graduation
/// under the Otto-105 cadence.
Comment on lines +563 to +567
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (codebase convention): New doc text introduces contributor/agent name attribution (“Amara…”, “Otto-105…”, etc.). docs/AGENT-BEST-PRACTICES.md defines an operational standing rule to avoid names in code/docs and use role references instead. Please rewrite these new attribution lines in role-based terms, or move provenance details to the allowed locations (e.g., memory/persona/** / historical docs) to keep code docs stable across contributor turnover.

Copilot uses AI. Check for mistakes.
let coordinationRiskScoreRobust
(alpha: double)
(beta: double)
(eigenTol: double)
(eigenIter: int)
(lpIter: int)
(baselineLambdas: double seq)
(baselineQs: double seq)
(attacked: Graph<'N>)
: double option =
match largestEigenvalue eigenTol eigenIter attacked with
| None -> None
| Some lambdaAttacked ->
let partition = labelPropagation lpIter attacked
match modularityScore partition attacked with
| None -> None
| Some qAttacked ->
match RobustStats.robustZScore baselineLambdas lambdaAttacked,
RobustStats.robustZScore baselineQs qAttacked with
| Some zLambda, Some zQ ->
Some (alpha * zLambda + beta * zQ)
| _ -> None
45 changes: 45 additions & 0 deletions src/Core/RobustStats.fs
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,48 @@ module RobustStats =
let threshold = 3.0 * max d MadFloor
let kept = arr |> Array.filter (fun x -> abs (x - m) <= threshold)
median kept

/// **Robust z-score.** Given a `baseline` distribution
/// and a `measurement`, return
/// `(measurement - median(baseline)) / (1.4826 * MAD(baseline))`.
/// The 1.4826 constant scales MAD to be consistent with
/// the standard deviation of a normal distribution (so
/// robust z-scores are directly comparable to ordinary
/// z-scores when the baseline actually IS normal).
///
/// Returns `None` when the baseline is empty. When
/// MAD collapses to zero (every baseline value
/// identical), `MadFloor` is substituted so the
/// function returns `Some` finite value rather than
/// `None` or infinity — the floor reflects "scale is
/// below epsilon" rather than "scale is undefined."
/// Per Copilot review thread 59VhYb: the earlier doc
/// contradicted the implementation by claiming None
/// on MAD=0; the implementation is the contract.
Comment on lines +118 to +120
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The doc/comment references specific PR review thread IDs (e.g., “Copilot review thread 59VhYb/59VhYq”). Those IDs are not stable project references and will become meaningless over time. Suggest removing the thread IDs and keeping only the technical rationale (or linking to a durable repo artifact like an ADR/issue if needed).

Suggested change
/// Per Copilot review thread 59VhYb: the earlier doc
/// contradicted the implementation by claiming None
/// on MAD=0; the implementation is the contract.
/// Earlier documentation incorrectly claimed `None`
/// on MAD=0; the implementation-defined behavior is
/// the contract.

Copilot uses AI. Check for mistakes.
///
/// Why robust z-scores for adversarial data: ordinary
/// z-scores assume Gaussian baseline; an attacker can
/// poison a ~normal distribution by adding a few outliers
/// that inflate the standard deviation, making subsequent
/// real attacks look "within one sigma" and evade
/// detection. Median+MAD survives ~50% adversarial
/// outliers.
///
/// Provenance: Amara 17th-ferry Part 2 correction #4
/// (robust statistics for adversarial data in
/// CoordinationRiskScore composition).
let robustZScore (baseline: double seq) (measurement: double) : double option =
// Materialize the baseline once. `median` + `mad`
// both need to walk the sequence; re-enumerating
// `double seq` costs O(n) twice AND can yield
// inconsistent results if the seq is lazy/non-
// repeatable (Copilot review thread 59VhYq).
let baselineArr = Seq.toArray baseline
match median baselineArr with
| None -> None
| Some med ->
match mad baselineArr with
| None -> None
| Some m ->
let scale = 1.4826 * max m MadFloor
Some ((measurement - med) / scale)
Comment thread
AceHack marked this conversation as resolved.
Comment on lines +133 to +147
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (performance/correctness-of-comment): robustZScore claims it materializes the baseline once, but it still forces multiple full enumerations/allocations because median and mad each call Seq.toArray internally (and mad calls median, which also copies). Consider adding array-specialized helpers (e.g., medianArray/madArray) and have robustZScore call those so the baseline is actually materialized once, or update the comment if that’s not the intent.

Copilot uses AI. Check for mistakes.
68 changes: 68 additions & 0 deletions tests/Tests.FSharp/Algebra/Graph.Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,71 @@ let ``coordinationRiskScore is near zero when attacked == baseline`` () =
Graph.coordinationRiskScore 0.5 0.5 1e-9 500 50 g g
|> Option.defaultValue nan
abs score |> should (be lessThan) 0.2


// ─── coordinationRiskScoreRobust + RobustStats.robustZScore ─────────

[<Fact>]
let ``robustZScore returns None on empty baseline`` () =
RobustStats.robustZScore [] 1.0 |> should equal (None: double option)

[<Fact>]
let ``robustZScore of measurement equal to baseline median is 0`` () =
// Baseline [1,2,3,4,5]; median = 3; measurement 3 → z = 0
let z = RobustStats.robustZScore [1.0; 2.0; 3.0; 4.0; 5.0] 3.0 |> Option.defaultValue 999.0
abs z |> should (be lessThan) 1e-9

[<Fact>]
let ``robustZScore scales MAD by 1.4826 for Gaussian consistency`` () =
// Baseline [1,2,3,4,5]; median=3; MAD=1; scale = 1.4826.
// Measurement 4: z = (4-3)/1.4826 ≈ 0.674.
let z = RobustStats.robustZScore [1.0; 2.0; 3.0; 4.0; 5.0] 4.0 |> Option.defaultValue 0.0
abs (z - 0.6744763) |> should (be lessThan) 0.001

[<Fact>]
let ``coordinationRiskScoreRobust fires strongly on cartel-injected graph`` () =
// Gather baseline samples: 5 sparse graphs with varying
// small lambdas and modularities. Build each as a slightly
// perturbed 5-node random graph.
let rng = System.Random(42)
let baselineGraphs =
[| for _ in 1 .. 5 ->
[ for _ in 1 .. 5 do
let s = rng.Next(5)
let t = rng.Next(5)
if s <> t then yield (s, t, 1L) ]
|> Graph.fromEdgeSeq |]
let baselineLambdas =
baselineGraphs
|> Array.choose (fun g -> Graph.largestEigenvalue 1e-9 200 g)
let baselineQs =
baselineGraphs
|> Array.choose (fun g ->
let p = Graph.labelPropagation 30 g
Graph.modularityScore p g)
// Now build the attacked graph with K4 cartel.
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 attacked =
[ yield! [(0, 1, 1L); (1, 2, 1L); (3, 4, 1L)]
yield! cartelEdges ]
|> Graph.fromEdgeSeq
let risk =
Graph.coordinationRiskScoreRobust
0.5 0.5 1e-9 500 50
baselineLambdas baselineQs attacked
|> Option.defaultValue 0.0
// Robust score: we expect a clear positive signal when
// lambda and/or Q jumps substantially beyond the baseline
// MAD. With K4 injected, lambda_attacked is much larger
// than any baseline value.
risk |> should (be greaterThan) 1.0

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