344 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|