Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Pull request overview
Consolidates Graph cohesion metrics (internal density, exclusivity, conductance) and introduces a new StakeCovariance primitive for windowed stake-delta covariance and “acceleration”, with accompanying tests.
Changes:
- Added
Graph.internalDensity,Graph.exclusivity, andGraph.conductance. - Added
StakeCovariancemodule withwindowedDeltaCovariance,covarianceAcceleration, andaggregateAcceleration. - Expanded
Graph.Tests.fswith new unit tests covering these additions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/Core/Graph.fs |
Adds cohesion metrics and embeds new StakeCovariance APIs. |
tests/Tests.FSharp/Algebra/Graph.Tests.fs |
Adds unit tests for cohesion metrics and StakeCovariance functions. |
| let aggregateAcceleration | ||
| (pairAccelerations: Map<int * int, double>) |
There was a problem hiding this comment.
aggregateAcceleration hard-codes Map<int * int, double>, which makes the StakeCovariance API inconsistent with the rest of Core’s generic node/key handling (e.g., Graph<'N>). Consider making this generic over the key type (e.g., Map<'N * 'N, double> with 'N : comparison) or just accepting a sequence of acceleration values if the keys aren’t used.
| let aggregateAcceleration | |
| (pairAccelerations: Map<int * int, double>) | |
| let aggregateAcceleration<'N when 'N : comparison> | |
| (pairAccelerations: Map<'N * 'N, double>) |
| /// acceleration in pairwise covariance, catching cartels that | ||
| /// coordinate economically even when their graph structure | ||
| /// looks ordinary. | ||
| [<AutoOpen>] |
There was a problem hiding this comment.
Graph.fs now defines a second [<AutoOpen>] module (StakeCovariance). Most Core modules appear to be one concept per file (e.g., src/Core/RobustStats.fs, src/Core/TemporalCoordinationDetection.fs), and auto-opening additional APIs from Graph.fs can unexpectedly pollute Zeta.Core. Consider moving StakeCovariance into its own StakeCovariance.fs (and only marking it AutoOpen if you really want those identifiers imported everywhere).
| [<AutoOpen>] |
| // ─── StakeCovariance ───────── | ||
|
|
||
| open Zeta.Core.StakeCovariance | ||
|
|
||
| [<Fact>] |
There was a problem hiding this comment.
In this test suite, open directives are consistently kept at the top of the file (e.g., Algebra/RobustStats.Tests.fs, Algebra/TemporalCoordinationDetection.Tests.fs). Adding open Zeta.Core.StakeCovariance mid-file is inconsistent; consider moving it to the top-level opens or qualifying the calls instead.
| let allNodes = nodes g | ||
| if subset.Count = allNodes.Count then None | ||
| else |
There was a problem hiding this comment.
P0: conductance treats a subset as “full” based on subset.Count = allNodes.Count, which can return None for non-full subsets that happen to have the same cardinality (e.g., {1;2;4} vs graph nodes {1;2;3}). Compare sets (or intersect with nodes g first) rather than comparing counts.
| /// Pairwise covariance of stake-delta series over the | ||
| /// trailing `windowSize` values. Returns None when either | ||
| /// series has fewer than `windowSize` points or when a | ||
| /// degenerate case (zero variance, insufficient samples) | ||
| /// makes covariance undefined. | ||
| let windowedDeltaCovariance | ||
| (windowSize: int) | ||
| (deltasA: double[]) | ||
| (deltasB: double[]) | ||
| : double option = | ||
| let n = min deltasA.Length deltasB.Length | ||
| if windowSize < 2 || n < windowSize then None | ||
| else | ||
| let start = n - windowSize | ||
| let mutable meanA = 0.0 | ||
| let mutable meanB = 0.0 | ||
| for i in 0 .. windowSize - 1 do | ||
| meanA <- meanA + deltasA.[start + i] | ||
| meanB <- meanB + deltasB.[start + i] | ||
| meanA <- meanA / double windowSize | ||
| meanB <- meanB / double windowSize | ||
| let mutable cov = 0.0 | ||
| for i in 0 .. windowSize - 1 do | ||
| cov <- cov + (deltasA.[start + i] - meanA) * | ||
| (deltasB.[start + i] - meanB) | ||
| Some (cov / double windowSize) |
There was a problem hiding this comment.
windowedDeltaCovariance doc says it returns None for “zero variance / insufficient samples”, but the implementation always returns Some when n >= windowSize (including constant/zero-variance windows, where covariance is well-defined and should be 0.0). Either implement the documented degeneracy checks or update the comment to match the actual behavior (and clarify whether you intend population vs sample covariance, since you divide by windowSize).
| /// trailing `windowSize` values. Returns None when either | ||
| /// series has fewer than `windowSize` points or when a | ||
| /// degenerate case (zero variance, insufficient samples) | ||
| /// makes covariance undefined. | ||
| let windowedDeltaCovariance | ||
| (windowSize: int) | ||
| (deltasA: double[]) | ||
| (deltasB: double[]) | ||
| : double option = | ||
| let n = min deltasA.Length deltasB.Length | ||
| if windowSize < 2 || n < windowSize then None | ||
| else | ||
| let start = n - windowSize | ||
| let mutable meanA = 0.0 | ||
| let mutable meanB = 0.0 | ||
| for i in 0 .. windowSize - 1 do | ||
| meanA <- meanA + deltasA.[start + i] | ||
| meanB <- meanB + deltasB.[start + i] | ||
| meanA <- meanA / double windowSize | ||
| meanB <- meanB / double windowSize | ||
| let mutable cov = 0.0 | ||
| for i in 0 .. windowSize - 1 do | ||
| cov <- cov + (deltasA.[start + i] - meanA) * | ||
| (deltasB.[start + i] - meanB) | ||
| Some (cov / double windowSize) |
There was a problem hiding this comment.
windowedDeltaCovariance uses n = min deltasA.Length deltasB.Length and then takes the trailing window ending at n. If the two series have different lengths, this silently drops the tail of the longer series and can misalign time indices. Consider requiring equal lengths (return None/error otherwise) or explicitly documenting the alignment/truncation semantics.
| /// trailing `windowSize` values. Returns None when either | |
| /// series has fewer than `windowSize` points or when a | |
| /// degenerate case (zero variance, insufficient samples) | |
| /// makes covariance undefined. | |
| let windowedDeltaCovariance | |
| (windowSize: int) | |
| (deltasA: double[]) | |
| (deltasB: double[]) | |
| : double option = | |
| let n = min deltasA.Length deltasB.Length | |
| if windowSize < 2 || n < windowSize then None | |
| else | |
| let start = n - windowSize | |
| let mutable meanA = 0.0 | |
| let mutable meanB = 0.0 | |
| for i in 0 .. windowSize - 1 do | |
| meanA <- meanA + deltasA.[start + i] | |
| meanB <- meanB + deltasB.[start + i] | |
| meanA <- meanA / double windowSize | |
| meanB <- meanB / double windowSize | |
| let mutable cov = 0.0 | |
| for i in 0 .. windowSize - 1 do | |
| cov <- cov + (deltasA.[start + i] - meanA) * | |
| (deltasB.[start + i] - meanB) | |
| Some (cov / double windowSize) | |
| /// trailing `windowSize` values. Returns None when the | |
| /// series lengths differ, when either series has fewer | |
| /// than `windowSize` points, or when a degenerate case | |
| /// (zero variance, insufficient samples) makes covariance | |
| /// undefined. Equal lengths are required so the trailing | |
| /// window is aligned by time index in both series. | |
| let windowedDeltaCovariance | |
| (windowSize: int) | |
| (deltasA: double[]) | |
| (deltasB: double[]) | |
| : double option = | |
| if deltasA.Length <> deltasB.Length then None | |
| else | |
| let n = deltasA.Length | |
| if windowSize < 2 || n < windowSize then None | |
| else | |
| let start = n - windowSize | |
| let mutable meanA = 0.0 | |
| let mutable meanB = 0.0 | |
| for i in 0 .. windowSize - 1 do | |
| meanA <- meanA + deltasA.[start + i] | |
| meanB <- meanB + deltasB.[start + i] | |
| meanA <- meanA / double windowSize | |
| meanB <- meanB / double windowSize | |
| let mutable cov = 0.0 | |
| for i in 0 .. windowSize - 1 do | |
| cov <- cov + (deltasA.[start + i] - meanA) * | |
| (deltasB.[start + i] - meanB) | |
| Some (cov / double windowSize) |
…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>
…-ferry §B + §F + corrections #2 #7 #9 (#342) Research-grade design doc for the Stage-2 rung of Amara's corrected promotion ladder. Specifies: (a) placement under src/Experimental/CartelLab/ (not src/Core/ — that's Stage 4); (b) MetricVector type with PLV magnitude AND offset split (correction #6); (c) INullModelGenerator interface + Preserves/Avoids table columns; (d) IAttackInjector forward-looking interface (Stage 3); (e) Wilson-interval reporting contract with {successes, trials, lowerBound, upperBound} schema (correction #2 — no more "~95% CI ±5%" handwave); (f) RobustZScoreMode with Hybrid fallback (correction #7 — percentile-rank when MAD < epsilon); (g) explicit artifact-output layout under artifacts/ coordination-risk/ with five files + run-manifest.json (correction #9). 6-stage promotion path (0 doc / 1 ADR / 2.a skeleton / 2.b full null-models + first attack / 3 attack suite / 4 Core/NetworkIntegrity / 5 Aurora-KSK) matches Amara's corrected ladder and Otto-105 cadence. Doc-only change; no code, no tests, no workflow, no BACKLOG tail touch (avoids positional-conflict pattern that cost #334 → #341 re-file this session). This is the 7th of 10 18th-ferry operationalizations: - #1/#10 test-classification (#339) - #2 Wilson-interval design specified (this doc) - #6 PLV phase-offset shipped (#340) - #7 MAD=0 Hybrid mode specified (this doc) - #9 artifact layout specified (this doc) - #4 exclusivity already shipped (#331) - #5 modularity relational already shipped (#324) Remaining: Wilson-interval IMPLEMENTATION (waits on #323 + Stage 2.a), MAD=0 Hybrid IMPLEMENTATION (waits on #333 + Stage 2.a), conductance-sign doc (waits on #331), Stage-2.a skeleton itself. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Addresses Amara 18th-ferry correction #6: PLV = 1 can mean anti-phase locking, not same-time synchronization. Downstream detectors that rely on "PLV = 1 => synchronized" misread anti-phase coordinators as same-time coordinators. Two new functions in `TemporalCoordinationDetection`: - `meanPhaseOffset phasesA phasesB : double option` Returns the argument (angle) of the mean complex phase- difference vector whose magnitude is the PLV. Returns None when series are empty, mismatched-length, or when the mean vector has effectively zero magnitude (1e-12 floor) — in which case direction is mathematically undefined. - `phaseLockingWithOffset phasesA phasesB : struct (double * double) option` Returns both magnitude and offset in one sequence pass. Zero-magnitude case: magnitude near 0, offset = nan; near-zero magnitude is the caller's reliable "offset is undefined" signal. Existing `phaseLockingValue` contract unchanged; new primitives are additive. Downstream `Graph.coordinationRiskScore*` and any other detector consuming PLV can now add a separate offset- based term instead of collapsing both into one scalar (Amara's explicit recommendation in correction #6). 8 new xUnit tests covering: - Identical series (offset = 0) - Constant pi/4 offset (observed = -pi/4, a-minus-b convention) - Anti-phase series (magnitude 1, offset = pi) — the correction #6 regression test, contrasted against in-phase (offset 0) with identical magnitude - Uniformly-distributed differences (zero-magnitude => None) - Empty / mismatched-length / single-element edge cases - phaseLockingWithOffset magnitude matches phaseLockingValue (consistency property preventing silent detector divergence) - phaseLockingWithOffset zero-magnitude returns (near-zero, nan) - phaseLockingWithOffset returns None on empty/mismatched All 37 TemporalCoordinationDetection tests pass locally. 0 Warnings / 0 Errors build. 6th of the 10 18th-ferry corrections operationalized this week (after test-classification doc in #339, parser-tech in #338). Remaining: Wilson CIs in CartelToy tests (needs #323 landed), MAD=0 percentile-rank fallback (needs #333 landed), conductance-sign doc (needs #331 landed), artifact-output layout (Stage-2 with calibration harness). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…18th graduation (Amara #4 robust) Two ships consolidated per the 'parallel PRs hit positional conflicts on tail-append' lesson: 1. RobustStats.robustZScore (baseline: double seq) -> (measurement: double) -> double option Returns (measurement - median) / (1.4826 · MAD). The 1.4826 constant scales MAD to be consistent with Gaussian stddev. MadFloor prevents blow-up when every baseline value equal. 2. Graph.coordinationRiskScoreRobust alpha beta eigenTol eigenIter lpIter (baselineLambdas: double seq) (baselineQs: double seq) (attacked: Graph<'N>) -> double option Upgrades coordinationRiskScore (PR #328) from raw linear differences to robust-standardized z-scores per Amara 17th-ferry correction #4. Caller provides baseline metric distributions; Z-scores calibrate thresholds from data. Why robust z-scores: adversarial data isn't normally distributed. An attacker can poison a ~normal distribution by adding a few outliers that inflate stddev, making subsequent real attacks look 'within one sigma'. Median+MAD survives ~50% adversarial outliers. Standard move in robust statistics literature; Amara's correction puts it on the Zeta composite. Tests (5 new; total 39 since main hasn't merged #331/#332 yet): - robustZScore None on empty baseline - robustZScore of measurement = median is 0 - robustZScore scales MAD by 1.4826 for Gaussian consistency (measurement 4 on baseline [1..5] ≈ 0.674) - coordinationRiskScoreRobust fires strongly on K4-injected graph given 5 baseline samples - coordinationRiskScoreRobust returns None on empty baselines BACKLOG rows added this tick per Aaron Otto-139 directives: 1. Signal-processing primitives (FFT + Hilbert) — unblocks Amara correction #5 Option B; Aaron standing-approval 2. F# DSL for entry points + graph-query-language standards compliance (Cypher / GQL / Gremlin / SPARQL / Datalog) 3. LINQ-compatible entry points for C# consumers — pair with F# DSL; two frontends, one algebraic backend 6 of 8 Amara 17th-ferry corrections now shipped or confirmed: Remaining: #6 ADR phrasing (already fine); #7 KSK naming (BACKLOG #318 Max coord pending); #8 SOTA humility (doc-phrasing discipline ongoing). Build: 0 Warning / 0 Error. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…mara #4 robust) + 3 BACKLOG rows (#333) * core: RobustStats.robustZScore + Graph.coordinationRiskScoreRobust — 18th graduation (Amara #4 robust) Two ships consolidated per the 'parallel PRs hit positional conflicts on tail-append' lesson: 1. RobustStats.robustZScore (baseline: double seq) -> (measurement: double) -> double option Returns (measurement - median) / (1.4826 · MAD). The 1.4826 constant scales MAD to be consistent with Gaussian stddev. MadFloor prevents blow-up when every baseline value equal. 2. Graph.coordinationRiskScoreRobust alpha beta eigenTol eigenIter lpIter (baselineLambdas: double seq) (baselineQs: double seq) (attacked: Graph<'N>) -> double option Upgrades coordinationRiskScore (PR #328) from raw linear differences to robust-standardized z-scores per Amara 17th-ferry correction #4. Caller provides baseline metric distributions; Z-scores calibrate thresholds from data. Why robust z-scores: adversarial data isn't normally distributed. An attacker can poison a ~normal distribution by adding a few outliers that inflate stddev, making subsequent real attacks look 'within one sigma'. Median+MAD survives ~50% adversarial outliers. Standard move in robust statistics literature; Amara's correction puts it on the Zeta composite. Tests (5 new; total 39 since main hasn't merged #331/#332 yet): - robustZScore None on empty baseline - robustZScore of measurement = median is 0 - robustZScore scales MAD by 1.4826 for Gaussian consistency (measurement 4 on baseline [1..5] ≈ 0.674) - coordinationRiskScoreRobust fires strongly on K4-injected graph given 5 baseline samples - coordinationRiskScoreRobust returns None on empty baselines BACKLOG rows added this tick per Aaron Otto-139 directives: 1. Signal-processing primitives (FFT + Hilbert) — unblocks Amara correction #5 Option B; Aaron standing-approval 2. F# DSL for entry points + graph-query-language standards compliance (Cypher / GQL / Gremlin / SPARQL / Datalog) 3. LINQ-compatible entry points for C# consumers — pair with F# DSL; two frontends, one algebraic backend 6 of 8 Amara 17th-ferry corrections now shipped or confirmed: Remaining: #6 ADR phrasing (already fine); #7 KSK naming (BACKLOG #318 Max coord pending); #8 SOTA humility (doc-phrasing discipline ongoing). Build: 0 Warning / 0 Error. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#333): 4 review-thread P1/P2s on robustZScore + coordinationRiskScoreRobust Active PR-resolve-loop on #333. 1. Doc/impl contradiction on MAD=0 (thread 59VhYb, P1): RobustStats.robustZScore doc said "returns None when MAD(baseline)=0" but impl uses MadFloor and returns Some finite value. Rewrote doc to match impl: explicit "MadFloor substituted when MAD collapses to zero" — floor reflects "scale is below epsilon" not "undefined." Implementation is the contract. 2. Multi-enumeration of baseline seq (thread 59VhYq, P1): robustZScore previously passed `baseline` to both `median` + `mad` which each call `Seq.toArray`. Expensive AND inconsistent for lazy/non-repeatable sequences (different values between enumerations = undefined behavior). Fixed: `Seq.toArray` once at entry, pass the materialized array to both. O(n) instead of O(2n); stable across lazy sources. 3. Name attribution in Graph.fs doc comment (thread 59VhY5, P1): "Amara 17th-ferry... Otto 18th graduation" → "external AI collaborator's 17th courier ferry... Eighteenth graduation under the Otto-105 cadence." Role-reference convention per AGENT-BEST-PRACTICES code/doc rule. 4. Array-vs-seq terminology (thread 59VhZG, P2): Graph.fs doc said callers "provide arrays" but the API is `double seq`. Rewrote: sequences + noted the materialize-once optimization in robustZScore so callers can pass any seq form without re-enumeration cost. Thread 59VhX9 (P3-label-in-P2-section mismatch) — already resolved on main via PR #341 which landed the signal- processing row correctly labeled "P2 research-grade." No fix needed on this branch. Build: 0 Warning(s) / 0 Error(s). 53 RobustStats + Graph tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Addresses Amara 18th-ferry correction #6: PLV = 1 can mean anti-phase locking, not same-time synchronization. Downstream detectors that rely on "PLV = 1 => synchronized" misread anti-phase coordinators as same-time coordinators. Two new functions in `TemporalCoordinationDetection`: - `meanPhaseOffset phasesA phasesB : double option` Returns the argument (angle) of the mean complex phase- difference vector whose magnitude is the PLV. Returns None when series are empty, mismatched-length, or when the mean vector has effectively zero magnitude (1e-12 floor) — in which case direction is mathematically undefined. - `phaseLockingWithOffset phasesA phasesB : struct (double * double) option` Returns both magnitude and offset in one sequence pass. Zero-magnitude case: magnitude near 0, offset = nan; near-zero magnitude is the caller's reliable "offset is undefined" signal. Existing `phaseLockingValue` contract unchanged; new primitives are additive. Downstream `Graph.coordinationRiskScore*` and any other detector consuming PLV can now add a separate offset- based term instead of collapsing both into one scalar (Amara's explicit recommendation in correction #6). 8 new xUnit tests covering: - Identical series (offset = 0) - Constant pi/4 offset (observed = -pi/4, a-minus-b convention) - Anti-phase series (magnitude 1, offset = pi) — the correction #6 regression test, contrasted against in-phase (offset 0) with identical magnitude - Uniformly-distributed differences (zero-magnitude => None) - Empty / mismatched-length / single-element edge cases - phaseLockingWithOffset magnitude matches phaseLockingValue (consistency property preventing silent detector divergence) - phaseLockingWithOffset zero-magnitude returns (near-zero, nan) - phaseLockingWithOffset returns None on empty/mismatched All 37 TemporalCoordinationDetection tests pass locally. 0 Warnings / 0 Errors build. 6th of the 10 18th-ferry corrections operationalized this week (after test-classification doc in #339, parser-tech in #338). Remaining: Wilson CIs in CartelToy tests (needs #323 landed), MAD=0 percentile-rank fallback (needs #333 landed), conductance-sign doc (needs #331 landed), artifact-output layout (Stage-2 with calibration harness). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…340) * core: PLV mean phase offset — 19th graduation (Amara 18th-ferry #6) Addresses Amara 18th-ferry correction #6: PLV = 1 can mean anti-phase locking, not same-time synchronization. Downstream detectors that rely on "PLV = 1 => synchronized" misread anti-phase coordinators as same-time coordinators. Two new functions in `TemporalCoordinationDetection`: - `meanPhaseOffset phasesA phasesB : double option` Returns the argument (angle) of the mean complex phase- difference vector whose magnitude is the PLV. Returns None when series are empty, mismatched-length, or when the mean vector has effectively zero magnitude (1e-12 floor) — in which case direction is mathematically undefined. - `phaseLockingWithOffset phasesA phasesB : struct (double * double) option` Returns both magnitude and offset in one sequence pass. Zero-magnitude case: magnitude near 0, offset = nan; near-zero magnitude is the caller's reliable "offset is undefined" signal. Existing `phaseLockingValue` contract unchanged; new primitives are additive. Downstream `Graph.coordinationRiskScore*` and any other detector consuming PLV can now add a separate offset- based term instead of collapsing both into one scalar (Amara's explicit recommendation in correction #6). 8 new xUnit tests covering: - Identical series (offset = 0) - Constant pi/4 offset (observed = -pi/4, a-minus-b convention) - Anti-phase series (magnitude 1, offset = pi) — the correction #6 regression test, contrasted against in-phase (offset 0) with identical magnitude - Uniformly-distributed differences (zero-magnitude => None) - Empty / mismatched-length / single-element edge cases - phaseLockingWithOffset magnitude matches phaseLockingValue (consistency property preventing silent detector divergence) - phaseLockingWithOffset zero-magnitude returns (near-zero, nan) - phaseLockingWithOffset returns None on empty/mismatched All 37 TemporalCoordinationDetection tests pass locally. 0 Warnings / 0 Errors build. 6th of the 10 18th-ferry corrections operationalized this week (after test-classification doc in #339, parser-tech in #338). Remaining: Wilson CIs in CartelToy tests (needs #323 landed), MAD=0 percentile-rank fallback (needs #333 landed), conductance-sign doc (needs #331 landed), artifact-output layout (Stage-2 with calibration harness). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#340): refactor shared accumulation + 5 review-thread fixes (Otto-216) Active PR-resolve-loop on #340 (PLV mean phase offset). 1. Sentinel-default in test (thread 59WGi9): replaced Option.defaultValue -1.0 pattern in the phaseLockingWithOffset-magnitude-matches-phaseLockingValue consistency test with explicit pattern-match + fail on None. Sentinel form would silently pass the equality assertion if BOTH primitives returned None, masking regressions. 2. Broken ferry cross-reference path (thread 59WGjn): doc comment referenced docs/aurora/2026-04-24-amara- calibration-ci-hardening-deep-research-plus-5-5- corrections-18th-ferry.md which doesn't exist on main (only 7th / 17th / 19th ferries landed as standalone docs). Rewrote provenance to describe the ferry topically + cross-reference the related 19th- ferry DST audit that IS in the repo. 3. Misleading "same PLV-magnitude floor" wording (thread 59WGj4): doc said meanPhaseOffset's zero-magnitude check uses "the same PLV-magnitude floor" — phaseLockingValue has NO floor (returns values arbitrarily close to 0). Fixed: clarified that the phasePairEpsilon floor applies ONLY to the offset-undefined decision; phaseLockingValue returns magnitude without threshold. 4. Name-attribution in doc comment (thread 59WGkP): "Aaron + Amara 11th ferry" replaced with "the 11th ferry" per factory role-reference convention. Audit- trail surfaces (commit messages, tick-history, memory) retain direct attribution; code/doc comments use role references. 5. Duplicate sin/cos accumulation across 3 functions (thread 59WGkn): extracted private helpers phasePairEpsilon + meanPhaseDiffVector. All three functions (phaseLockingValue, meanPhaseOffset, phaseLockingWithOffset) now route through the shared accumulator. Eliminates drift risk — one function can no longer silently diverge from the others on accumulation or threshold. Build: 0 Warning(s) / 0 Error(s). All 37 TemporalCoordinationDetection tests pass. All 5 threads replied via GraphQL next step. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#340): 2 review threads (stale ferry path + atan2 range) Thread 59Yqkl (P1) — stale provenance reference: The doc cited `docs/aurora/2026-04-24-amara-temporal- coordination-detection-cartel-graph-influence-surface- 11th-ferry.md`, but the 11th ferry has not yet landed under `docs/aurora/` (it's queued in the Otto-105 operationalize cadence; PR #296 is its pending absorb). Replaced with the intent-preserving form: role references ("external AI collaborator's 11th courier ferry") plus a pointer at the MEMORY.md queue entry, so the provenance survives regardless of when the file-path question resolves. Also dropped the direct first-name so this factory-produced doc-comment tracks the name-attribution discipline. Thread 59YqlC (P2) — atan2 range correction: Doc said `(-pi, pi]` but `System.Math.Atan2` is documented as `[-pi, pi]` (both endpoints reachable under IEEE-754 signed-zero semantics: atan2(0, -1) = +pi, atan2(-0, -1) = -pi). Updated the doc to match the implementation. Behaviour unchanged. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…leration — 15th+16th graduation consolidated Consolidates the DIRTY #329 cohesion primitives (Amara 17th-ferry correction #3) with the new StakeCovariance module (Amara correction #4). Content from closed PR #329 (resurrect): - Graph.internalDensity / exclusivity / conductance - Wachs & Kertész 2019 cartel-detection cohesion/exclusivity replacing muddy 'subgraph entropy collapse' NEW content (Amara correction #4): - Zeta.Core.StakeCovariance module with: - windowedDeltaCovariance : int -> double[] -> double[] -> double option Pairwise cov over sliding window of stake-delta series - covarianceAcceleration : double option -> double option -> double option -> double option 2nd-difference of three consecutive cov values (A(t) = C(t) - 2·C(t-1) + C(t-2)) - aggregateAcceleration : Map<int * int, double> -> double option Mean pairwise acceleration over candidate group Why a separate module: StakeCovariance operates on raw stake- delta time series, not Graph edges. Composes WITH Graph via downstream detector that combines graph signals + covariance signals + sync signals into the full CoordinationRiskScore. Addresses Amara's Part 2 correction #4 ambiguity: her Part 1 had 'C(t) = Cov({s_i(t)}, {s_j(t)})' which is undefined at a single timepoint. This ship implements the windowed-delta + 2nd-difference formulation from her correction. Tests (10 new, 44 total in GraphTests, all passing): - internalDensity None on |S|<2 - internalDensity of K3 weight-10 ≈ 10 - exclusivity = 1 for isolated K3 - conductance < 0.1 for well-isolated subset - windowedDeltaCovariance: None on too-small series - windowedDeltaCovariance: high positive for synchronized motion - windowedDeltaCovariance: negative for anti-correlated motion - covarianceAcceleration = 2nd difference of cov series - covarianceAcceleration None when any input missing - aggregateAcceleration averages across pairs 4 of 8 Amara 17th-ferry corrections now shipped (#1 K₃=2 ✓, conductance ✓, #4 windowed stake covariance acceleration ✓). Remaining: #5 event→phase pipeline (future); #4 robust-z-score variant of composite (future); #6 + #7 + #8 doc-phrasing / BACKLOG. Build: 0 Warning / 0 Error. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
913965d to
81994e0
Compare
| /// trailing `windowSize` values. Returns None when either | ||
| /// series has fewer than `windowSize` points or when a | ||
| /// degenerate case (zero variance, insufficient samples) | ||
| /// makes covariance undefined. |
There was a problem hiding this comment.
P2: The doc comment for windowedDeltaCovariance says it returns None for degenerate cases like zero variance, but the implementation always returns Some (often 0.0) whenever windowSize >= 2 and enough samples exist. Either implement the stated degeneracy checks or update the comment to reflect the actual behavior.
| /// trailing `windowSize` values. Returns None when either | |
| /// series has fewer than `windowSize` points or when a | |
| /// degenerate case (zero variance, insufficient samples) | |
| /// makes covariance undefined. | |
| /// trailing `windowSize` values. Returns None only when | |
| /// `windowSize < 2` or when either series has fewer than | |
| /// `windowSize` aligned points. Otherwise returns the | |
| /// trailing-window covariance, which may be `0.0` for | |
| /// constant windows or other zero-covariance inputs. |
| let values = pairAccelerations |> Map.toSeq |> Seq.map snd |> Seq.toArray | ||
| let sum = values |> Array.sum | ||
| Some (sum / double values.Length) |
There was a problem hiding this comment.
P2: aggregateAcceleration materializes the map values into an array (Seq.toArray + Array.sum) just to compute an average. This adds an avoidable allocation; a Map.fold/Seq.fold that tracks (sum, count) would compute the mean in one pass without extra memory.
| let values = pairAccelerations |> Map.toSeq |> Seq.map snd |> Seq.toArray | |
| let sum = values |> Array.sum | |
| Some (sum / double values.Length) | |
| let sum, count = | |
| pairAccelerations | |
| |> Map.fold (fun (sum, count) _ acceleration -> sum + acceleration, count + 1) (0.0, 0) | |
| Some (sum / double count) |
| let (s, t) = entry.Key | ||
| if subset.Contains s && subset.Contains t then | ||
| acc <- acc + double entry.Weight | ||
| let pairs = double size * double (size - 1) | ||
| Some (acc / pairs) |
There was a problem hiding this comment.
P1: internalDensity counts self-loops in the numerator (because it only checks subset.Contains s && subset.Contains t) but the denominator uses |S|(|S|-1) which assumes ordered pairs with s <> t. Since this graph type explicitly allows self-loops, either exclude s = t edges from the numerator or change the denominator to include self-pairs (e.g., |S|^2) so the metric matches its definition.
Consolidates DIRTY #329 (Amara correction #3) with new StakeCovariance module (Amara correction #4).
44 GraphTests passing. 4 of 8 Amara 17th-ferry corrections now shipped.