rspace-online/modules/rcred/grain-engine.ts

344 lines
11 KiB
TypeScript

/**
* 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<string>();
/**
* 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<TokenLedgerDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), 'init grain ledger', (d) => {
const init = tokenLedgerSchema.init();
Object.assign(d, init);
});
syncServer.setDoc(docId, doc);
}
if (!doc.token.name) {
syncServer.changeDoc<TokenLedgerDoc>(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<string, number>();
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<string, number>();
if (totalLifetimeCred > 0) {
let totalSlowDue = 0;
const dues = new Map<string, number>();
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<CredConfigDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<CredConfigDoc>(), '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<CredScoresDoc>(scoresDocId(space));
const credScores: Record<string, CredScore> = {};
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<string, number> = {};
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<ContribGraphDoc>(gDocId);
if (!gDoc) {
gDoc = Automerge.change(Automerge.init<ContribGraphDoc>(), 'init rcred graph', (d) => {
const init = graphSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(gDocId, gDoc);
}
syncServer.changeDoc<ContribGraphDoc>(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<CredScoresDoc>(sDocId);
if (!sDoc) {
sDoc = Automerge.change(Automerge.init<CredScoresDoc>(), 'init rcred scores', (d) => {
const init = scoresSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(sDocId, sDoc);
}
syncServer.changeDoc<CredScoresDoc>(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<CredScoresDoc>(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<CredScoresDoc>(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<CredConfigDoc>(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);
}
}