/** * rCred Grain Engine — token setup + distribution. * * Creates a GRAIN token per space and distributes it proportional * to CredRank scores using an 80/20 slow/fast split. * * Orchestrates the full recompute pipeline: collect → rank → store → distribute. */ import * as Automerge from '@automerge/automerge'; import type { SyncServer } from '../../server/local-first/sync-server'; import { mintTokens, getTokenDoc, getBalance, getAllBalances } from '../../server/token-service'; import { tokenDocId, tokenLedgerSchema } from '../../server/token-schemas'; import type { TokenLedgerDoc } from '../../server/token-schemas'; import { collectContribGraph } from './graph-collector'; import { computeCredRank } from './credrank'; import type { CredConfigDoc, CredScoresDoc, ContribGraphDoc, CredScore, CredNode, } from './schemas'; import { graphDocId, scoresDocId, configDocId, graphSchema, scoresSchema, configSchema, } from './schemas'; /** In-memory lock to prevent concurrent recomputes per space. */ const runningSpaces = new Set(); /** * Ensure the grain token doc exists for a space. * Returns the token ID (grain-{space}). */ export function ensureGrainToken(space: string, syncServer: SyncServer): string { const tokenId = `grain-${space}`; const docId = tokenDocId(tokenId); let doc = syncServer.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init grain ledger', (d) => { const init = tokenLedgerSchema.init(); Object.assign(d, init); }); syncServer.setDoc(docId, doc); } if (!doc.token.name) { syncServer.changeDoc(docId, `define GRAIN token for ${space}`, (d) => { d.token.id = tokenId; d.token.name = `Grain (${space})`; d.token.symbol = 'GRAIN'; d.token.decimals = 0; d.token.description = `Contribution recognition token for the ${space} space — earned via CredRank`; d.token.icon = '🌾'; d.token.color = '#d97706'; d.token.createdAt = Date.now(); d.token.createdBy = 'rcred'; }); } return tokenId; } /** * Distribute grain to contributors based on cred scores. * Uses 80/20 slow/fast split per SourceCred grain distribution model. */ export function distributeGrain( space: string, syncServer: SyncServer, config: CredConfigDoc, scores: CredScoresDoc, ): { distributed: number; recipients: number } { const tokenId = config.grainTokenId || `grain-${space}`; const tokenDoc = getTokenDoc(tokenId); if (!tokenDoc) return { distributed: 0, recipients: 0 }; const pool = config.grainPerEpoch; const slowPool = Math.floor(pool * config.slowFraction); const fastPool = pool - slowPool; const scoreEntries = Object.values(scores.scores).filter(s => s.cred > 0); if (scoreEntries.length === 0) return { distributed: 0, recipients: 0 }; // ── Fast distribution: proportional to current epoch cred ── const totalEpochCred = scoreEntries.reduce((s, e) => s + e.cred, 0); const fastPayouts = new Map(); if (totalEpochCred > 0) { for (const entry of scoreEntries) { const payout = Math.floor(fastPool * (entry.cred / totalEpochCred)); if (payout > 0) fastPayouts.set(entry.did, payout); } } // ── Slow distribution: lifetime equity catch-up ── // Total slow grain ever minted for this epoch calculation const existingBalances = getAllBalances(tokenDoc); const totalLifetimeCred = scoreEntries.reduce((s, e) => s + (e.rawScore || e.cred), 0); // Estimate total slow grain minted so far from existing supplies const totalSlowMinted = (tokenDoc.token.totalSupply || 0) * config.slowFraction + slowPool; const slowPayouts = new Map(); if (totalLifetimeCred > 0) { let totalSlowDue = 0; const dues = new Map(); for (const entry of scoreEntries) { const lifetimeFraction = (entry.rawScore || entry.cred) / totalLifetimeCred; const due = totalSlowMinted * lifetimeFraction; const alreadyReceived = entry.grainLifetime; const payout = Math.max(0, due - alreadyReceived); dues.set(entry.did, payout); totalSlowDue += payout; } // Scale down if total due exceeds pool const scale = totalSlowDue > slowPool ? slowPool / totalSlowDue : 1; for (const [did, payout] of dues) { const scaled = Math.floor(payout * scale); if (scaled > 0) slowPayouts.set(did, scaled); } } // ── Mint combined payouts ── let distributed = 0; let recipients = 0; const epochId = scores.epochId; const allDids = new Set([...fastPayouts.keys(), ...slowPayouts.keys()]); for (const did of allDids) { const fast = fastPayouts.get(did) || 0; const slow = slowPayouts.get(did) || 0; const total = fast + slow; if (total <= 0) continue; const label = scores.scores[did]?.label || did.slice(0, 16); const success = mintTokens( tokenId, did, label, total, `Grain epoch ${epochId}: ${fast} fast + ${slow} slow`, 'rcred', ); if (success) { distributed += total; recipients++; } } return { distributed, recipients }; } /** * Ensure the config doc exists with defaults. */ export function ensureConfigDoc(space: string, syncServer: SyncServer): CredConfigDoc { const docId = configDocId(space); let doc = syncServer.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init rcred config', (d) => { const init = configSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.grainTokenId = `grain-${space}`; }); syncServer.setDoc(docId, doc); } return doc; } /** * Full recompute pipeline for a space: * 1. Ensure config + grain token * 2. Collect contribution graph * 3. Run CredRank * 4. Post-process into CredScores * 5. Store graph + scores docs * 6. Distribute grain */ export function recomputeSpace( space: string, syncServer: SyncServer, ): { success: boolean; scores: number; grain: number; error?: string } { // Prevent concurrent runs if (runningSpaces.has(space)) { return { success: false, scores: 0, grain: 0, error: 'Already running' }; } runningSpaces.add(space); try { // 1. Config + token const config = ensureConfigDoc(space, syncServer); if (!config.enabled) { return { success: false, scores: 0, grain: 0, error: 'CredRank disabled' }; } ensureGrainToken(space, syncServer); // 2. Collect graph const { nodes, edges } = collectContribGraph(space, syncServer, config); if (nodes.length === 0) { return { success: true, scores: 0, grain: 0 }; } // 3. Run CredRank const rawScores = computeCredRank(nodes, edges, config); // 4. Post-process: filter to contributors, normalize, build breakdown const contributors = nodes.filter(n => n.type === 'contributor'); const maxRaw = Math.max(...contributors.map(c => rawScores.get(c.id) || 0), 1e-10); const epochId = `epoch-${Date.now()}`; const prevScores = syncServer.getDoc(scoresDocId(space)); const credScores: Record = {}; let totalCred = 0; for (const contributor of contributors) { const raw = rawScores.get(contributor.id) || 0; const cred = Math.round((raw / maxRaw) * 1000); if (cred === 0) continue; const did = contributor.did || contributor.id.replace('contributor:', ''); // Breakdown by source module: sum raw scores of connected contribution nodes const breakdown: Record = {}; for (const edge of edges) { if (edge.from !== contributor.id) continue; const targetNode = nodes.find(n => n.id === edge.to); if (targetNode?.sourceModule) { breakdown[targetNode.sourceModule] = (breakdown[targetNode.sourceModule] || 0) + (rawScores.get(targetNode.id) || 0); } } // Normalize breakdown to percentages const breakdownTotal = Object.values(breakdown).reduce((s, v) => s + v, 0) || 1; for (const mod of Object.keys(breakdown)) { breakdown[mod] = Math.round((breakdown[mod] / breakdownTotal) * 100); } // Carry forward lifetime grain from previous epoch const prevEntry = prevScores?.scores[did]; const grainLifetime = prevEntry?.grainLifetime || 0; credScores[did] = { did, label: contributor.label || did.slice(0, 16), cred, rawScore: raw, grainLifetime, epochScores: { ...(prevEntry?.epochScores || {}), [epochId]: cred }, breakdown, lastActive: contributor.timestamp || Date.now(), }; totalCred += cred; } // 5. Store graph doc const gDocId = graphDocId(space); let gDoc = syncServer.getDoc(gDocId); if (!gDoc) { gDoc = Automerge.change(Automerge.init(), 'init rcred graph', (d) => { const init = graphSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); syncServer.setDoc(gDocId, gDoc); } syncServer.changeDoc(gDocId, 'Update contribution graph', (d) => { // Clear and repopulate const nodeKeys = Object.keys(d.nodes); for (const k of nodeKeys) delete d.nodes[k]; const edgeKeys = Object.keys(d.edges); for (const k of edgeKeys) delete d.edges[k]; for (const node of nodes) d.nodes[node.id] = node; for (const edge of edges) d.edges[edge.id] = edge; d.lastBuiltAt = Date.now(); d.stats = { nodeCount: nodes.length, edgeCount: edges.length, contributorCount: contributors.length, contributionCount: nodes.length - contributors.length, }; }); // 6. Store scores doc const sDocId = scoresDocId(space); let sDoc = syncServer.getDoc(sDocId); if (!sDoc) { sDoc = Automerge.change(Automerge.init(), 'init rcred scores', (d) => { const init = scoresSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); syncServer.setDoc(sDocId, sDoc); } syncServer.changeDoc(sDocId, `CredRank epoch ${epochId}`, (d) => { const scoreKeys = Object.keys(d.scores); for (const k of scoreKeys) delete d.scores[k]; for (const [did, score] of Object.entries(credScores)) { d.scores[did] = score; } d.totalCred = totalCred; d.computedAt = Date.now(); d.epochId = epochId; }); // 7. Distribute grain const updatedScores = syncServer.getDoc(sDocId)!; const grainResult = distributeGrain(space, syncServer, config, updatedScores); // Update grain lifetime in scores if (grainResult.distributed > 0) { const tokenId = config.grainTokenId || `grain-${space}`; const tokenDoc = getTokenDoc(tokenId); if (tokenDoc) { syncServer.changeDoc(sDocId, 'Update grain lifetime', (d) => { for (const did of Object.keys(d.scores)) { d.scores[did].grainLifetime = getBalance(tokenDoc!, did); } }); } } // Update config last epoch syncServer.changeDoc(configDocId(space), 'Update last epoch', (d) => { d.lastEpochAt = Date.now(); }); console.log(`[rCred] Recomputed ${space}: ${Object.keys(credScores).length} contributors, ${totalCred} total cred, ${grainResult.distributed} grain to ${grainResult.recipients} recipients`); return { success: true, scores: Object.keys(credScores).length, grain: grainResult.distributed, }; } catch (err) { console.error(`[rCred] Recompute failed for ${space}:`, err); return { success: false, scores: 0, grain: 0, error: String(err) }; } finally { runningSpaces.delete(space); } }