feat(rnetwork): power indices for DAO governance analysis
Banzhaf & Shapley-Shubik power index computation via DP, integrated
into trust engine 5-min cycle. Power tab in rNetwork 3D graph viewer
with animated bar chart, Gini/HHI gauges, and Banzhaf-scaled node
sizes. On-demand computation when DB empty. Left-drag now rotates.
New files:
- src/encryptid/power-indices.ts (pure math: Banzhaf DP, SS DP, Gini, HHI)
- modules/rnetwork/components/folk-power-indices.ts (standalone component)
API: GET /api/power-indices, GET /api/power-indices/:did,
POST /api/power-indices/simulate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b0049ba8f4
commit
97c1b02c58
|
|
@ -144,6 +144,9 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
private ringGuides: any[] = [];
|
private ringGuides: any[] = [];
|
||||||
private demoDelegations: GraphEdge[] = [];
|
private demoDelegations: GraphEdge[] = [];
|
||||||
private showMemberList = false;
|
private showMemberList = false;
|
||||||
|
private powerMode = false;
|
||||||
|
private powerData: { results: any[]; giniCoefficient: number; herfindahlIndex: number; totalWeight: number; playerCount: number } | null = null;
|
||||||
|
private _powerFetchController: AbortController | null = null;
|
||||||
|
|
||||||
// Layers mode state
|
// Layers mode state
|
||||||
private layersMode = false;
|
private layersMode = false;
|
||||||
|
|
@ -203,23 +206,38 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
|
|
||||||
private applyTab(tab: string) {
|
private applyTab(tab: string) {
|
||||||
const wasTrust = this.trustMode;
|
const wasTrust = this.trustMode;
|
||||||
|
const wasPower = this.powerMode;
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "members":
|
case "members":
|
||||||
this.filter = "all";
|
this.filter = "all";
|
||||||
this.trustMode = false;
|
this.trustMode = false;
|
||||||
|
this.powerMode = false;
|
||||||
break;
|
break;
|
||||||
case "trust":
|
case "trust":
|
||||||
this.filter = "all";
|
this.filter = "all";
|
||||||
this.trustMode = true;
|
this.trustMode = true;
|
||||||
|
this.powerMode = false;
|
||||||
if (this.layoutMode !== "rings") {
|
if (this.layoutMode !== "rings") {
|
||||||
this.layoutMode = "rings";
|
this.layoutMode = "rings";
|
||||||
const ringsBtn = this.shadow.getElementById("rings-toggle");
|
const ringsBtn = this.shadow.getElementById("rings-toggle");
|
||||||
if (ringsBtn) ringsBtn.classList.add("active");
|
if (ringsBtn) ringsBtn.classList.add("active");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "power":
|
||||||
|
this.filter = "all";
|
||||||
|
this.trustMode = true; // power mode builds on trust data
|
||||||
|
this.powerMode = true;
|
||||||
|
if (this.layoutMode !== "rings") {
|
||||||
|
this.layoutMode = "rings";
|
||||||
|
const ringsBtn = this.shadow.getElementById("rings-toggle");
|
||||||
|
if (ringsBtn) ringsBtn.classList.add("active");
|
||||||
|
}
|
||||||
|
this.fetchPowerData();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
this.updateAuthorityBar();
|
this.updateAuthorityBar();
|
||||||
|
if (this.powerMode !== wasPower) this.updatePowerPanel();
|
||||||
if (this.trustMode !== wasTrust) {
|
if (this.trustMode !== wasTrust) {
|
||||||
this.loadData();
|
this.loadData();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -353,6 +371,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
this._textSpriteCache.clear();
|
this._textSpriteCache.clear();
|
||||||
this._badgeSpriteCache.clear();
|
this._badgeSpriteCache.clear();
|
||||||
await this.loadData();
|
await this.loadData();
|
||||||
|
if (this.powerMode) this.fetchPowerData();
|
||||||
}
|
}
|
||||||
|
|
||||||
private importGraph(graph: { nodes?: any[]; edges?: any[] }) {
|
private importGraph(graph: { nodes?: any[]; edges?: any[] }) {
|
||||||
|
|
@ -502,6 +521,14 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
if (node.type === "feed") return 15;
|
if (node.type === "feed") return 15;
|
||||||
if (node.type === "company") return 22;
|
if (node.type === "company") return 22;
|
||||||
if (node.type === "space") return 16;
|
if (node.type === "space") return 16;
|
||||||
|
// Power mode: size by Banzhaf index
|
||||||
|
if (this.powerMode && this.powerData?.results) {
|
||||||
|
const pr = this.powerData.results.find((r: any) => r.did === node.id);
|
||||||
|
if (pr) {
|
||||||
|
const maxB = Math.max(...this.powerData.results.map((r: any) => r.banzhaf), 0.01);
|
||||||
|
return 6 + (pr.banzhaf / maxB) * 55;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.trustMode && node.weightAccounting) {
|
if (this.trustMode && node.weightAccounting) {
|
||||||
const acct = node.weightAccounting;
|
const acct = node.weightAccounting;
|
||||||
let ew: number;
|
let ew: number;
|
||||||
|
|
@ -633,6 +660,39 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
font-size: 12px; padding: 2px; position: absolute; top: 8px; right: 8px;
|
font-size: 12px; padding: 2px; position: absolute; top: 8px; right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Power analysis panel */
|
||||||
|
.power-panel {
|
||||||
|
display: none; position: absolute; top: 12px; right: 12px;
|
||||||
|
width: 280px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
||||||
|
border-radius: 10px; padding: 14px; z-index: 7; font-size: 12px;
|
||||||
|
max-height: calc(100% - 24px); overflow-y: auto;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.power-panel.visible { display: block; }
|
||||||
|
.power-header { font-size: 11px; font-weight: 700; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||||||
|
.power-close { background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px; position: absolute; top: 8px; right: 10px; }
|
||||||
|
.power-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.power-metric { background: rgba(255,255,255,0.03); border-radius: 6px; padding: 8px; text-align: center; }
|
||||||
|
.power-metric-val { font-size: 18px; font-weight: 700; }
|
||||||
|
.power-metric-lbl { font-size: 9px; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
|
||||||
|
.power-gauge { height: 3px; background: rgba(255,255,255,0.08); border-radius: 2px; margin-top: 4px; overflow: hidden; }
|
||||||
|
.power-gauge-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease-out; }
|
||||||
|
.power-bars { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.power-bar-row { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 2px 0; border-radius: 4px; }
|
||||||
|
.power-bar-row:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
|
||||||
|
.power-bar-name { width: 72px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--rs-text-secondary, #cbd5e1); text-align: right; flex-shrink: 0; }
|
||||||
|
.power-bar-tracks { flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.power-bar-track { height: 10px; background: rgba(255,255,255,0.03); border-radius: 2px; overflow: hidden; }
|
||||||
|
.power-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease-out; min-width: 2px; font-size: 8px; line-height: 10px; padding-left: 3px; color: rgba(255,255,255,0.7); }
|
||||||
|
.power-bar-fill.weight { background: rgba(148,163,184,0.35); }
|
||||||
|
.power-bar-fill.banzhaf { background: var(--power-color, #a78bfa); opacity: 0.85; }
|
||||||
|
.power-bar-fill.shapley { background: var(--power-color, #a78bfa); opacity: 0.4; }
|
||||||
|
.power-legend { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.power-legend-item { display: flex; align-items: center; gap: 3px; font-size: 9px; color: var(--rs-text-muted); }
|
||||||
|
.power-legend-sw { width: 8px; height: 8px; border-radius: 2px; }
|
||||||
|
.power-section { font-size: 10px; font-weight: 600; color: var(--rs-text-muted); margin: 8px 0 6px; text-transform: uppercase; }
|
||||||
|
.power-loading { text-align: center; padding: 20px; color: var(--rs-text-muted); }
|
||||||
|
|
||||||
.member-list-panel {
|
.member-list-panel {
|
||||||
display: none; width: 260px; flex-shrink: 0;
|
display: none; width: 260px; flex-shrink: 0;
|
||||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
||||||
|
|
@ -925,6 +985,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
<div class="graph-loading" id="graph-loading"><div class="spinner"></div></div>
|
<div class="graph-loading" id="graph-loading"><div class="spinner"></div></div>
|
||||||
<div class="graph-empty hidden" id="graph-empty">No nodes match your current filter.</div>
|
<div class="graph-empty hidden" id="graph-empty">No nodes match your current filter.</div>
|
||||||
<div class="metrics-panel" id="metrics-panel"></div>
|
<div class="metrics-panel" id="metrics-panel"></div>
|
||||||
|
<div class="power-panel" id="power-panel"></div>
|
||||||
<div class="zoom-controls">
|
<div class="zoom-controls">
|
||||||
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
||||||
<span class="zoom-level" id="zoom-level">100%</span>
|
<span class="zoom-level" id="zoom-level">100%</span>
|
||||||
|
|
@ -1300,11 +1361,11 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap controls: LEFT=PAN, RIGHT=ROTATE, MIDDLE=DOLLY
|
// Remap controls: LEFT=ROTATE, RIGHT=PAN, MIDDLE=DOLLY
|
||||||
// THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2
|
// THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2
|
||||||
const controls = graph.controls();
|
const controls = graph.controls();
|
||||||
if (controls) {
|
if (controls) {
|
||||||
controls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: 0 };
|
controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
controls.dampingFactor = 0.12;
|
controls.dampingFactor = 0.12;
|
||||||
controls.zoomSpeed = 2.5; // faster scroll wheel zoom
|
controls.zoomSpeed = 2.5; // faster scroll wheel zoom
|
||||||
|
|
@ -2242,6 +2303,137 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchPowerData() {
|
||||||
|
this._powerFetchController?.abort();
|
||||||
|
this._powerFetchController = new AbortController();
|
||||||
|
const auth = this.authority === "all" ? "gov-ops" : this.authority;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/rnetwork/api/power-indices?space=${encodeURIComponent(this.space)}&authority=${encodeURIComponent(auth)}`,
|
||||||
|
{ signal: this._powerFetchController.signal },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.powerData = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== "AbortError") {
|
||||||
|
console.error("[power] fetch error:", e);
|
||||||
|
this.powerData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updatePowerPanel();
|
||||||
|
// Re-render graph with power-based node sizes
|
||||||
|
if (this.graph) this.updateGraphData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePowerPanel() {
|
||||||
|
const panel = this.shadow.getElementById("power-panel");
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
if (!this.powerMode) {
|
||||||
|
panel.classList.remove("visible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel.classList.add("visible");
|
||||||
|
|
||||||
|
const d = this.powerData;
|
||||||
|
if (!d || !d.results || d.results.length === 0) {
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="power-header">Power Analysis</div>
|
||||||
|
<button class="power-close" id="power-close">×</button>
|
||||||
|
<div class="power-loading">${d ? 'No delegation data for analysis.' : 'Loading power indices...'}</div>
|
||||||
|
`;
|
||||||
|
panel.querySelector("#power-close")?.addEventListener("click", () => panel.classList.remove("visible"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = this.authority === "all" ? "gov-ops" : this.authority;
|
||||||
|
const authColor = AUTHORITY_COLORS[auth] || "#a78bfa";
|
||||||
|
const effectiveN = d.herfindahlIndex > 0 ? (1 / d.herfindahlIndex).toFixed(1) : "—";
|
||||||
|
const giniPct = (d.giniCoefficient * 100).toFixed(0);
|
||||||
|
const hhiPct = (d.herfindahlIndex * 100).toFixed(0);
|
||||||
|
const giniColor = d.giniCoefficient > 0.6 ? "#f87171" : d.giniCoefficient > 0.3 ? "#fbbf24" : "#34d399";
|
||||||
|
const hhiColor = d.herfindahlIndex > 0.25 ? "#f87171" : d.herfindahlIndex > 0.15 ? "#fbbf24" : "#34d399";
|
||||||
|
|
||||||
|
const maxVal = Math.max(...d.results.map((r: any) => Math.max(r.weight / d.totalWeight, r.banzhaf, r.shapleyShubik)), 0.01);
|
||||||
|
const top = d.results.slice(0, 15);
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="power-header">Power Analysis</div>
|
||||||
|
<button class="power-close" id="power-close">×</button>
|
||||||
|
<div class="power-metrics">
|
||||||
|
<div class="power-metric">
|
||||||
|
<div class="power-metric-val">${d.playerCount}</div>
|
||||||
|
<div class="power-metric-lbl">Voters</div>
|
||||||
|
</div>
|
||||||
|
<div class="power-metric">
|
||||||
|
<div class="power-metric-val">${effectiveN}</div>
|
||||||
|
<div class="power-metric-lbl">Effective</div>
|
||||||
|
<div class="power-gauge"><div class="power-gauge-fill" style="width:${Math.min(100, (1 / d.herfindahlIndex / d.playerCount) * 100)}%;background:${hhiColor}"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="power-metric">
|
||||||
|
<div class="power-metric-val">${giniPct}%</div>
|
||||||
|
<div class="power-metric-lbl">Gini</div>
|
||||||
|
<div class="power-gauge"><div class="power-gauge-fill" style="width:${d.giniCoefficient * 100}%;background:${giniColor}"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="power-metric">
|
||||||
|
<div class="power-metric-val">${hhiPct}%</div>
|
||||||
|
<div class="power-metric-lbl">HHI</div>
|
||||||
|
<div class="power-gauge"><div class="power-gauge-fill" style="width:${d.herfindahlIndex * 100}%;background:${hhiColor}"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="power-legend">
|
||||||
|
<div class="power-legend-item"><div class="power-legend-sw" style="background:rgba(148,163,184,0.35)"></div>Weight</div>
|
||||||
|
<div class="power-legend-item"><div class="power-legend-sw" style="background:${authColor}"></div>Banzhaf</div>
|
||||||
|
<div class="power-legend-item"><div class="power-legend-sw" style="background:${authColor}66"></div>Shapley</div>
|
||||||
|
</div>
|
||||||
|
<div class="power-section">Top ${top.length} by Banzhaf Power</div>
|
||||||
|
<div class="power-bars" style="--power-color:${authColor}">${top.map((r: any) => {
|
||||||
|
const node = this.nodes.find(n => n.id === r.did);
|
||||||
|
const name = node?.name || r.did.slice(0, 12);
|
||||||
|
const wPct = ((r.weight / d.totalWeight) / maxVal * 100).toFixed(1);
|
||||||
|
const bPct = (r.banzhaf / maxVal * 100).toFixed(1);
|
||||||
|
const sPct = (r.shapleyShubik / maxVal * 100).toFixed(1);
|
||||||
|
return `<div class="power-bar-row" data-focus-did="${r.did}">
|
||||||
|
<div class="power-bar-name" title="${this.esc(name)}">${this.esc(name)}</div>
|
||||||
|
<div class="power-bar-tracks">
|
||||||
|
<div class="power-bar-track"><div class="power-bar-fill weight" style="width:${wPct}%">${(r.weight / d.totalWeight * 100).toFixed(0)}%</div></div>
|
||||||
|
<div class="power-bar-track"><div class="power-bar-fill banzhaf" style="width:${bPct}%">${(r.banzhaf * 100).toFixed(1)}%</div></div>
|
||||||
|
<div class="power-bar-track"><div class="power-bar-fill shapley" style="width:${sPct}%">${(r.shapleyShubik * 100).toFixed(1)}%</div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("")}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Delay bar fill widths by 1 frame so CSS transitions animate
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
panel.querySelectorAll<HTMLElement>(".power-bar-fill").forEach(el => {
|
||||||
|
const w = el.style.width;
|
||||||
|
el.style.width = "0%";
|
||||||
|
requestAnimationFrame(() => { el.style.width = w; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
panel.querySelector("#power-close")?.addEventListener("click", () => panel.classList.remove("visible"));
|
||||||
|
panel.querySelectorAll("[data-focus-did]").forEach(el => {
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
const did = (el as HTMLElement).dataset.focusDid!;
|
||||||
|
const node = this.nodes.find(n => n.id === did);
|
||||||
|
if (node && this.graph && node.x != null && node.y != null && node.z != null) {
|
||||||
|
this.selectedNode = node;
|
||||||
|
this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
|
||||||
|
this.updateDetailPanel();
|
||||||
|
this.updateGraphData();
|
||||||
|
this.graph.cameraPosition(
|
||||||
|
{ x: node.x + 60, y: node.y + 18, z: node.z + 60 },
|
||||||
|
{ x: node.x, y: node.y, z: node.z },
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private updateMemberList() {
|
private updateMemberList() {
|
||||||
const panel = this.shadow.getElementById("member-list-panel");
|
const panel = this.shadow.getElementById("member-list-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
/**
|
||||||
|
* <folk-power-indices> — Coalitional power analysis visualization.
|
||||||
|
*
|
||||||
|
* Shows Banzhaf power index, Shapley-Shubik power index, raw voting weight,
|
||||||
|
* Gini coefficient, HHI concentration, and a power-vs-weight scatter plot.
|
||||||
|
* Fetches from EncryptID /api/power-indices endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PowerResult {
|
||||||
|
did: string;
|
||||||
|
weight: number;
|
||||||
|
banzhaf: number;
|
||||||
|
shapleyShubik: number;
|
||||||
|
swingCount: number;
|
||||||
|
pivotalCount: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PowerData {
|
||||||
|
space: string;
|
||||||
|
authority: string;
|
||||||
|
totalWeight: number;
|
||||||
|
playerCount: number;
|
||||||
|
giniCoefficient: number;
|
||||||
|
herfindahlIndex: number;
|
||||||
|
lastComputed: number;
|
||||||
|
results: PowerResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
||||||
|
const AUTHORITY_META: Record<string, { label: string; color: string }> = {
|
||||||
|
"gov-ops": { label: "Governance", color: "#a78bfa" },
|
||||||
|
"fin-ops": { label: "Economic", color: "#10b981" },
|
||||||
|
"dev-ops": { label: "Technical", color: "#3b82f6" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENCRYPTID_URL = (typeof window !== "undefined" && (window as any).__ENCRYPTID_URL) || "";
|
||||||
|
|
||||||
|
class FolkPowerIndices extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = "";
|
||||||
|
private authority = "gov-ops";
|
||||||
|
private data: PowerData | null = null;
|
||||||
|
private loading = true;
|
||||||
|
private error = "";
|
||||||
|
private userMap = new Map<string, string>(); // did → display name
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observedAttributes() { return ["space", "authority"]; }
|
||||||
|
|
||||||
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
|
if (name === "space" && val !== this.space) { this.space = val; this.fetchData(); }
|
||||||
|
if (name === "authority" && val !== this.authority) { this.authority = val; this.fetchData(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
this.authority = this.getAttribute("authority") || "gov-ops";
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchData() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = "";
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch user directory for name resolution
|
||||||
|
const usersUrl = ENCRYPTID_URL
|
||||||
|
? `${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(this.space)}`
|
||||||
|
: `/api/power-indices/users?space=${encodeURIComponent(this.space)}`;
|
||||||
|
try {
|
||||||
|
const uRes = await fetch(ENCRYPTID_URL ? usersUrl : `/rnetwork/api/users?space=${encodeURIComponent(this.space)}`);
|
||||||
|
if (uRes.ok) {
|
||||||
|
const uData = await uRes.json();
|
||||||
|
for (const u of uData.users || []) {
|
||||||
|
this.userMap.set(u.did, u.displayName || u.username || u.did.slice(0, 12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
|
||||||
|
const piUrl = ENCRYPTID_URL
|
||||||
|
? `${ENCRYPTID_URL}/api/power-indices?space=${encodeURIComponent(this.space)}&authority=${encodeURIComponent(this.authority)}`
|
||||||
|
: `/rnetwork/api/power-indices?space=${encodeURIComponent(this.space)}&authority=${encodeURIComponent(this.authority)}`;
|
||||||
|
const res = await fetch(piUrl);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.data = await res.json();
|
||||||
|
this.loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = (e as Error).message;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
const meta = AUTHORITY_META[this.authority] || { label: this.authority, color: "#888" };
|
||||||
|
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
|
||||||
|
.container { padding: 16px; }
|
||||||
|
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.tab {
|
||||||
|
padding: 6px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
|
||||||
|
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); color: #94a3b8;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tab:hover { background: rgba(255,255,255,0.1); }
|
||||||
|
.tab.active { background: ${meta.color}22; border-color: ${meta.color}; color: ${meta.color}; }
|
||||||
|
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
||||||
|
.metric {
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px;
|
||||||
|
padding: 12px; text-align: center;
|
||||||
|
}
|
||||||
|
.metric-value { font-size: 24px; font-weight: 700; color: #f1f5f9; }
|
||||||
|
.metric-label { font-size: 11px; color: #64748b; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.gauge-bar { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.1); margin-top: 8px; overflow: hidden; }
|
||||||
|
.gauge-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
|
||||||
|
|
||||||
|
.chart-section { margin-bottom: 24px; }
|
||||||
|
.section-title { font-size: 13px; font-weight: 600; color: #94a3b8; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
/* Bar chart */
|
||||||
|
.bar-chart { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.bar-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||||
|
.bar-label { width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #cbd5e1; text-align: right; flex-shrink: 0; }
|
||||||
|
.bar-group { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.bar-track { height: 14px; background: rgba(255,255,255,0.04); border-radius: 3px; overflow: hidden; position: relative; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; display: flex; align-items: center; padding-left: 4px; font-size: 10px; color: rgba(255,255,255,0.8); min-width: 0; }
|
||||||
|
.bar-fill.weight { background: rgba(148,163,184,0.4); }
|
||||||
|
.bar-fill.banzhaf { background: ${meta.color}aa; }
|
||||||
|
.bar-fill.shapley { background: ${meta.color}55; }
|
||||||
|
.bar-value { font-size: 11px; color: #64748b; width: 48px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Scatter plot */
|
||||||
|
.scatter-container { position: relative; width: 100%; padding-top: 100%; max-width: 400px; max-height: 400px; }
|
||||||
|
.scatter-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
.scatter-point { cursor: pointer; transition: r 0.2s; }
|
||||||
|
.scatter-point:hover { r: 6; }
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.legend { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #94a3b8; }
|
||||||
|
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.loading, .error-msg { text-align: center; padding: 40px; color: #64748b; }
|
||||||
|
.error-msg { color: #f87171; }
|
||||||
|
.empty-state { text-align: center; padding: 40px; color: #64748b; }
|
||||||
|
.tooltip {
|
||||||
|
position: fixed; z-index: 100; background: #1e293b; border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 6px; padding: 8px 12px; font-size: 12px; pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
${this.renderTabs()}
|
||||||
|
${this.loading ? '<div class="loading">Computing power indices...</div>'
|
||||||
|
: this.error ? `<div class="error-msg">Error: ${this.error}</div>`
|
||||||
|
: this.data && this.data.results.length > 0 ? this.renderContent()
|
||||||
|
: '<div class="empty-state">No delegation data available for power analysis.</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip" id="tooltip"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.attachEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTabs(): string {
|
||||||
|
return `<div class="tabs">${AUTHORITIES.map(a => {
|
||||||
|
const m = AUTHORITY_META[a];
|
||||||
|
return `<div class="tab ${a === this.authority ? 'active' : ''}" data-authority="${a}">${m.label}</div>`;
|
||||||
|
}).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderContent(): string {
|
||||||
|
if (!this.data) return "";
|
||||||
|
const d = this.data;
|
||||||
|
const effectiveN = d.herfindahlIndex > 0 ? (1 / d.herfindahlIndex).toFixed(1) : "—";
|
||||||
|
const giniColor = d.giniCoefficient > 0.6 ? "#f87171" : d.giniCoefficient > 0.3 ? "#fbbf24" : "#34d399";
|
||||||
|
const hhiColor = d.herfindahlIndex > 0.25 ? "#f87171" : d.herfindahlIndex > 0.15 ? "#fbbf24" : "#34d399";
|
||||||
|
|
||||||
|
return `
|
||||||
|
${this.renderMetrics(d, effectiveN, giniColor, hhiColor)}
|
||||||
|
<div class="chart-section">
|
||||||
|
<div class="section-title">Power Distribution</div>
|
||||||
|
${this.renderLegend()}
|
||||||
|
${this.renderBarChart(d)}
|
||||||
|
</div>
|
||||||
|
<div class="chart-section">
|
||||||
|
<div class="section-title">Power vs Weight (deviation from proportionality)</div>
|
||||||
|
${this.renderScatter(d)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMetrics(d: PowerData, effectiveN: string, giniColor: string, hhiColor: string): string {
|
||||||
|
return `<div class="metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">${d.playerCount}</div>
|
||||||
|
<div class="metric-label">Voters</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">${effectiveN}</div>
|
||||||
|
<div class="metric-label">Effective Voters</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-fill" style="width:${Math.min(100, (1/d.herfindahlIndex/d.playerCount)*100)}%; background:${hhiColor}"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">${(d.giniCoefficient * 100).toFixed(0)}%</div>
|
||||||
|
<div class="metric-label">Gini (Power)</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-fill" style="width:${d.giniCoefficient*100}%; background:${giniColor}"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-value">${(d.herfindahlIndex * 100).toFixed(0)}%</div>
|
||||||
|
<div class="metric-label">HHI Concentration</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-fill" style="width:${d.herfindahlIndex*100}%; background:${hhiColor}"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLegend(): string {
|
||||||
|
const meta = AUTHORITY_META[this.authority];
|
||||||
|
return `<div class="legend">
|
||||||
|
<div class="legend-item"><div class="legend-swatch" style="background:rgba(148,163,184,0.4)"></div>Raw Weight</div>
|
||||||
|
<div class="legend-item"><div class="legend-swatch" style="background:${meta.color}aa"></div>Banzhaf Index</div>
|
||||||
|
<div class="legend-item"><div class="legend-swatch" style="background:${meta.color}55"></div>Shapley-Shubik</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBarChart(d: PowerData): string {
|
||||||
|
const maxVal = Math.max(...d.results.map(r => Math.max(r.weight / d.totalWeight, r.banzhaf, r.shapleyShubik)), 0.01);
|
||||||
|
const top = d.results.slice(0, 20); // show top 20
|
||||||
|
|
||||||
|
return `<div class="bar-chart">${top.map(r => {
|
||||||
|
const name = this.userMap.get(r.did) || r.label || r.did.slice(0, 16);
|
||||||
|
const wPct = ((r.weight / d.totalWeight) / maxVal * 100).toFixed(1);
|
||||||
|
const bPct = (r.banzhaf / maxVal * 100).toFixed(1);
|
||||||
|
const sPct = (r.shapleyShubik / maxVal * 100).toFixed(1);
|
||||||
|
return `<div class="bar-row" data-did="${r.did}">
|
||||||
|
<div class="bar-label" title="${name}">${name}</div>
|
||||||
|
<div class="bar-group">
|
||||||
|
<div class="bar-track"><div class="bar-fill weight" style="width:${wPct}%">${(r.weight/d.totalWeight*100).toFixed(1)}%</div></div>
|
||||||
|
<div class="bar-track"><div class="bar-fill banzhaf" style="width:${bPct}%">${(r.banzhaf*100).toFixed(1)}%</div></div>
|
||||||
|
<div class="bar-track"><div class="bar-fill shapley" style="width:${sPct}%">${(r.shapleyShubik*100).toFixed(1)}%</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="bar-value">${(r.banzhaf*100).toFixed(1)}%</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderScatter(d: PowerData): string {
|
||||||
|
// SVG scatter: X = weight%, Y = Banzhaf%
|
||||||
|
const pad = 40;
|
||||||
|
const size = 300;
|
||||||
|
const meta = AUTHORITY_META[this.authority];
|
||||||
|
|
||||||
|
const points = d.results.map(r => ({
|
||||||
|
x: r.weight / d.totalWeight,
|
||||||
|
y: r.banzhaf,
|
||||||
|
did: r.did,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const maxX = Math.max(...points.map(p => p.x), 0.01) * 1.1;
|
||||||
|
const maxY = Math.max(...points.map(p => p.y), 0.01) * 1.1;
|
||||||
|
|
||||||
|
const sx = (v: number) => pad + (v / maxX) * (size - pad * 2);
|
||||||
|
const sy = (v: number) => size - pad - (v / maxY) * (size - pad * 2);
|
||||||
|
|
||||||
|
// Diagonal line (proportional power)
|
||||||
|
const diagEnd = Math.min(maxX, maxY);
|
||||||
|
|
||||||
|
const pointsSvg = points.map(p => {
|
||||||
|
const overpower = p.y > p.x / d.totalWeight * d.playerCount; // more power than weight would suggest
|
||||||
|
const color = p.y > (p.x / d.totalWeight) * 1.2 ? "#f87171" : p.y < (p.x / d.totalWeight) * 0.8 ? "#60a5fa" : "#94a3b8";
|
||||||
|
return `<circle class="scatter-point" cx="${sx(p.x)}" cy="${sy(p.y)}" r="4" fill="${color}" data-did="${p.did}" />`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return `<div class="scatter-container" style="max-width:${size}px; padding-top:${size}px;">
|
||||||
|
<svg class="scatter-svg" viewBox="0 0 ${size} ${size}">
|
||||||
|
<!-- axes -->
|
||||||
|
<line x1="${pad}" y1="${size-pad}" x2="${size-pad}" y2="${size-pad}" stroke="rgba(255,255,255,0.15)" />
|
||||||
|
<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${size-pad}" stroke="rgba(255,255,255,0.15)" />
|
||||||
|
<!-- diagonal (proportional) -->
|
||||||
|
<line x1="${sx(0)}" y1="${sy(0)}" x2="${sx(diagEnd)}" y2="${sy(diagEnd)}" stroke="rgba(255,255,255,0.1)" stroke-dasharray="4,4" />
|
||||||
|
<!-- labels -->
|
||||||
|
<text x="${size/2}" y="${size-6}" fill="#64748b" font-size="10" text-anchor="middle">Weight %</text>
|
||||||
|
<text x="10" y="${size/2}" fill="#64748b" font-size="10" text-anchor="middle" transform="rotate(-90 10 ${size/2})">Banzhaf %</text>
|
||||||
|
${pointsSvg}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="legend" style="margin-top:8px">
|
||||||
|
<div class="legend-item"><div class="legend-swatch" style="background:#f87171"></div>Overrepresented</div>
|
||||||
|
<div class="legend-item"><div class="legend-swatch" style="background:#94a3b8"></div>Proportional</div>
|
||||||
|
<div class="legend-item"><div class="legend-swatch" style="background:#60a5fa"></div>Underrepresented</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachEvents() {
|
||||||
|
// Tab clicks
|
||||||
|
this.shadow.querySelectorAll<HTMLElement>(".tab").forEach(tab => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
const auth = tab.dataset.authority;
|
||||||
|
if (auth && auth !== this.authority) {
|
||||||
|
this.authority = auth;
|
||||||
|
this.setAttribute("authority", auth);
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tooltip on scatter points
|
||||||
|
const tooltip = this.shadow.getElementById("tooltip");
|
||||||
|
this.shadow.querySelectorAll<SVGCircleElement>(".scatter-point").forEach(pt => {
|
||||||
|
pt.addEventListener("mouseenter", (e) => {
|
||||||
|
if (!tooltip || !this.data) return;
|
||||||
|
const did = pt.dataset.did || "";
|
||||||
|
const r = this.data.results.find(x => x.did === did);
|
||||||
|
if (!r) return;
|
||||||
|
const name = this.userMap.get(did) || did.slice(0, 16);
|
||||||
|
tooltip.innerHTML = `<b>${name}</b><br>Weight: ${(r.weight / this.data.totalWeight * 100).toFixed(1)}%<br>Banzhaf: ${(r.banzhaf * 100).toFixed(1)}%<br>Shapley-Shubik: ${(r.shapleyShubik * 100).toFixed(1)}%<br>Swings: ${r.swingCount}`;
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
tooltip.style.left = `${(e as MouseEvent).clientX + 12}px`;
|
||||||
|
tooltip.style.top = `${(e as MouseEvent).clientY - 10}px`;
|
||||||
|
});
|
||||||
|
pt.addEventListener("mouseleave", () => {
|
||||||
|
if (tooltip) tooltip.style.display = "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("folk-power-indices", FolkPowerIndices);
|
||||||
|
|
@ -696,6 +696,7 @@ routes.get("/api/opportunities", async (c) => {
|
||||||
const GRAPH_TABS = [
|
const GRAPH_TABS = [
|
||||||
{ id: "members", label: "Members" },
|
{ id: "members", label: "Members" },
|
||||||
{ id: "trust", label: "Trust" },
|
{ id: "trust", label: "Trust" },
|
||||||
|
{ id: "power", label: "Power" },
|
||||||
{ id: "crm", label: "CRM" },
|
{ id: "crm", label: "CRM" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -744,6 +745,39 @@ routes.get("/crm/:tabId", (c) => {
|
||||||
return c.html(renderCrm(space, tabId, c.get("isSubdomain")));
|
return c.html(renderCrm(space, tabId, c.get("isSubdomain")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── API: Power Indices — proxy to EncryptID ──
|
||||||
|
routes.get("/api/power-indices", async (c) => {
|
||||||
|
const space = getTrustSpace(c);
|
||||||
|
const authority = c.req.query("authority") || "gov-ops";
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${ENCRYPTID_URL}/api/power-indices?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`,
|
||||||
|
{ signal: AbortSignal.timeout(5000) },
|
||||||
|
);
|
||||||
|
if (!res.ok) return c.json({ results: [], authority, error: "Power indices unavailable" });
|
||||||
|
return c.json(await res.json());
|
||||||
|
} catch {
|
||||||
|
return c.json({ results: [], authority, error: "EncryptID unreachable" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/power-indices/simulate", async (c) => {
|
||||||
|
const auth = c.req.header("Authorization");
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
try {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const res = await fetch(`${ENCRYPTID_URL}/api/power-indices/simulate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Authorization": auth },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
return c.json(await res.json(), res.status as any);
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "EncryptID unreachable" }, 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Graph sub-tab routes ──
|
// ── Graph sub-tab routes ──
|
||||||
function renderGraph(space: string, activeTab: string, isSubdomain: boolean) {
|
function renderGraph(space: string, activeTab: string, isSubdomain: boolean) {
|
||||||
return renderShell({
|
return renderShell({
|
||||||
|
|
@ -765,12 +799,12 @@ routes.get("/:tabId", (c, next) => {
|
||||||
const tabId = c.req.param("tabId");
|
const tabId = c.req.param("tabId");
|
||||||
// Only handle graph tab IDs here; let other routes (crm, api, etc.) pass through
|
// Only handle graph tab IDs here; let other routes (crm, api, etc.) pass through
|
||||||
if (!GRAPH_TAB_IDS.has(tabId as any)) return next();
|
if (!GRAPH_TAB_IDS.has(tabId as any)) return next();
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
// "crm" tab redirects to the CRM sub-app
|
// "crm" tab redirects to the CRM sub-app
|
||||||
if (tabId === "crm") {
|
if (tabId === "crm") {
|
||||||
const space = c.req.param("space") || "demo";
|
|
||||||
return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm/pipeline` : `/${space}/rnetwork/crm/pipeline`, 302);
|
return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm/pipeline` : `/${space}/rnetwork/crm/pipeline`, 302);
|
||||||
}
|
}
|
||||||
const space = c.req.param("space") || "demo";
|
// All graph tabs (members, trust, power) use the graph viewer
|
||||||
return c.html(renderGraph(space, tabId, c.get("isSubdomain")));
|
return c.html(renderGraph(space, tabId, c.get("isSubdomain")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2520,4 +2520,83 @@ export async function listAllAgentMailboxes(): Promise<Array<{ spaceSlug: string
|
||||||
return rows.map((r) => ({ spaceSlug: r.space_slug, email: r.email, password: r.password }));
|
return rows.map((r) => ({ spaceSlug: r.space_slug, email: r.email, password: r.password }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POWER INDICES (Banzhaf, Shapley-Shubik coalitional power)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface StoredPowerIndex {
|
||||||
|
did: string;
|
||||||
|
spaceSlug: string;
|
||||||
|
authority: string;
|
||||||
|
rawWeight: number;
|
||||||
|
banzhaf: number;
|
||||||
|
shapleyShubik: number;
|
||||||
|
swingCount: number;
|
||||||
|
pivotalCount: number;
|
||||||
|
giniCoefficient: number;
|
||||||
|
herfindahlIndex: number;
|
||||||
|
lastComputed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPowerIndexRow(r: any): StoredPowerIndex {
|
||||||
|
return {
|
||||||
|
did: r.did,
|
||||||
|
spaceSlug: r.space_slug,
|
||||||
|
authority: r.authority,
|
||||||
|
rawWeight: parseFloat(r.raw_weight),
|
||||||
|
banzhaf: parseFloat(r.banzhaf),
|
||||||
|
shapleyShubik: parseFloat(r.shapley_shubik),
|
||||||
|
swingCount: parseInt(r.swing_count),
|
||||||
|
pivotalCount: parseInt(r.pivotal_count),
|
||||||
|
giniCoefficient: parseFloat(r.gini_coefficient),
|
||||||
|
herfindahlIndex: parseFloat(r.herfindahl_index),
|
||||||
|
lastComputed: new Date(r.last_computed).getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertPowerIndex(idx: {
|
||||||
|
did: string;
|
||||||
|
spaceSlug: string;
|
||||||
|
authority: string;
|
||||||
|
rawWeight: number;
|
||||||
|
banzhaf: number;
|
||||||
|
shapleyShubik: number;
|
||||||
|
swingCount: number;
|
||||||
|
pivotalCount: number;
|
||||||
|
giniCoefficient: number;
|
||||||
|
herfindahlIndex: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO power_indices (did, space_slug, authority, raw_weight, banzhaf, shapley_shubik, swing_count, pivotal_count, gini_coefficient, herfindahl_index, last_computed)
|
||||||
|
VALUES (${idx.did}, ${idx.spaceSlug}, ${idx.authority}, ${idx.rawWeight}, ${idx.banzhaf}, ${idx.shapleyShubik}, ${idx.swingCount}, ${idx.pivotalCount}, ${idx.giniCoefficient}, ${idx.herfindahlIndex}, NOW())
|
||||||
|
ON CONFLICT (did, space_slug, authority) DO UPDATE SET
|
||||||
|
raw_weight = EXCLUDED.raw_weight,
|
||||||
|
banzhaf = EXCLUDED.banzhaf,
|
||||||
|
shapley_shubik = EXCLUDED.shapley_shubik,
|
||||||
|
swing_count = EXCLUDED.swing_count,
|
||||||
|
pivotal_count = EXCLUDED.pivotal_count,
|
||||||
|
gini_coefficient = EXCLUDED.gini_coefficient,
|
||||||
|
herfindahl_index = EXCLUDED.herfindahl_index,
|
||||||
|
last_computed = NOW()
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPowerIndices(spaceSlug: string, authority: string): Promise<StoredPowerIndex[]> {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT * FROM power_indices
|
||||||
|
WHERE space_slug = ${spaceSlug} AND authority = ${authority}
|
||||||
|
ORDER BY banzhaf DESC
|
||||||
|
`;
|
||||||
|
return rows.map(mapPowerIndexRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPowerIndex(did: string, spaceSlug: string): Promise<StoredPowerIndex[]> {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT * FROM power_indices
|
||||||
|
WHERE did = ${did} AND space_slug = ${spaceSlug}
|
||||||
|
ORDER BY authority
|
||||||
|
`;
|
||||||
|
return rows.map(mapPowerIndexRow);
|
||||||
|
}
|
||||||
|
|
||||||
export { sql };
|
export { sql };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,411 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -595,3 +595,25 @@ CREATE TABLE IF NOT EXISTS agent_mailboxes (
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- POWER INDICES (Banzhaf, Shapley-Shubik coalitional power analysis)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS power_indices (
|
||||||
|
did TEXT NOT NULL,
|
||||||
|
space_slug TEXT NOT NULL,
|
||||||
|
authority TEXT NOT NULL,
|
||||||
|
raw_weight REAL DEFAULT 0,
|
||||||
|
banzhaf REAL DEFAULT 0,
|
||||||
|
shapley_shubik REAL DEFAULT 0,
|
||||||
|
swing_count INTEGER DEFAULT 0,
|
||||||
|
pivotal_count INTEGER DEFAULT 0,
|
||||||
|
gini_coefficient REAL DEFAULT 0,
|
||||||
|
herfindahl_index REAL DEFAULT 0,
|
||||||
|
last_computed TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (did, space_slug, authority)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_power_indices_space ON power_indices(space_slug, authority);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_power_indices_did ON power_indices(did);
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@ import {
|
||||||
getAggregatedTrustScores,
|
getAggregatedTrustScores,
|
||||||
getTrustScoresByAuthority,
|
getTrustScoresByAuthority,
|
||||||
listAllUsersWithTrust,
|
listAllUsersWithTrust,
|
||||||
|
getPowerIndices,
|
||||||
|
getPowerIndex,
|
||||||
|
upsertPowerIndex,
|
||||||
sql,
|
sql,
|
||||||
getUserNotifications,
|
getUserNotifications,
|
||||||
getUnreadCount,
|
getUnreadCount,
|
||||||
|
|
@ -162,6 +165,7 @@ import {
|
||||||
import { notify } from '../../server/notification-service';
|
import { notify } from '../../server/notification-service';
|
||||||
import { sendWelcomeEmail } from '../../server/welcome-email';
|
import { sendWelcomeEmail } from '../../server/welcome-email';
|
||||||
import { startTrustEngine } from './trust-engine.js';
|
import { startTrustEngine } from './trust-engine.js';
|
||||||
|
import { computePowerIndices, resolveVotingWeights, simulateCoalition } from './power-indices.js';
|
||||||
import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias, provisionAgentMailbox, deprovisionAgentMailbox } from './space-alias-service.js';
|
import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias, provisionAgentMailbox, deprovisionAgentMailbox } from './space-alias-service.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -9637,6 +9641,110 @@ app.get('/api/users/directory', async (c) => {
|
||||||
return c.json({ users, space: spaceSlug });
|
return c.json({ users, space: spaceSlug });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POWER INDICES (Banzhaf, Shapley-Shubik coalitional power)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GET /api/power-indices — full power analysis for a space+authority
|
||||||
|
app.get('/api/power-indices', async (c) => {
|
||||||
|
const spaceSlug = c.req.query('space');
|
||||||
|
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
|
||||||
|
const authority = c.req.query('authority') || 'gov-ops';
|
||||||
|
|
||||||
|
// Try precomputed results from DB first
|
||||||
|
let indices = await getPowerIndices(spaceSlug, authority);
|
||||||
|
|
||||||
|
// On-demand computation if DB is empty (first load, demo space, etc.)
|
||||||
|
if (indices.length === 0) {
|
||||||
|
try {
|
||||||
|
const players = await resolveVotingWeights(spaceSlug, authority);
|
||||||
|
if (players.length >= 2) {
|
||||||
|
const analysis = computePowerIndices(players, spaceSlug, authority);
|
||||||
|
// Persist for next time
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
indices = await getPowerIndices(spaceSlug, authority);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[power-indices] On-demand computation failed:`, (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indices.length === 0) {
|
||||||
|
return c.json({ results: [], space: spaceSlug, authority, message: 'No delegation data for power analysis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWeight = indices.reduce((a, i) => a + i.rawWeight, 0);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
space: spaceSlug,
|
||||||
|
authority,
|
||||||
|
totalWeight,
|
||||||
|
playerCount: indices.length,
|
||||||
|
giniCoefficient: indices[0]?.giniCoefficient ?? 0,
|
||||||
|
herfindahlIndex: indices[0]?.herfindahlIndex ?? 0,
|
||||||
|
lastComputed: indices[0]?.lastComputed ?? 0,
|
||||||
|
results: indices.map(i => ({
|
||||||
|
did: i.did,
|
||||||
|
weight: i.rawWeight,
|
||||||
|
banzhaf: i.banzhaf,
|
||||||
|
shapleyShubik: i.shapleyShubik,
|
||||||
|
swingCount: i.swingCount,
|
||||||
|
pivotalCount: i.pivotalCount,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/power-indices/:did — one user's power profile across all authorities
|
||||||
|
app.get('/api/power-indices/:did', async (c) => {
|
||||||
|
const did = c.req.param('did');
|
||||||
|
const spaceSlug = c.req.query('space');
|
||||||
|
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
|
||||||
|
|
||||||
|
const indices = await getPowerIndex(did, spaceSlug);
|
||||||
|
return c.json({
|
||||||
|
did,
|
||||||
|
space: spaceSlug,
|
||||||
|
authorities: indices.map(i => ({
|
||||||
|
authority: i.authority,
|
||||||
|
weight: i.rawWeight,
|
||||||
|
banzhaf: i.banzhaf,
|
||||||
|
shapleyShubik: i.shapleyShubik,
|
||||||
|
swingCount: i.swingCount,
|
||||||
|
pivotalCount: i.pivotalCount,
|
||||||
|
giniCoefficient: i.giniCoefficient,
|
||||||
|
herfindahlIndex: i.herfindahlIndex,
|
||||||
|
lastComputed: i.lastComputed,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/power-indices/simulate — "what if" coalition simulation
|
||||||
|
app.post('/api/power-indices/simulate', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body) return c.json({ error: 'Invalid JSON body' }, 400);
|
||||||
|
|
||||||
|
const { space, authority, coalition } = body;
|
||||||
|
if (!space || typeof space !== 'string') return c.json({ error: 'space required' }, 400);
|
||||||
|
if (!Array.isArray(coalition) || coalition.length === 0) return c.json({ error: 'coalition array required' }, 400);
|
||||||
|
|
||||||
|
const auth = typeof authority === 'string' ? authority : 'gov-ops';
|
||||||
|
const players = await resolveVotingWeights(space, auth);
|
||||||
|
if (players.length === 0) return c.json({ error: 'No voting weights found for this space' }, 404);
|
||||||
|
|
||||||
|
const result = simulateCoalition(players, coalition, space, auth);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DATABASE INITIALIZATION & SERVER START
|
// DATABASE INITIALIZATION & SERVER START
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
type StoredDelegation,
|
type StoredDelegation,
|
||||||
type DelegationAuthority,
|
type DelegationAuthority,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
import { computeSpacePowerIndices } from './power-indices.js';
|
||||||
|
|
||||||
// ── Configuration ──
|
// ── Configuration ──
|
||||||
|
|
||||||
|
|
@ -199,6 +200,16 @@ export async function recomputeSpaceTrustScores(spaceSlug: string): Promise<numb
|
||||||
totalScores += scores.length;
|
totalScores += scores.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recompute power indices (Banzhaf, Shapley-Shubik) from updated trust scores
|
||||||
|
try {
|
||||||
|
const piCount = await computeSpacePowerIndices(spaceSlug);
|
||||||
|
if (piCount > 0) {
|
||||||
|
console.log(`[trust-engine] Recomputed ${piCount} power indices for space "${spaceSlug}"`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[trust-engine] Power index computation failed for "${spaceSlug}":`, (err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
return totalScores;
|
return totalScores;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -929,6 +929,26 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build power indices component
|
||||||
|
await wasmBuild({
|
||||||
|
configFile: false,
|
||||||
|
root: resolve(__dirname, "modules/rnetwork/components"),
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
outDir: resolve(__dirname, "dist/modules/rnetwork"),
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, "modules/rnetwork/components/folk-power-indices.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => "folk-power-indices.js",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "folk-power-indices.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Copy network CSS
|
// Copy network CSS
|
||||||
mkdirSync(resolve(__dirname, "dist/modules/rnetwork"), { recursive: true });
|
mkdirSync(resolve(__dirname, "dist/modules/rnetwork"), { recursive: true });
|
||||||
copyFileSync(
|
copyFileSync(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue