412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
/**
|
|
* Power Indices for DAO Governance Analysis
|
|
*
|
|
* Algorithms:
|
|
* - Banzhaf Power Index (exact DP, O(n*Q))
|
|
* - Shapley-Shubik Power Index (exact DP, O(n²*Q))
|
|
* - Gini coefficient & Herfindahl-Hirschman Index (HHI)
|
|
*
|
|
* Pure functions — no I/O. Called by trust-engine.ts on 5-min recompute cycle.
|
|
*/
|
|
|
|
import {
|
|
getAggregatedTrustScores,
|
|
listActiveDelegations,
|
|
upsertPowerIndex,
|
|
getPowerIndices as dbGetPowerIndices,
|
|
type DelegationAuthority,
|
|
} from './db.js';
|
|
|
|
// ── Types ──
|
|
|
|
export interface WeightedPlayer {
|
|
did: string;
|
|
weight: number;
|
|
label?: string;
|
|
}
|
|
|
|
export interface PowerIndexResult {
|
|
did: string;
|
|
label?: string;
|
|
weight: number;
|
|
banzhaf: number;
|
|
shapleyShubik: number;
|
|
swingCount: number;
|
|
pivotalCount: number;
|
|
}
|
|
|
|
export interface PowerAnalysis {
|
|
space: string;
|
|
authority: string;
|
|
quota: number;
|
|
totalWeight: number;
|
|
playerCount: number;
|
|
giniCoefficient: number;
|
|
giniTokenWeight: number;
|
|
herfindahlIndex: number;
|
|
results: PowerIndexResult[];
|
|
computedAt: number;
|
|
}
|
|
|
|
// ── Weight scaling ──
|
|
// Algorithms use integer weights for DP arrays. Scale factor trades precision for memory.
|
|
const WEIGHT_SCALE = 1000;
|
|
|
|
/**
|
|
* Banzhaf Power Index via generating-function DP.
|
|
*
|
|
* For each player i, count the number of coalitions S (not including i)
|
|
* where sum(S) < quota but sum(S) + w_i >= quota (i is a swing voter).
|
|
*
|
|
* DP: dp[w] = number of coalitions of the OTHER players with total weight w.
|
|
* Swing count for i = sum of dp[w] for w in [quota - w_i, quota - 1].
|
|
* Normalized Banzhaf = swingCount_i / sum(swingCounts).
|
|
*/
|
|
export function banzhafIndex(players: WeightedPlayer[], quota: number): PowerIndexResult[] {
|
|
const n = players.length;
|
|
if (n === 0) return [];
|
|
|
|
// Integer weights
|
|
const weights = players.map(p => Math.max(1, Math.round(p.weight * WEIGHT_SCALE)));
|
|
const intQuota = Math.round(quota * WEIGHT_SCALE);
|
|
const totalW = weights.reduce((a, b) => a + b, 0);
|
|
|
|
// Build full DP over all players: dp[w] = # coalitions with weight exactly w
|
|
// Using BigInt to avoid floating-point overflow for large coalition counts
|
|
const dp = new Float64Array(totalW + 1);
|
|
dp[0] = 1;
|
|
for (let i = 0; i < n; i++) {
|
|
const wi = weights[i];
|
|
// Traverse right-to-left so each player counted once (subset-sum DP)
|
|
for (let w = totalW; w >= wi; w--) {
|
|
dp[w] += dp[w - wi];
|
|
}
|
|
}
|
|
|
|
// For each player, "remove" them from the DP and count swings
|
|
const results: PowerIndexResult[] = [];
|
|
let totalSwings = 0;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const wi = weights[i];
|
|
|
|
// dp_without_i[w] = dp[w] - dp_without_i[w - wi], built incrementally left-to-right
|
|
// We reuse a temporary array
|
|
const dpWithout = new Float64Array(totalW + 1);
|
|
dpWithout[0] = dp[0]; // = 1
|
|
for (let w = 1; w <= totalW; w++) {
|
|
dpWithout[w] = dp[w] - (w >= wi ? dpWithout[w - wi] : 0);
|
|
}
|
|
|
|
// Count swings: coalitions S (without i) where S < quota but S + wi >= quota
|
|
let swingCount = 0;
|
|
const lo = Math.max(0, intQuota - wi);
|
|
const hi = Math.min(totalW - wi, intQuota - 1);
|
|
for (let w = lo; w <= hi; w++) {
|
|
swingCount += dpWithout[w];
|
|
}
|
|
|
|
totalSwings += swingCount;
|
|
results.push({
|
|
did: players[i].did,
|
|
label: players[i].label,
|
|
weight: players[i].weight,
|
|
banzhaf: 0, // normalized below
|
|
shapleyShubik: 0,
|
|
swingCount: Math.round(swingCount),
|
|
pivotalCount: 0,
|
|
});
|
|
}
|
|
|
|
// Normalize
|
|
if (totalSwings > 0) {
|
|
for (const r of results) {
|
|
r.banzhaf = r.swingCount / totalSwings;
|
|
}
|
|
} else if (n > 0) {
|
|
// Edge case: no swings possible (e.g. single player with 100% weight)
|
|
// All power to the player(s) that meet quota alone
|
|
for (const r of results) {
|
|
const intW = Math.round(r.weight * WEIGHT_SCALE);
|
|
r.banzhaf = intW >= intQuota ? 1 / results.filter(x => Math.round(x.weight * WEIGHT_SCALE) >= intQuota).length : 0;
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Shapley-Shubik Power Index via DP.
|
|
*
|
|
* For a weighted voting game, the Shapley-Shubik index of player i is the
|
|
* fraction of all n! permutations where i is the pivotal voter (the one
|
|
* whose addition first makes the coalition winning).
|
|
*
|
|
* DP approach: Let f(k, w) = number of orderings of k players (from the set
|
|
* excluding i) that sum to weight w. Then player i is pivotal when the weight
|
|
* of the players before them is in [quota - w_i, quota - 1], and there are
|
|
* k players before them. The number of such permutations is
|
|
* f(k, w) * k! * (n-1-k)! for the specific position (k+1) in the ordering,
|
|
* but we sum over all k.
|
|
*
|
|
* Actually, we use: f(k, w) = # of SIZE-k ORDERED sequences (permutations of
|
|
* k players from the others) summing to w. Pivotal count for i =
|
|
* sum over w in [q-wi, q-1] of sum over k of f(k, w) * (n-1-k)!
|
|
*/
|
|
export function shapleyShubikIndex(players: WeightedPlayer[], quota: number): PowerIndexResult[] {
|
|
const n = players.length;
|
|
if (n === 0) return [];
|
|
|
|
const weights = players.map(p => Math.max(1, Math.round(p.weight * WEIGHT_SCALE)));
|
|
const intQuota = Math.round(quota * WEIGHT_SCALE);
|
|
const totalW = weights.reduce((a, b) => a + b, 0);
|
|
|
|
// Precompute factorials (as regular numbers — fine for n ≤ ~170)
|
|
const fact = new Array(n + 1);
|
|
fact[0] = 1;
|
|
for (let i = 1; i <= n; i++) fact[i] = fact[i - 1] * i;
|
|
const nFact = fact[n];
|
|
|
|
const results: PowerIndexResult[] = [];
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const wi = weights[i];
|
|
const others = weights.filter((_, j) => j !== i);
|
|
const m = others.length; // n - 1
|
|
const maxW = totalW - wi;
|
|
|
|
// f[k][w] = # ordered sequences of k players from `others` with total weight w
|
|
// We compute this iteratively. Start with f[0][0] = 1.
|
|
// When adding a new player with weight wj:
|
|
// f'[k][w] = f[k][w] + f[k-1][w - wj] (the new player goes last in the sequence)
|
|
// But since a player can appear at any of the k positions, we need to be careful.
|
|
//
|
|
// Actually, a simpler approach: f[k][w] counts *subsets* of size k with weight w,
|
|
// then multiply by k! to get ordered sequences.
|
|
// subset[k][w] via DP, then pivotal = sum_{k,w} subset[k][w] * k! * (m-k)!
|
|
|
|
// subset DP: subset[k][w] = # subsets of size k from `others` summing to w
|
|
// We use 2D array but keep k <= m, w <= maxW.
|
|
// Optimization: cap maxW at intQuota - 1 since we only need w < intQuota
|
|
const capW = Math.min(maxW, intQuota - 1);
|
|
// subset[k][w] — use flat arrays indexed by k*(capW+1)+w
|
|
const stride = capW + 1;
|
|
const subset = new Float64Array((m + 1) * stride);
|
|
subset[0] = 1; // subset[0][0] = 1
|
|
|
|
for (let j = 0; j < m; j++) {
|
|
const wj = others[j];
|
|
// Traverse k descending, w descending (standard subset-sum with size tracking)
|
|
for (let k = Math.min(j + 1, m); k >= 1; k--) {
|
|
const kOff = k * stride;
|
|
const kPrevOff = (k - 1) * stride;
|
|
for (let w = capW; w >= wj; w--) {
|
|
subset[kOff + w] += subset[kPrevOff + w - wj];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count pivotal permutations
|
|
let pivotal = 0;
|
|
const lo = Math.max(0, intQuota - wi);
|
|
const hi = Math.min(capW, intQuota - 1);
|
|
|
|
for (let k = 0; k <= m; k++) {
|
|
const kOff = k * stride;
|
|
const coeff = fact[k] * fact[m - k]; // k! * (n-1-k)!
|
|
for (let w = lo; w <= hi; w++) {
|
|
pivotal += subset[kOff + w] * coeff;
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
did: players[i].did,
|
|
label: players[i].label,
|
|
weight: players[i].weight,
|
|
banzhaf: 0,
|
|
shapleyShubik: pivotal / nFact,
|
|
swingCount: 0,
|
|
pivotalCount: Math.round(pivotal),
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ── Concentration metrics ──
|
|
|
|
/** Gini coefficient of an array of non-negative values. Returns 0 for empty/uniform, 1 for max inequality. */
|
|
export function giniCoefficient(values: number[]): number {
|
|
const n = values.length;
|
|
if (n <= 1) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
if (sum === 0) return 0;
|
|
let numerator = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
numerator += (2 * (i + 1) - n - 1) * sorted[i];
|
|
}
|
|
return numerator / (n * sum);
|
|
}
|
|
|
|
/** Herfindahl-Hirschman Index: sum of squared market shares. Range [1/n, 1]. */
|
|
export function herfindahlIndex(values: number[]): number {
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
if (sum === 0) return 0;
|
|
return values.reduce((acc, v) => acc + (v / sum) ** 2, 0);
|
|
}
|
|
|
|
// ── Combined computation ──
|
|
|
|
/**
|
|
* Run both Banzhaf and Shapley-Shubik on a set of weighted players.
|
|
* Merges results and computes concentration metrics.
|
|
*/
|
|
export function computePowerIndices(
|
|
players: WeightedPlayer[],
|
|
space: string,
|
|
authority: string,
|
|
quota?: number,
|
|
): PowerAnalysis {
|
|
const totalWeight = players.reduce((a, p) => a + p.weight, 0);
|
|
const q = quota ?? totalWeight * 0.5 + 0.001; // simple majority
|
|
|
|
const banzhafResults = banzhafIndex(players, q);
|
|
const ssResults = shapleyShubikIndex(players, q);
|
|
|
|
// Merge
|
|
const results: PowerIndexResult[] = banzhafResults.map((br, i) => ({
|
|
...br,
|
|
shapleyShubik: ssResults[i]?.shapleyShubik ?? 0,
|
|
pivotalCount: ssResults[i]?.pivotalCount ?? 0,
|
|
}));
|
|
|
|
const banzhafValues = results.map(r => r.banzhaf);
|
|
const weightValues = results.map(r => r.weight);
|
|
|
|
return {
|
|
space,
|
|
authority,
|
|
quota: q,
|
|
totalWeight,
|
|
playerCount: players.length,
|
|
giniCoefficient: giniCoefficient(banzhafValues),
|
|
giniTokenWeight: giniCoefficient(weightValues),
|
|
herfindahlIndex: herfindahlIndex(banzhafValues),
|
|
results,
|
|
computedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
// ── Weight resolution ──
|
|
|
|
/**
|
|
* Resolve voting weights for a space+authority from the delegation graph.
|
|
* For fin-ops: could blend with token balances (future extension).
|
|
*/
|
|
export async function resolveVotingWeights(
|
|
space: string,
|
|
authority: string,
|
|
): Promise<WeightedPlayer[]> {
|
|
const scores = await getAggregatedTrustScores(space, authority);
|
|
if (scores.length === 0) return [];
|
|
|
|
return scores.map(s => ({
|
|
did: s.did,
|
|
weight: s.totalScore,
|
|
}));
|
|
}
|
|
|
|
// ── Top-level entry point for background job ──
|
|
|
|
const POWER_AUTHORITIES: DelegationAuthority[] = ['gov-ops', 'fin-ops', 'dev-ops'];
|
|
|
|
/**
|
|
* Compute and persist power indices for all authorities in a space.
|
|
* Called from trust-engine.ts after trust score recomputation.
|
|
*/
|
|
export async function computeSpacePowerIndices(spaceSlug: string): Promise<number> {
|
|
let totalUpserts = 0;
|
|
|
|
for (const authority of POWER_AUTHORITIES) {
|
|
const players = await resolveVotingWeights(spaceSlug, authority);
|
|
if (players.length < 2) continue; // need ≥2 players for meaningful indices
|
|
|
|
const analysis = computePowerIndices(players, spaceSlug, authority);
|
|
|
|
for (const r of analysis.results) {
|
|
await upsertPowerIndex({
|
|
did: r.did,
|
|
spaceSlug,
|
|
authority,
|
|
rawWeight: r.weight,
|
|
banzhaf: r.banzhaf,
|
|
shapleyShubik: r.shapleyShubik,
|
|
swingCount: r.swingCount,
|
|
pivotalCount: r.pivotalCount,
|
|
giniCoefficient: analysis.giniCoefficient,
|
|
herfindahlIndex: analysis.herfindahlIndex,
|
|
});
|
|
totalUpserts++;
|
|
}
|
|
}
|
|
|
|
return totalUpserts;
|
|
}
|
|
|
|
// ── Coalition simulation ──
|
|
|
|
export interface CoalitionSimulation {
|
|
space: string;
|
|
authority: string;
|
|
coalition: string[];
|
|
coalitionWeight: number;
|
|
totalWeight: number;
|
|
quota: number;
|
|
isWinning: boolean;
|
|
marginalContributions: Array<{ did: string; marginal: number; isSwing: boolean }>;
|
|
}
|
|
|
|
/**
|
|
* Simulate whether a coalition is winning, and each member's marginal contribution.
|
|
*/
|
|
export function simulateCoalition(
|
|
players: WeightedPlayer[],
|
|
coalitionDids: string[],
|
|
space: string,
|
|
authority: string,
|
|
quota?: number,
|
|
): CoalitionSimulation {
|
|
const totalWeight = players.reduce((a, p) => a + p.weight, 0);
|
|
const q = quota ?? totalWeight * 0.5 + 0.001;
|
|
|
|
const playerMap = new Map(players.map(p => [p.did, p]));
|
|
const coalitionPlayers = coalitionDids
|
|
.map(did => playerMap.get(did))
|
|
.filter((p): p is WeightedPlayer => !!p);
|
|
|
|
const coalitionWeight = coalitionPlayers.reduce((a, p) => a + p.weight, 0);
|
|
const isWinning = coalitionWeight >= q;
|
|
|
|
const marginalContributions = coalitionPlayers.map(p => {
|
|
const without = coalitionWeight - p.weight;
|
|
const isSwing = without < q && coalitionWeight >= q;
|
|
return {
|
|
did: p.did,
|
|
marginal: coalitionWeight - without, // = p.weight, but conceptually it's the marginal
|
|
isSwing,
|
|
};
|
|
});
|
|
|
|
return {
|
|
space,
|
|
authority,
|
|
coalition: coalitionDids,
|
|
coalitionWeight,
|
|
totalWeight,
|
|
quota: q,
|
|
isWinning,
|
|
marginalContributions,
|
|
};
|
|
}
|