import { WeightedDirectedGraph } from '../supporting/wdg.js'; import { Action } from '../display/action.js'; import { Actor } from '../display/actor.js'; import { ReputationHolder } from '../reputation/reputation-holder.js'; import { displayNumber } from '../../util/helpers.js'; import { EPSILON, INCINERATOR_ADDRESS, EdgeTypes, VertexTypes, } from '../../util/constants.js'; class Post extends Actor { constructor(forum, senderId, postContent) { const index = forum.graph.countVertices(VertexTypes.POST); const name = `Post${index + 1}`; super(name, forum.scene); this.forum = forum; this.id = postContent.id ?? name; this.senderId = senderId; this.value = 0; this.initialValue = 0; this.authors = postContent.authors; this.citations = postContent.citations; this.title = postContent.title; } getLabel() { return `${this.name}
initial ${displayNumber(this.initialValue)}
value ${displayNumber(this.value)}
` .replaceAll(/\n\s*/g, ''); } async setValue(value) { this.value = value; await this.setDisplayValue('value', value); this.forum.graph.getVertex(this.id).setDisplayLabel(this.getLabel()); } } /** * Purpose: * - Forum: Maintain a directed, acyclic, graph of positively and negatively weighted citations. * and the value accrued via each post and citation. */ export class Forum extends ReputationHolder { constructor(dao, name, scene) { super(name, scene); this.dao = dao; this.id = this.reputationPublicKey; this.graph = new WeightedDirectedGraph(scene); this.actions = { propagate: new Action('propagate', scene), confirm: new Action('confirm', scene), transfer: new Action('transfer', scene), }; } async addPost(senderId, postContent) { console.log('addPost', { senderId, postContent }); const post = new Post(this, senderId, postContent); this.graph.addVertex(VertexTypes.POST, post.id, post, post.getLabel()); for (const { postId: citedPostId, weight } of post.citations) { // Special case: Incinerator if (citedPostId === INCINERATOR_ADDRESS && !this.graph.getVertex(INCINERATOR_ADDRESS)) { this.graph.addVertex(VertexTypes.POST, INCINERATOR_ADDRESS, { name: 'Incinerator' }, 'Incinerator'); } this.graph.addEdge(EdgeTypes.CITATION, post.id, citedPostId, weight); } return post; } getPost(postId) { return this.graph.getVertexData(postId); } getPosts() { return this.graph.getVerticesData(); } getTotalValue() { return this.getPosts().reduce((total, { value }) => total += value, 0); } // getLatestContract(type) { } // getContract(type) { } async onValidate({ pool, postId, tokenId, referenceChainLimit, leachingValue, }) { console.log('onValidate', { pool, postId, tokenId }); const initialValue = this.dao.reputation.valueOf(tokenId); const postVertex = this.graph.getVertex(postId); const post = postVertex.data; post.setStatus('Validated'); post.initialValue = initialValue; postVertex.setDisplayLabel(post.getLabel()); const addAuthorToGraph = (publicKey, weight, authorTokenId) => { // For graph display purposes, we want to use the existing Expert actors from the current scene. const author = this.scene.findActor(({ reputationPublicKey }) => reputationPublicKey === publicKey); author.setDisplayValue('reputation', () => author.getReputation()); const authorVertex = this.graph.getVertex(publicKey) ?? this.graph.addVertex(VertexTypes.AUTHOR, publicKey, author, author.getLabel(), { hide: author.options.hide, }); this.graph.addEdge( EdgeTypes.AUTHOR, postVertex, authorVertex, weight, { tokenId: authorTokenId }, { hide: author.options.hide }, ); }; // In the case of multiple authors, mint additional (empty) tokens. // If no authors are specified, treat the sender as the sole author. // TODO: Verify that cumulative author weight == 1. if (!post.authors?.length) { addAuthorToGraph(post.senderId, 1, tokenId); } else { for (const { publicKey, weight } of post.authors) { // If the sender is also listed among the authors, do not mint them an additional token. const authorTokenId = (publicKey === post.senderId) ? tokenId : this.dao.reputation.mint(this.id, 0); addAuthorToGraph(publicKey, weight, authorTokenId); } // If the sender is not an author, they will end up with the minted token but with zero value. if (!post.authors.find(({ publicKey }) => publicKey === post.senderId)) { addAuthorToGraph(post.senderId, 0, tokenId); } } const rewardsAccumulator = new Map(); // Compute reputation rewards await this.propagateValue( { to: postVertex, from: { data: pool } }, { rewardsAccumulator, increment: initialValue, referenceChainLimit, leachingValue, }, ); // Apply computed rewards to update values of tokens for (const [authorEdge, amount] of rewardsAccumulator) { const { to: authorVertex, data: { tokenId: authorTokenId } } = authorEdge; const { data: author } = authorVertex; // The primary author gets the validation pool minted token. // So we don't need to transfer any reputation to the primary author. // Their reward will be the remaining balance after all other transfers. if (authorTokenId !== tokenId) { if (amount < 0) { this.dao.reputation.transferValueFrom(authorTokenId, tokenId, -amount); } else { this.dao.reputation.transferValueFrom(tokenId, authorTokenId, amount); } await author.computeDisplayValues(); authorVertex.setDisplayLabel(author.getLabel()); } } const senderVertex = this.graph.getVertex(post.senderId); const { data: sender } = senderVertex; await sender.computeDisplayValues(); senderVertex.setDisplayLabel(sender.getLabel()); // Transfer ownership of the minted tokens to the authors for (const authorEdge of postVertex.getEdges(EdgeTypes.AUTHOR, true)) { const authorVertex = authorEdge.to; const author = authorVertex.data; const { tokenId: authorTokenId } = authorEdge.data; this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenId); } } /** * @param {Edge} edge * @param {Object} opaqueData */ async propagateValue(edge, { rewardsAccumulator, increment, depth = 0, initialNegative = false, referenceChainLimit, leachingValue, }) { const postVertex = edge.to; const post = postVertex.data; this.actions.propagate.log(edge.from.data, post, `(${increment})`); if (!!referenceChainLimit && depth > referenceChainLimit) { this.actions.propagate.log( edge.from.data, post, `referenceChainLimit (${referenceChainLimit}) reached`, null, '-x', ); return increment; } console.log('propagateValue start', { from: edge.from.id ?? edge.from, to: edge.to.id, depth, value: post.value, increment, initialNegative, }); const propagate = async (positive) => { let totalOutboundAmount = 0; const citationEdges = postVertex.getEdges(EdgeTypes.CITATION, true) .filter(({ weight }) => (positive ? weight > 0 : weight < 0)); for (const citationEdge of citationEdges) { const { weight } = citationEdge; let outboundAmount = weight * increment; if (Math.abs(outboundAmount) > EPSILON) { const balanceToOutbound = this.graph.getEdgeWeight(EdgeTypes.BALANCE, citationEdge.from, citationEdge.to) ?? 0; let refundFromOutbound = 0; // Special case: Incineration. if (citationEdge.to.id === INCINERATOR_ADDRESS) { // Only a positive amount may be incinerated! Otherwise the sink could be used as a source. if (outboundAmount < 0) { this.scene?.flowchart?.log(`style ${citationEdge.from.id} fill:#620000`); this.actions.propagate.log( citationEdge.from.data, { name: 'Incinerator' }, `(${increment})`, undefined, '-x', ); throw new Error('Incinerator can only receive positive citations!'); } // Reputation sent to the incinerator is burned! This means it is deducted from the sender, // without increasing the value of any other token. this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, `(${increment})`); } else { // We need to ensure that we at most undo the prior effects of this post if (initialNegative) { outboundAmount = outboundAmount < 0 ? Math.max(outboundAmount, -balanceToOutbound) : Math.min(outboundAmount, -balanceToOutbound); } // Recursively propagate reputation effects refundFromOutbound = await this.propagateValue(citationEdge, { rewardsAccumulator, increment: outboundAmount, depth: depth + 1, initialNegative: initialNegative || (depth === 0 && outboundAmount < 0), referenceChainLimit, leachingValue, }); // Any excess (negative) amount that could not be propagated, // i.e. because a cited post has been reduced to zero value, // is retained by the citing post. outboundAmount -= refundFromOutbound; } // Keep a record of the effect of the reputation transferred along this edge in the graph, // so that later, negative citations can be constrained to at most undo these effects. this.graph.setEdgeWeight( EdgeTypes.BALANCE, citationEdge.from, citationEdge.to, balanceToOutbound + outboundAmount, ); totalOutboundAmount += outboundAmount; this.actions.confirm.log( citationEdge.to.data, citationEdge.from.data, `(refund: ${displayNumber(refundFromOutbound)}, leach: ${outboundAmount * leachingValue})`, undefined, '-->>', ); } } return totalOutboundAmount; }; // First, leach value via negative citations const totalLeachingAmount = await propagate(false); increment -= totalLeachingAmount * leachingValue; // Now propagate value via positive citations const totalDonationAmount = await propagate(true); increment -= totalDonationAmount * leachingValue; // Apply the remaining increment to the present post const rawNewValue = post.value + increment; const newValue = Math.max(0, rawNewValue); const appliedIncrement = newValue - post.value; const refundToInbound = increment - appliedIncrement; // Apply reputation effects to post authors, not to the post directly for (const authorEdge of postVertex.getEdges(EdgeTypes.AUTHOR, true)) { const { weight, to: { data: author } } = authorEdge; const authorIncrement = weight * appliedIncrement; rewardsAccumulator.set(authorEdge, authorIncrement); this.actions.propagate.log(post, author, `(${authorIncrement})`); } console.log('propagateValue end', { depth, increment, rawNewValue, newValue, appliedIncrement, refundToInbound, }); // Increment the value of the post await post.setValue(newValue); return refundToInbound; } }