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 {
No nodes match your current filter.
+
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 = `
+
+
+
${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 = `
+
+
+
+
+
${d.playerCount}
+
Voters
+
+
+
${effectiveN}
+
Effective
+
+
+
+
+
+
+
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 ``;
+ }
+
+ 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 `
+
+
+ `;
+ }
+
+ 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(