Skip to content

core: Graph.modularityScore — 11th graduation (Newman modularity)#322

Closed
AceHack wants to merge 1 commit intomainfrom
feat/graph-modularity-score
Closed

core: Graph.modularityScore — 11th graduation (Newman modularity)#322
AceHack wants to merge 1 commit intomainfrom
feat/graph-modularity-score

Conversation

@AceHack
Copy link
Copy Markdown
Member

@AceHack AceHack commented Apr 24, 2026

Second cartel-detection primitive per Graph ADR #316. Newman modularity Q for caller-supplied node partition.

Discovered theoretical calibration reality mid-tick: when cartel clique dominates total edge weight, Q is inherently compressed even for perfect partition. Test comment documents this; future toy detector calibrates vs null-baseline.

22 tests passing. Next: toy cartel detector property test combining eigenvalue + modularity.

🤖 Generated with Claude Code

…community partition)

Second cartel-detection primitive per Graph ADR #316. Computes
Newman's modularity Q for a caller-supplied node partition.

Formula:
  Q = (1 / 2m) * sum over i,j of
       [ A_sym[i,j] - (k_i * k_j) / (2m) ] * delta(c_i, c_j)

Returns Some Q when defined; None for empty graph or all-zero
edge weights.

Partition semantics:
- Nodes in the partition map use their assigned community label.
- Nodes MISSING from the partition are treated as singleton
  groups (each in a unique community). This is the "don't-know
  where this goes" default.

Cartel-detection use:
Given a partition produced by a community detector (Louvain /
Girvan-Newman / spectral clustering — future graduations), Q
jumps sharply when a cartel clique is correctly identified as
its own community. This primitive computes Q GIVEN a partition;
the detector produces the partition.

MVP scope:
Computes Q for caller-supplied partition. Full-fidelity pipeline
needs community detector + null-baseline threshold calibration,
both separate graduations.

Tests (5 new, 22 total in GraphTests, all passing):
- None on empty graph
- Single-community on K3 -> Q = 0 (no partition boundary, no
  structure detected; matches theory)
- Two K3 cliques bridged by one edge, correct 2-community
  partition -> Q > 0.3 (well-separated communities)
- Same graph with WRONG (interleaved) partition -> Q strictly
  lower than correct partition
- Cartel-detection: baseline 5-node sparse graph + K_4 clique
  attack, correct 2-community partition -> Q > 0.05 (detectable
  but modest signal; relaxed from naive 0.3 threshold because
  the cartel's K4 dominates total edge weight, compressing Q
  via the expected-random baseline; this is theoretical reality
  + documented in test comment)

Theoretical calibration note in the commit message and test
comment: when ONE community dominates total edge weight,
modularity is inherently compressed even for perfect partitions.
Future toy cartel detector calibrates thresholds against null-
baseline simulation, not hard-coded values. This was a real
correctness signal discovered mid-tick (initial threshold 0.3
failed; hand-calculated Q = 0.0907 matched; fix = lower
threshold + explanation).

Provenance:
- Concept: 11th ferry community modularity + 13th ferry
  metrics + 14th ferry alert row "Modularity Q jump > 0.1 or
  Q > 0.4"
- Implementation: Otto (11th graduation)

Composes with:
- src/Core/Graph.fs skeleton (#317 merged main)
- src/Core/Graph.largestEigenvalue (#321 pending)
- src/Core/RobustStats.robustAggregate (#295) for combining
  eigenvalue + modularity signals outlier-resistantly

Build: 0 Warning / 0 Error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 24, 2026 07:38
@AceHack AceHack enabled auto-merge (squash) April 24, 2026 07:38
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds Newman modularity (Q) as a second cartel-detection primitive on the ZSet-backed Graph<'N> substrate, plus unit tests to validate basic modularity behavior on small synthetic graphs and a cartel-injection scenario.

Changes:

  • Implement Graph.modularityScore to compute Newman modularity Q for a caller-supplied node partition.
  • Add test coverage for empty graphs, single-community partitions, well-separated communities, and a cartel clique injection scenario.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/Core/Graph.fs Adds Graph.modularityScore implementation and extensive doc comment describing the modularity primitive.
tests/Tests.FSharp/Algebra/Graph.Tests.fs Adds unit tests exercising Graph.modularityScore across several graph/partition scenarios.

Comment thread src/Core/Graph.fs
for i in 0 .. n - 1 do
acc <- acc + k.[i]
acc
if twoM = 0.0 then None
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.

twoM is used as the "2m" normalization constant. Because Graph supports signed weights (retractions), it’s possible for twoM to be negative; the current guard only checks twoM = 0.0, which can yield a negative normalization and surprising Q values. Consider defining modularity only for nonnegative total weight (e.g., return None when twoM <= 0.0 and/or when any symmetrized edge weight is negative), or document the intended signed-weight semantics explicitly.

Suggested change
if twoM = 0.0 then None
if twoM <= 0.0 then None

Copilot uses AI. Check for mistakes.
Comment thread src/Core/Graph.fs
Comment on lines +228 to +273
let idx =
nodeList
|> List.mapi (fun i node -> node, i)
|> Map.ofList
// Symmetrized adjacency A_sym[i,j] = (A[i,j] + A[j,i]) / 2
let adj = Array2D.create n n 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 i = idx.[s]
let j = idx.[t]
adj.[i, j] <- adj.[i, j] + double entry.Weight
let sym = Array2D.create n n 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
sym.[i, j] <- (adj.[i, j] + adj.[j, i]) / 2.0
// Weighted degree k_i = sum_j A_sym[i, j]
let k = Array.create n 0.0
for i in 0 .. n - 1 do
let mutable acc = 0.0
for j in 0 .. n - 1 do
acc <- acc + sym.[i, j]
k.[i] <- acc
// 2m = sum of all degrees (undirected)
let twoM =
let mutable acc = 0.0
for i in 0 .. n - 1 do
acc <- acc + k.[i]
acc
if twoM = 0.0 then None
else
// Community label per node: partition lookup, or
// node-index-based-singleton when missing
let community i =
let node = nodeList.[i]
match Map.tryFind node partition with
| Some c -> c
| None -> -(i + 1) // unique negative = singleton
let mutable q = 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
if community i = community j then
let expected = (k.[i] * k.[j]) / twoM
q <- q + (sym.[i, j] - expected)
Some (q / twoM)
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.

The implementation builds dense n×n adjacency/symmetry matrices and then performs nested i/j loops, making runtime and memory O(n²) regardless of edge count. Since Graph is ZSet-backed and likely sparse, consider a sparse computation (iterate only existing edges and use per-community degree/weight aggregates) to keep this usable for larger graphs.

Suggested change
let idx =
nodeList
|> List.mapi (fun i node -> node, i)
|> Map.ofList
// Symmetrized adjacency A_sym[i,j] = (A[i,j] + A[j,i]) / 2
let adj = Array2D.create n n 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 i = idx.[s]
let j = idx.[t]
adj.[i, j] <- adj.[i, j] + double entry.Weight
let sym = Array2D.create n n 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
sym.[i, j] <- (adj.[i, j] + adj.[j, i]) / 2.0
// Weighted degree k_i = sum_j A_sym[i, j]
let k = Array.create n 0.0
for i in 0 .. n - 1 do
let mutable acc = 0.0
for j in 0 .. n - 1 do
acc <- acc + sym.[i, j]
k.[i] <- acc
// 2m = sum of all degrees (undirected)
let twoM =
let mutable acc = 0.0
for i in 0 .. n - 1 do
acc <- acc + k.[i]
acc
if twoM = 0.0 then None
else
// Community label per node: partition lookup, or
// node-index-based-singleton when missing
let community i =
let node = nodeList.[i]
match Map.tryFind node partition with
| Some c -> c
| None -> -(i + 1) // unique negative = singleton
let mutable q = 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
if community i = community j then
let expected = (k.[i] * k.[j]) / twoM
q <- q + (sym.[i, j] - expected)
Some (q / twoM)
let communityByNode =
nodeList
|> List.mapi (fun i node ->
let community =
match Map.tryFind node partition with
| Some c -> c
| None -> -(i + 1) // unique negative = singleton
node, community)
|> Map.ofList
let addToDictionary
(dict: System.Collections.Generic.Dictionary<'K, double>)
key
delta =
match dict.TryGetValue key with
| true, value -> dict.[key] <- value + delta
| false, _ -> dict.[key] <- delta
let degreeByNode =
System.Collections.Generic.Dictionary<'N, double>()
let internalWeightByCommunity =
System.Collections.Generic.Dictionary<int, double>()
let degreeByCommunity =
System.Collections.Generic.Dictionary<int, double>()
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
// Weighted degree k_i = sum_j A_sym[i, j].
// Under A_sym = (A + A^T) / 2, each directed edge
// contributes w/2 to its source degree and w/2 to
// its target degree. Self-loops still contribute w.
addToDictionary degreeByNode s (w / 2.0)
addToDictionary degreeByNode t (w / 2.0)
let sCommunity = communityByNode.[s]
let tCommunity = communityByNode.[t]
// Sum of A_sym[i, j] over node pairs within a
// community equals the total directed edge weight
// whose endpoints both lie in that community.
if sCommunity = tCommunity then
addToDictionary internalWeightByCommunity sCommunity w
// 2m = sum of all weighted degrees in the symmetrized
// undirected view.
let twoM =
let mutable acc = 0.0
for kvp in degreeByNode do
acc <- acc + kvp.Value
acc
if twoM = 0.0 then None
else
for kvp in degreeByNode do
let community = communityByNode.[kvp.Key]
addToDictionary degreeByCommunity community kvp.Value
let mutable q = 0.0
for kvp in degreeByCommunity do
let community = kvp.Key
let communityDegree = kvp.Value
let internalWeight =
match internalWeightByCommunity.TryGetValue community with
| true, value -> value
| false, _ -> 0.0
let degreeFraction = communityDegree / twoM
q <- q + (internalWeight / twoM) - (degreeFraction * degreeFraction)
Some q

Copilot uses AI. Check for mistakes.
Comment thread src/Core/Graph.fs
Comment on lines +262 to +270
let community i =
let node = nodeList.[i]
match Map.tryFind node partition with
| Some c -> c
| None -> -(i + 1) // unique negative = singleton
let mutable q = 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
if community i = community j then
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.

community i does a Map.tryFind for every (i,j) pair inside the O(n²) loop. Even for moderate n, this adds a lot of repeated work (and allocations/lookup overhead). Precompute an int[]/array of community labels for all nodes once before the nested loop, then compare array entries inside the loop.

Suggested change
let community i =
let node = nodeList.[i]
match Map.tryFind node partition with
| Some c -> c
| None -> -(i + 1) // unique negative = singleton
let mutable q = 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
if community i = community j then
let communities =
Array.init n (fun i ->
let node = nodeList.[i]
match Map.tryFind node partition with
| Some c -> c
| None -> -(i + 1)) // unique negative = singleton
let mutable q = 0.0
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
if communities.[i] = communities.[j] then

Copilot uses AI. Check for mistakes.
Comment thread src/Core/Graph.fs
Comment on lines +198 to +200
/// when the graph is empty or every node is unassigned.
/// Nodes missing from `partition` are treated as singleton
/// groups (each in a unique trivial community).
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.

Docstring says the function returns None when "every node is unassigned", but the implementation treats missing nodes as singleton communities and will still return Some for any graph with nonzero total weight (even if partition is empty). Please either update the doc comment to match the implemented behavior, or change the behavior to actually return None when partition assigns no nodes (and document that choice).

Suggested change
/// when the graph is empty or every node is unassigned.
/// Nodes missing from `partition` are treated as singleton
/// groups (each in a unique trivial community).
/// when the graph is empty or the symmetrized graph has
/// zero total edge weight. Nodes missing from `partition`
/// are treated as singleton groups (each in a unique
/// trivial community), including the case where
/// `partition` is empty.

Copilot uses AI. Check for mistakes.
Comment thread src/Core/Graph.fs
///
/// Provenance: 11th ferry §2 (community modularity) + 13th
/// ferry metrics + 14th ferry alert row "Modularity Q jump
/// > 0.1 or Q > 0.4". Implementation Otto (11th graduation).
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.

This doc comment introduces contributor/agent name attribution ("Implementation Otto …" / "Provenance …"). Repo convention is to avoid names in code/docs and use role references instead (docs/AGENT-BEST-PRACTICES.md:284-290). Please rewrite these lines to remove names and keep the provenance info in a role- or artifact-based form (e.g., ADR/doc IDs only).

Suggested change
/// > 0.1 or Q > 0.4". Implementation Otto (11th graduation).
/// > 0.1 or Q > 0.4". Implementation tracked under the 11th
/// graduation artifacts.

Copilot uses AI. Check for mistakes.
@AceHack
Copy link
Copy Markdown
Member Author

AceHack commented Apr 24, 2026

Superseded by consolidated PR. Content re-applied there.

@AceHack AceHack closed this Apr 24, 2026
auto-merge was automatically disabled April 24, 2026 07:45

Pull request was closed

AceHack added a commit that referenced this pull request Apr 24, 2026
…urrect (supersedes #319 + #322) (#324)

PRs #319 (operator composition) and #322 (modularity) both hit
DIRTY state from positional rebase conflicts — each appended to
Graph.fs tail, and as main grew with PR #321 (largestEigenvalue),
the appends conflicted. Closed both and re-filed consolidated
fresh from main.

Ships (combining #319 + #322 content):

**Operator composition** (5 functions — ADR property 5):
- Graph.map : ('N -> 'M) -> Graph<'N> -> Graph<'M>
- Graph.filter : ('N * 'N -> bool) -> Graph<'N> -> Graph<'N>
- Graph.distinct : Graph<'N> -> Graph<'N>
- Graph.union : Graph<'N> -> Graph<'N> -> Graph<'N>
- Graph.difference : Graph<'N> -> Graph<'N> -> Graph<'N>

Each is 1-2 lines delegating to the corresponding ZSet operator.

**Modularity score** (Newman's Q formula):
- Graph.modularityScore : Map<'N, int> -> Graph<'N> -> double option
- Computes Q over symmetrized adjacency given a partition;
  nodes missing from partition treated as singleton-community

Tests (7 new in this consolidated ship, 28 total in GraphTests,
all passing):
- map relabels nodes
- filter keeps matching edges
- distinct collapses multi-edges + drops anti-edges
- union + difference round-trip restores original
- modularityScore returns None for empty graph
- modularityScore is high (>0.3) for well-separated two-K3
  communities bridged thin
- modularityScore is 0 for single-community K3 (no boundary,
  no structure; matches theory)

Build: 0 Warning / 0 Error.

Counts as the 9th + 11th graduation (originally ships #319 +
#322 that both got DIRTY). Consolidated to unblock the queue
with a single clean merge.

Superseded PRs (closed):
- #319 feat/graph-operator-composition-map-filter-distinct
- #322 feat/graph-modularity-score

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
AceHack added a commit that referenced this pull request Apr 24, 2026
The bar Amara set Otto-122: "Can this detect even a dumb cartel
in a toy simulation?"

Answer: **YES.** 2 property tests, both passing:

1. ``toy cartel detector — 100 seeds, detection rate >= 90%``
   Generates 50-validator baseline + injects 5-node cartel clique
   (weight 10) per seed. Rule: attacked-lambda >= 2.0 *
   baseline-lambda triggers detection. Runs 100 seeds;
   detection rate >= 90% required. Actual run on local machine:
   PASSED.

2. ``toy cartel detector — clean baseline rarely triggers``
   False-positive rate check. Compares two independent baseline
   lambdas; detection rule applied. Allows up to 20% false-
   positive rate (generous upper bound; real deployment uses
   null-baseline calibration per Amara 14th ferry). 100 seeds;
   PASSED.

New code:
- tests/Tests.FSharp/_Support/CartelInjector.fs
  Red-team synthetic cartel generator. TEST-ONLY per Otto-118
  discipline: lives in _Support/, NOT shipped as public API.
  Two functions:
  - buildBaseline (rng, nodeCount, avgDegree) : Graph<int>
  - injectCartel (rng, baseline, cartelSize, weight, nodeCount)
    : Graph<int> * Set<int>
- tests/Tests.FSharp/Simulation/CartelToy.Tests.fs
  The property tests above.

Parameters matching Amara's 15th/16th ferry prescription:
- 50 validators
- 5-node cartel
- avgDegree=3 (sparse baseline)
- cartelWeight=10
- detectionMultiplier=2.0 (attacked-lambda >= 2x baseline)
- 100 seeds (1000-seed scaled-up run is a follow-up bench-
  project; unit-test obligation is 100)

What this proves per Graph ADR (PR #316):
- The Graph substrate (ZSet-backed, retraction-native) compiles
  under real detection workload
- largestEigenvalue (PR #321) produces a reliable cartel signal
  on synthetic data
- The theory-cathedral warning (Amara 15th ferry) is addressed:
  running code detects a dumb cartel at the promised rate

What this does NOT yet prove:
- Real-world cartels (stealthy weights, partial coordination,
  adversarial evasion)
- Full composite detector (adds modularity #322 + covariance)
- Null-baseline threshold calibration (per Amara 14th ferry)
- 1000-seed + adversarial-seed-selection (benchmark project)

These are the next graduations. For now: the substrate works.
Every primitive shipped (RobustStats, crossCorrelation, PLV,
burstAlignment, Veridicality.Provenance/Claim/validate +
antiConsensusGate + CanonicalClaimKey, Graph.addEdge /
removeEdge / ... / largestEigenvalue / modularityScore)
composes cleanly and produces the detection signal it was
designed to produce.

12th graduation under the Otto-105 cadence (counts as the
first INTEGRATION ship — uses primitives from Graph + the
test-support CartelInjector to produce a working detector).

Provenance:
- Design bar: Aaron Otto-121 ("tight in all aspects") +
  Amara Otto-122 ("toy cartel simulation")
- Formalization: Amara 11th/12th/13th/14th ferries
- Implementation: Otto-123 ADR (PR #316) + Otto-124 skeleton
  (PR #317) + Otto-126 operators (PR #319) + Otto-127
  eigenvalue (PR #321) + Otto-129 integration (THIS PR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AceHack added a commit that referenced this pull request Apr 24, 2026
* test: toy cartel detector — Amara Otto-122 validation bar CLEARED

The bar Amara set Otto-122: "Can this detect even a dumb cartel
in a toy simulation?"

Answer: **YES.** 2 property tests, both passing:

1. ``toy cartel detector — 100 seeds, detection rate >= 90%``
   Generates 50-validator baseline + injects 5-node cartel clique
   (weight 10) per seed. Rule: attacked-lambda >= 2.0 *
   baseline-lambda triggers detection. Runs 100 seeds;
   detection rate >= 90% required. Actual run on local machine:
   PASSED.

2. ``toy cartel detector — clean baseline rarely triggers``
   False-positive rate check. Compares two independent baseline
   lambdas; detection rule applied. Allows up to 20% false-
   positive rate (generous upper bound; real deployment uses
   null-baseline calibration per Amara 14th ferry). 100 seeds;
   PASSED.

New code:
- tests/Tests.FSharp/_Support/CartelInjector.fs
  Red-team synthetic cartel generator. TEST-ONLY per Otto-118
  discipline: lives in _Support/, NOT shipped as public API.
  Two functions:
  - buildBaseline (rng, nodeCount, avgDegree) : Graph<int>
  - injectCartel (rng, baseline, cartelSize, weight, nodeCount)
    : Graph<int> * Set<int>
- tests/Tests.FSharp/Simulation/CartelToy.Tests.fs
  The property tests above.

Parameters matching Amara's 15th/16th ferry prescription:
- 50 validators
- 5-node cartel
- avgDegree=3 (sparse baseline)
- cartelWeight=10
- detectionMultiplier=2.0 (attacked-lambda >= 2x baseline)
- 100 seeds (1000-seed scaled-up run is a follow-up bench-
  project; unit-test obligation is 100)

What this proves per Graph ADR (PR #316):
- The Graph substrate (ZSet-backed, retraction-native) compiles
  under real detection workload
- largestEigenvalue (PR #321) produces a reliable cartel signal
  on synthetic data
- The theory-cathedral warning (Amara 15th ferry) is addressed:
  running code detects a dumb cartel at the promised rate

What this does NOT yet prove:
- Real-world cartels (stealthy weights, partial coordination,
  adversarial evasion)
- Full composite detector (adds modularity #322 + covariance)
- Null-baseline threshold calibration (per Amara 14th ferry)
- 1000-seed + adversarial-seed-selection (benchmark project)

These are the next graduations. For now: the substrate works.
Every primitive shipped (RobustStats, crossCorrelation, PLV,
burstAlignment, Veridicality.Provenance/Claim/validate +
antiConsensusGate + CanonicalClaimKey, Graph.addEdge /
removeEdge / ... / largestEigenvalue / modularityScore)
composes cleanly and produces the detection signal it was
designed to produce.

12th graduation under the Otto-105 cadence (counts as the
first INTEGRATION ship — uses primitives from Graph + the
test-support CartelInjector to produce a working detector).

Provenance:
- Design bar: Aaron Otto-121 ("tight in all aspects") +
  Amara Otto-122 ("toy cartel simulation")
- Formalization: Amara 11th/12th/13th/14th ferries
- Implementation: Otto-123 ADR (PR #316) + Otto-124 skeleton
  (PR #317) + Otto-126 operators (PR #319) + Otto-127
  eigenvalue (PR #321) + Otto-129 integration (THIS PR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#323): 3 review threads — docstring accuracy + injectCartel node-set source

Thread 1 (PRRT_kwDOSF9kNM59VAIi, line 7): docstring path corrected
from `tests/_Support/` to `tests/Tests.FSharp/_Support/` — the
actual location of this helper.

Thread 2 (PRRT_kwDOSF9kNM59VAI2, line 27): docstring for
buildBaseline clarified. `Graph.fromEdgeSeq` derives nodes from
edge endpoints, and self-edges are skipped, so `Graph.nodes
baseline` may be a **strict subset** of `0..nodeCount-1`. The
prior phrasing incorrectly implied a contiguous node range.

Thread 3 (PRRT_kwDOSF9kNM59VAJB, line 55): BEHAVIOR fix.
injectCartel now derives the candidate cartel node set from
`Graph.nodes baseline` (the actual node set) rather than
`0..nodeCount-1`. Previously, if a caller ever passed a baseline
whose node set diverged from that index range, the cartel would
inject edges onto non-existent nodes. The `nodeCount` parameter is
retained (now `_nodeCount`) for signature-compatibility with
existing callers in CartelToy.Tests.fs. A `min cartelSize
shuffled.Length` guard prevents Array.take from throwing if
baseline happens to have fewer nodes than requested cartel size.

Build: 0 warnings / 0 errors. Cartel tests: 5 passed / 0 failed.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants