dao-governance-framework/forum-network/src/classes/dao/forum.js

331 lines
12 KiB
JavaScript

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}
<table><tr>
<td>initial</td>
<td>${displayNumber(this.initialValue)}</td>
</tr><tr>
<td>value</td>
<td>${displayNumber(this.value)}</td>
</tr></table>`
.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;
}
}