diff --git a/web/scripts/__tests__/check-governance-health.test.ts b/web/scripts/__tests__/check-governance-health.test.ts index 09cbaf55..64b0c0a0 100644 --- a/web/scripts/__tests__/check-governance-health.test.ts +++ b/web/scripts/__tests__/check-governance-health.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { computeGini, percentile } from '../../shared/governance-snapshot'; import type { ActivityData, Comment, @@ -9,7 +10,6 @@ import { buildHealthReport, computeCrossRoleReviewRate, computeDataWindowDays, - computeGini, computeMergeBacklogDepth, computeMergeLatency, computeContestedRate, @@ -20,7 +20,6 @@ import { extractRole, hadQuorumFailure, inferEligibleVoterCount, - percentile, resolveActivityFile, } from '../check-governance-health'; diff --git a/web/scripts/check-governance-health.ts b/web/scripts/check-governance-health.ts index 91a32932..646cb4f0 100644 --- a/web/scripts/check-governance-health.ts +++ b/web/scripts/check-governance-health.ts @@ -21,6 +21,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { computeGini, percentile } from '../shared/governance-snapshot'; import type { ActivityData, Comment, @@ -156,33 +157,6 @@ export function extractRole(login: string): string | null { return match ? match[1] : null; } -/** - * Compute the p-th percentile of a pre-sorted ascending array. - * Returns null for empty arrays. - */ -export function percentile(sorted: number[], p: number): number | null { - if (sorted.length === 0) return null; - const index = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, index)]; -} - -/** - * Compute the Gini coefficient for an array of non-negative values. - * Returns 0 for arrays of length ≤ 1 or all-zero arrays. - */ -export function computeGini(values: number[]): number { - if (values.length <= 1) return 0; - const sorted = [...values].sort((a, b) => a - b); - const n = sorted.length; - const total = sorted.reduce((a, b) => a + b, 0); - if (total === 0) return 0; - let sumOfDiffs = 0; - for (let i = 0; i < n; i++) { - sumOfDiffs += (2 * (i + 1) - n - 1) * sorted[i]; - } - return sumOfDiffs / (n * total); -} - // ────────────────────────────────────────────── // Metric computation // ────────────────────────────────────────────── diff --git a/web/shared/__tests__/governance-snapshot.test.ts b/web/shared/__tests__/governance-snapshot.test.ts index 4474390c..38041603 100644 --- a/web/shared/__tests__/governance-snapshot.test.ts +++ b/web/shared/__tests__/governance-snapshot.test.ts @@ -7,6 +7,8 @@ import { buildGovernanceHistoryArtifact, parseGovernanceHistoryArtifact, serializeGovernanceHistoryForIntegrity, + percentile, + computeGini, type GovernanceSnapshot, } from '../governance-snapshot'; import type { ActivityData, Proposal, AgentStats } from '../types'; @@ -356,3 +358,57 @@ describe('governance history artifact', () => { expect(decoded.schemaVersion).toBe(GOVERNANCE_HISTORY_SCHEMA_VERSION); }); }); + +// ────────────────────────────────────────────── +// percentile +// ────────────────────────────────────────────── + +describe('percentile', () => { + it('returns null for empty array', () => { + expect(percentile([], 50)).toBeNull(); + }); + + it('returns single element for any percentile', () => { + expect(percentile([100], 50)).toBe(100); + expect(percentile([100], 95)).toBe(100); + }); + + it('computes median of sorted array', () => { + expect(percentile([10, 20, 30, 40, 50], 50)).toBe(30); + }); + + it('computes p95 of sorted array', () => { + const sorted = [1, 2, 3, 4, 5, 6, 7, 8, 9, 100]; + expect(percentile(sorted, 95)).toBe(100); + }); +}); + +// ────────────────────────────────────────────── +// computeGini +// ────────────────────────────────────────────── + +describe('computeGini', () => { + it('returns 0 for empty or single-element array', () => { + expect(computeGini([])).toBe(0); + expect(computeGini([10])).toBe(0); + }); + + it('returns 0 for perfectly equal distribution', () => { + expect(computeGini([5, 5, 5, 5])).toBe(0); + }); + + it('returns 0 for all-zero values', () => { + expect(computeGini([0, 0, 0])).toBe(0); + }); + + it('returns near 1 for maximum concentration', () => { + const gini = computeGini([0, 0, 0, 100]); + expect(gini).toBeGreaterThan(0.7); + }); + + it('returns a value between 0 and 1 for mixed distribution', () => { + const gini = computeGini([10, 20, 30, 40]); + expect(gini).toBeGreaterThan(0); + expect(gini).toBeLessThan(1); + }); +}); diff --git a/web/shared/governance-snapshot.ts b/web/shared/governance-snapshot.ts index 97e06ae8..2a52e916 100644 --- a/web/shared/governance-snapshot.ts +++ b/web/shared/governance-snapshot.ts @@ -432,6 +432,16 @@ function computeConsensusScore(proposals: Proposal[]): number { return Math.min(25, voteScore + diversityScore + discussionScore); } +/** + * Compute the p-th percentile of a pre-sorted ascending array. + * Returns null for empty arrays. + */ +export function percentile(sorted: number[], p: number): number | null { + if (sorted.length === 0) return null; + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, index)]; +} + /** Gini coefficient for distribution analysis. Returns 0-1. */ export function computeGini(values: number[]): number { if (values.length <= 1) return 0;