Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…first detection primitive) First cartel-detection primitive per the Graph ADR (PR #316). Computes approximate lambda_1 (principal eigenvalue of the symmetrized adjacency matrix) via standard power iteration with L2 normalization + Rayleigh quotient. Surface: Graph.largestEigenvalue (tolerance: double) (maxIterations: int) (g: Graph<'N>) : double option Method: - Build adjacency map from edge ZSet (coerce int64 weights to double; include negative weights as signed entries) - Symmetrize: A_sym[i,j] = (A[i,j] + A[j,i]) / 2 - Start with all-ones vector (non-pathological seed; avoids zero-vector trap) - Iterate v <- A_sym * v; v <- v / ||v|| - Stop when |lambda_k - lambda_{k-1}| / (|lambda_k| + eps) < tolerance or hit maxIterations - Return Rayleigh quotient as lambda estimate Cartel-detection use: Sharp jump in lambda_1 between baseline graph and injected- cartel graph indicates a dense subgraph formed. The 11th-ferry / 13th-ferry / 14th-ferry spec treats this as the first trivial-cartel warning signal. Performance note: dense Array2D adjacency for MVP. Suitable for toy simulations (50-500 nodes). For larger graphs, Lanczos- based incremental spectral method is a future graduation. Tests (4 new, 21 total in GraphTests, all passing): - None on empty graph - Symmetric 2-edge (weight 5) graph -> lambda ≈ 5 (exact to 1e-6) - K3 triangle (weight 1) -> lambda ≈ 2 (K_n has lambda_1 = n-1) - Cartel-injection test (the LOAD-BEARING one): baseline sparse 5-node graph vs. baseline + K_4 clique (weight 10). Attacked lambda >= 5x baseline lambda. This is the cartel-detection signal in action. Provenance: - Concept: Aaron (differentiable firefly network; first-order detection signal) - Formalization: Amara (11th ferry signal-model §2 + 13th ferry metrics §2 "lambda_1 growth" + 14th ferry "principal eigenvalue growth" alert row) - Implementation: Otto (10th graduation) Build: 0 Warning / 0 Error. SPOF (per Otto-106): pure function; deterministic output for same input (within floating-point). Caller threshold is the sensitivity SPOF — too low -> false positives, too high -> missed cartels. Mitigation documented: threshold should come from baseline-null-distribution percentile, not hard-coded. Future graduation: null-baseline calibration helper. Toy cartel detector (Amara Otto-122 validation bar) prerequisite: this is the first half. Next graduation: modularityScore + toy harness combining both signals + 90%-detection-across- 1000-FsCheck-seeds property test. Composes with: - src/Core/Graph.fs skeleton (PR #317 merged main) - src/Core/Graph.fs operators (PR #319 pending) - src/Core/RobustStats.fs (PR #295) for outlier-resistant signal combination across many graph-pair comparisons Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4fadb36 to
9c96226
Compare
There was a problem hiding this comment.
Pull request overview
Adds the first spectral cartel-detection primitive to the ZSet-backed Graph<'N> substrate by implementing Graph.largestEigenvalue (λ₁) using power iteration over the symmetrized adjacency matrix, along with correctness and “cartel injection” tests.
Changes:
- Implement
Graph.largestEigenvalue(power iteration + Rayleigh quotient) on symmetrized adjacency. - Add unit tests for empty graph, simple known-spectrum graphs, and a cartel-clique injection signal test.
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.largestEigenvalue implementation and extensive docstring describing the method and intended use. |
tests/Tests.FSharp/Algebra/Graph.Tests.fs |
Adds test coverage for largestEigenvalue, including a load-bearing cartel-injection growth assertion. |
| /// **Performance note:** builds a dense | ||
| /// `IReadOnlyDictionary<'N, Dictionary<'N, double>>` as the | ||
| /// adjacency representation. Suitable for MVP / toy | ||
| /// simulations (50-500 nodes). For larger graphs, a | ||
| /// Lanczos-based incremental spectral method is the next | ||
| /// graduation; documented as future work. |
There was a problem hiding this comment.
The docstring says this function “builds a dense IReadOnlyDictionary<'N, Dictionary<'N, double>>” adjacency representation, but the implementation actually builds dense Array2D matrices (adj and sym). Please update the docstring to match the actual representation (or change the implementation) so readers don’t optimize/extend based on incorrect assumptions.
| /// **Performance note:** builds a dense | |
| /// `IReadOnlyDictionary<'N, Dictionary<'N, double>>` as the | |
| /// adjacency representation. Suitable for MVP / toy | |
| /// simulations (50-500 nodes). For larger graphs, a | |
| /// Lanczos-based incremental spectral method is the next | |
| /// graduation; documented as future work. | |
| /// **Performance note:** builds dense `Array2D<double>` | |
| /// adjacency and symmetric-adjacency matrices for the | |
| /// power iteration. Suitable for MVP / toy simulations | |
| /// (50-500 nodes). For larger graphs, a Lanczos-based | |
| /// incremental spectral method is the next graduation; | |
| /// documented as future work. |
| /// Provenance: concept Aaron; formalization Amara (11th | ||
| /// ferry §2 + 13th ferry §2); implementation Otto (10th | ||
| /// graduation). |
There was a problem hiding this comment.
This provenance/attribution block introduces contributor names in a code comment. Repo convention is to avoid name attribution in code/docs/skills and use role references instead (see docs/AGENT-BEST-PRACTICES.md:284-292). Please rephrase this section to remove personal names.
| if converged then Some lambda | ||
| else if iter >= maxIterations then Some lambda | ||
| else None |
There was a problem hiding this comment.
Return semantics/documentation don’t match: the docstring says the function returns None when it fails to converge within maxIterations, but the implementation returns Some lambda when iter >= maxIterations. Also, the final else None branch is unreachable. Please decide on the contract (return None on non-convergence, or update docs and remove the dead branch / consider returning a richer result indicating convergence).
| let baseline = | ||
| Graph.fromEdgeSeq [ | ||
| (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 attacked = Graph.fromEdgeSeq (List.append [ (1, 2, 1L); (2, 1, 1L); (3, 4, 1L); (4, 3, 1L); (2, 5, 1L); (5, 2, 1L) ] cartelEdges) |
There was a problem hiding this comment.
attacked duplicates the baseline edge list inline instead of reusing baseline (or a shared baselineEdges list). This makes the test harder to maintain and easy to accidentally diverge from the baseline scenario when edits happen.
| let baseline = | |
| Graph.fromEdgeSeq [ | |
| (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 attacked = Graph.fromEdgeSeq (List.append [ (1, 2, 1L); (2, 1, 1L); (3, 4, 1L); (4, 3, 1L); (2, 5, 1L); (5, 2, 1L) ] cartelEdges) | |
| let baselineEdges = | |
| [ | |
| (1, 2, 1L); (2, 1, 1L) | |
| (3, 4, 1L); (4, 3, 1L) | |
| (2, 5, 1L); (5, 2, 1L) | |
| ] | |
| let baseline = Graph.fromEdgeSeq baselineEdges | |
| 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 = Graph.fromEdgeSeq (List.append baselineEdges cartelEdges) |
| // Baseline lambda on sparse 5-node graph is ~1 (max | ||
| // single-edge weight). Attacked lambda should be ~30 | ||
| // (K_4 with weight 10 has lambda_1 = 3*10 = 30, since | ||
| // K_n has lambda_1 = n-1 scaled by weight). |
There was a problem hiding this comment.
This comment claims the baseline λ₁ is “~1 (max single-edge weight)”, but the baseline component with nodes 1-2-5 is a 3-node path whose top eigenvalue is √2 (~1.414) (and the 3-4 edge contributes 1). Please adjust the comment to avoid misleading future readers about expected magnitudes.
| // Baseline lambda on sparse 5-node graph is ~1 (max | |
| // single-edge weight). Attacked lambda should be ~30 | |
| // (K_4 with weight 10 has lambda_1 = 3*10 = 30, since | |
| // K_n has lambda_1 = n-1 scaled by weight). | |
| // Baseline lambda on this sparse 5-node graph is driven by | |
| // the 3-node path 1-2-5, whose top eigenvalue is sqrt(2) | |
| // (~1.414); the separate 3-4 edge contributes 1. Attacked | |
| // lambda should be ~30 (K_4 with weight 10 has lambda_1 = | |
| // 3*10 = 30, since K_n has lambda_1 = n-1 scaled by weight). |
…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>
… detector) (#328) 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 <noreply@anthropic.com>
…ns tracked; 3 already shipped) (#330) * ferry: Amara 17th absorb — Cartel-Lab Implementation Closure + 5.5 Verification (8 corrections tracked) Two-part ferry: Amara's deep-research Implementation Closure for Cartel-Lab + her own GPT-5.5 Thinking verification pass with 8 load-bearing corrections. Otto correction-pass status (all 8 tracked): 1. λ₁(K₃) = 2 — ALREADY CORRECT PR #321 Otto-127 (independent convergence before verification arrived) 2. Modularity relational-not-absolute — ALREADY CORRECT PR #324 Otto-128 (caught mid-tick via hand-calc) 3. Cohesion/Exclusivity/Conductance replace entropy-collapse — SHIPPED PR #329 Otto-135 (3 primitives + 6 tests) 4. Windowed stake covariance acceleration — FUTURE GRADUATION 5. Event-stream → phase pipeline for PLV — FUTURE GRADUATION 6. 'ZSet invertible' → 'deltas support retractions' — ADR ALREADY PHRASED CORRECTLY (PR #316 never claimed full invertibility) 7. KSK 'contract' → 'policy layer' — FILED BACKLOG PR #318 Otto-124 (Max coord pending) 8. SOTA humility — DOC PHRASING (applied in new absorb docs) Amara's proposed 3-PR split NOT adopted (Otto-105 small- graduation cadence; content delivered across 7 ticks instead: PRs #317, #321, #323, #324, #326, #328, #329). Amara's proposed /cartel-lab/ folder NOT adopted (Otto-108 Conway's-Law: single-module-tree until interfaces harden). Current Graph.fs + test-support split works. Aaron's SharderInfoTheoreticTests flake flag (trailing Otto-132 note) filed as BACKLOG PR #327 Otto-133 — unrelated hygiene item. Amara's Otto-136 follow-up note: '#323 conceptually accepted, do not canonicalize until sharder test is seed-locked/ recalibrated'. Acknowledged — #323 lives in tests/Simulation/ already (test-scoped); 'canonicalize' = future promotion to src/Core/NetworkIntegrity/ per Amara's PR #3 split suggestion; that's gated on #327 completion. §33 archive header compliance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * lint: fix line-start PR-number header false-positive in 17th-ferry absorb --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…correction) (#332) Completes the input pipeline for TemporalCoordinationDetection. phaseLockingValue (PR #298): PLV expects phases in radians but didn't prescribe how events become phases. This ship fills the gap. 17th graduation under Otto-105 cadence. Addresses Amara 17th-ferry Part 2 correction #5: 'Without phase construction, PLV is just a word.' Surface (2 pure functions): - PhaseExtraction.epochPhase : double -> double[] -> double[] Periodic-epoch phase. φ(t) = 2π · (t mod period) / period. Suited to consensus-protocol events with fixed cadence (slot duration, heartbeat, epoch boundary). - PhaseExtraction.interEventPhase : double[] -> double[] -> double[] Circular phase between consecutive events. For sample t in [t_k, t_{k+1}), phase = 2π · (t - t_k) / (t_{k+1} - t_k). Suited to irregular event-driven streams. Both return double[] of phase values in [0, 2π) radians. Empty output on degenerate inputs (no exception). eventTimes assumed sorted ascending; samples outside the event range get 0 phase (callers filter to interior if they care). Hilbert-transform analytic-signal approach (Amara's Option B) deferred — needs FFT support which Zeta doesn't currently ship. Future graduation when signal-processing substrate lands. Tests (12, all passing): epochPhase: - t=0 → phase 0 - t=period/2 → phase π - wraps cleanly at period boundary - handles negative sample times correctly - returns empty on invalid period (≤0) or empty samples interEventPhase: - empty on <2 events or empty samples - phase 0 at start of first interval - phase π at midpoint - adapts to varying interval lengths (O(log n) binary search for bracketing interval) - returns 0 before first and after last event (edge cases) Composition with phaseLockingValue: - Two nodes with identical epochPhase period → PLV = 1 (synchronized) - Two nodes with same period but constant offset → PLV = 1 (perfect phase locking at non-zero offset is still locking) This composes the full firefly-synchronization detection pipeline end-to-end for event-driven validator streams: validator event times → PhaseExtraction → phaseLockingValue → temporal-coordination-detection signal 5 of 8 Amara 17th-ferry corrections now shipped: #1 λ₁(K₃)=2 ✓ already correct (PR #321) #2 modularity relational ✓ already correct (PR #324) #3 cohesion/exclusivity/conductance ✓ shipped (PR #331) #4 windowed stake covariance ✓ shipped (PR #331) #5 event-stream → phase pipeline ✓ THIS SHIP Remaining: #4 robust-z-score composite variant (future); #6 ADR phrasing (already correct); #7 KSK naming (BACKLOG #318 awaiting Max coord); #8 SOTA humility (doc-phrasing discipline). Build: 0 Warning / 0 Error. Provenance: - Concept: Aaron firefly-synchronization design - Formalization: Amara 17th-ferry correction #5 with 3-option menu (epoch / Hilbert / circular) - Implementation: Otto (17th graduation; options A + C shipped, Hilbert deferred) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
First cartel-detection primitive per Graph ADR #316. Power iteration + L2 normalize + Rayleigh quotient on symmetrized adjacency.
Load-bearing cartel-injection test: baseline 5-node sparse graph vs. baseline + K_4 clique (weight 10). Attacked lambda ≥ 5x baseline lambda. Detection signal in action.
Test invariants:
21 tests passing. Next: modularity + toy harness.
🤖 Generated with Claude Code