From 97c1b02c58edb0d698b35c0bcf47112c28ffe859 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 12:39:01 -0400 Subject: [PATCH] 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 --- .../rnetwork/components/folk-graph-viewer.ts | 196 ++++++++- .../rnetwork/components/folk-power-indices.ts | 336 ++++++++++++++ modules/rnetwork/mod.ts | 38 +- src/encryptid/db.ts | 79 ++++ src/encryptid/power-indices.ts | 411 ++++++++++++++++++ src/encryptid/schema.sql | 22 + src/encryptid/server.ts | 108 +++++ src/encryptid/trust-engine.ts | 11 + vite.config.ts | 20 + 9 files changed, 1217 insertions(+), 4 deletions(-) create mode 100644 modules/rnetwork/components/folk-power-indices.ts create mode 100644 src/encryptid/power-indices.ts diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 965c4a0e..bb417b4d 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -144,6 +144,9 @@ class FolkGraphViewer extends HTMLElement { private ringGuides: any[] = []; private demoDelegations: GraphEdge[] = []; 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 private layersMode = false; @@ -203,23 +206,38 @@ class FolkGraphViewer extends HTMLElement { private applyTab(tab: string) { const wasTrust = this.trustMode; + const wasPower = this.powerMode; switch (tab) { case "members": this.filter = "all"; this.trustMode = false; + this.powerMode = false; break; case "trust": this.filter = "all"; this.trustMode = true; + this.powerMode = false; if (this.layoutMode !== "rings") { this.layoutMode = "rings"; const ringsBtn = this.shadow.getElementById("rings-toggle"); if (ringsBtn) ringsBtn.classList.add("active"); } 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(); + if (this.powerMode !== wasPower) this.updatePowerPanel(); if (this.trustMode !== wasTrust) { this.loadData(); } else { @@ -353,6 +371,7 @@ class FolkGraphViewer extends HTMLElement { this._textSpriteCache.clear(); this._badgeSpriteCache.clear(); await this.loadData(); + if (this.powerMode) this.fetchPowerData(); } private importGraph(graph: { nodes?: any[]; edges?: any[] }) { @@ -502,6 +521,14 @@ class FolkGraphViewer extends HTMLElement { if (node.type === "feed") return 15; if (node.type === "company") return 22; 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) { const acct = node.weightAccounting; let ew: number; @@ -633,6 +660,39 @@ class FolkGraphViewer extends HTMLElement { 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 { display: none; width: 260px; flex-shrink: 0; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); @@ -925,6 +985,7 @@ class FolkGraphViewer extends HTMLElement {
+
100% @@ -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 const controls = graph.controls(); if (controls) { - controls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: 0 }; + controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; controls.enableDamping = true; controls.dampingFactor = 0.12; 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 = ` +
Power Analysis
+ +
${d ? 'No delegation data for analysis.' : 'Loading power indices...'}
+ `; + 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 = ` +
Power Analysis
+ +
+
+
${d.playerCount}
+
Voters
+
+
+
${effectiveN}
+
Effective
+
+
+
+
${giniPct}%
+
Gini
+
+
+
+
${hhiPct}%
+
HHI
+
+
+
+
+
Weight
+
Banzhaf
+
Shapley
+
+
Top ${top.length} by Banzhaf Power
+
${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 `
+
${this.esc(name)}
+
+
${(r.weight / d.totalWeight * 100).toFixed(0)}%
+
${(r.banzhaf * 100).toFixed(1)}%
+
${(r.shapleyShubik * 100).toFixed(1)}%
+
+
`; + }).join("")}
+ `; + + // Delay bar fill widths by 1 frame so CSS transitions animate + requestAnimationFrame(() => { + panel.querySelectorAll(".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() { const panel = this.shadow.getElementById("member-list-panel"); if (!panel) return; diff --git a/modules/rnetwork/components/folk-power-indices.ts b/modules/rnetwork/components/folk-power-indices.ts new file mode 100644 index 00000000..fc256c6a --- /dev/null +++ b/modules/rnetwork/components/folk-power-indices.ts @@ -0,0 +1,336 @@ +/** + * — 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 = { + "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(); // 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 = ` + +
+ ${this.renderTabs()} + ${this.loading ? '
Computing power indices...
' + : this.error ? `
Error: ${this.error}
` + : this.data && this.data.results.length > 0 ? this.renderContent() + : '
No delegation data available for power analysis.
'} +
+
+ `; + + this.attachEvents(); + } + + private renderTabs(): string { + return `
${AUTHORITIES.map(a => { + const m = AUTHORITY_META[a]; + return `
${m.label}
`; + }).join("")}
`; + } + + 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)} +
+
Power Distribution
+ ${this.renderLegend()} + ${this.renderBarChart(d)} +
+
+
Power vs Weight (deviation from proportionality)
+ ${this.renderScatter(d)} +
+ `; + } + + private renderMetrics(d: PowerData, effectiveN: string, giniColor: string, hhiColor: string): string { + return `
+
+
${d.playerCount}
+
Voters
+
+
+
${effectiveN}
+
Effective Voters
+
+
+
+
${(d.giniCoefficient * 100).toFixed(0)}%
+
Gini (Power)
+
+
+
+
${(d.herfindahlIndex * 100).toFixed(0)}%
+
HHI Concentration
+
+
+
`; + } + + private renderLegend(): string { + const meta = AUTHORITY_META[this.authority]; + return `
+
Raw Weight
+
Banzhaf Index
+
Shapley-Shubik
+
`; + } + + 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 `
${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 `
+
${name}
+
+
${(r.weight/d.totalWeight*100).toFixed(1)}%
+
${(r.banzhaf*100).toFixed(1)}%
+
${(r.shapleyShubik*100).toFixed(1)}%
+
+
${(r.banzhaf*100).toFixed(1)}%
+
`; + }).join("")}
`; + } + + 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 ``; + }).join(""); + + return `
+ + + + + + + + Weight % + Banzhaf % + ${pointsSvg} + +
+
+
Overrepresented
+
Proportional
+
Underrepresented
+
`; + } + + private attachEvents() { + // Tab clicks + this.shadow.querySelectorAll(".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(".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 = `${name}
Weight: ${(r.weight / this.data.totalWeight * 100).toFixed(1)}%
Banzhaf: ${(r.banzhaf * 100).toFixed(1)}%
Shapley-Shubik: ${(r.shapleyShubik * 100).toFixed(1)}%
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); diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index e381d9d5..4b6f9203 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -696,6 +696,7 @@ routes.get("/api/opportunities", async (c) => { const GRAPH_TABS = [ { id: "members", label: "Members" }, { id: "trust", label: "Trust" }, + { id: "power", label: "Power" }, { id: "crm", label: "CRM" }, ] as const; @@ -744,6 +745,39 @@ routes.get("/crm/:tabId", (c) => { 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 ── function renderGraph(space: string, activeTab: string, isSubdomain: boolean) { return renderShell({ @@ -765,12 +799,12 @@ routes.get("/:tabId", (c, next) => { const tabId = c.req.param("tabId"); // Only handle graph tab IDs here; let other routes (crm, api, etc.) pass through 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 if (tabId === "crm") { - const space = c.req.param("space") || "demo"; 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"))); }); diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 426f7cfc..d9600765 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -2520,4 +2520,83 @@ export async function listAllAgentMailboxes(): Promise ({ 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 { + 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 { + 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 { + const rows = await sql` + SELECT * FROM power_indices + WHERE did = ${did} AND space_slug = ${spaceSlug} + ORDER BY authority + `; + return rows.map(mapPowerIndexRow); +} + export { sql }; diff --git a/src/encryptid/power-indices.ts b/src/encryptid/power-indices.ts new file mode 100644 index 00000000..9733e335 --- /dev/null +++ b/src/encryptid/power-indices.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index cec96d9d..60522b0c 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -595,3 +595,25 @@ CREATE TABLE IF NOT EXISTS agent_mailboxes ( password TEXT NOT NULL, 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); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 01874e30..5d45e6e8 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -122,6 +122,9 @@ import { getAggregatedTrustScores, getTrustScoresByAuthority, listAllUsersWithTrust, + getPowerIndices, + getPowerIndex, + upsertPowerIndex, sql, getUserNotifications, getUnreadCount, @@ -162,6 +165,7 @@ import { import { notify } from '../../server/notification-service'; import { sendWelcomeEmail } from '../../server/welcome-email'; 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'; // ============================================================================ @@ -9637,6 +9641,110 @@ app.get('/api/users/directory', async (c) => { 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 // ============================================================================ diff --git a/src/encryptid/trust-engine.ts b/src/encryptid/trust-engine.ts index 0d3df4ae..09e021b4 100644 --- a/src/encryptid/trust-engine.ts +++ b/src/encryptid/trust-engine.ts @@ -20,6 +20,7 @@ import { type StoredDelegation, type DelegationAuthority, } from './db.js'; +import { computeSpacePowerIndices } from './power-indices.js'; // ── Configuration ── @@ -199,6 +200,16 @@ export async function recomputeSpaceTrustScores(spaceSlug: string): Promise 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; } diff --git a/vite.config.ts b/vite.config.ts index 8a021b3c..cf4ff177 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 mkdirSync(resolve(__dirname, "dist/modules/rnetwork"), { recursive: true }); copyFileSync(