339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
import { WDAG } from '../supporting/wdag.js';
|
|
import { Action } from '../display/action.js';
|
|
import { Actor } from '../display/actor.js';
|
|
import params from '../../params.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;
|
|
const leachingTotal = this.citations
|
|
.filter(({ weight }) => weight < 0)
|
|
.reduce((total, { weight }) => total += -weight, 0);
|
|
const donationTotal = this.citations
|
|
.filter(({ weight }) => weight > 0)
|
|
.reduce((total, { weight }) => total += weight, 0);
|
|
|
|
// TODO: Move evaluation of these parameters to Validation Pool
|
|
if (leachingTotal > params.revaluationLimit) {
|
|
throw new Error('Post leaching total exceeds revaluation limit '
|
|
+ `(${leachingTotal} > ${params.revaluationLimit})`);
|
|
}
|
|
if (donationTotal > params.revaluationLimit) {
|
|
throw new Error('Post donation total exceeds revaluation limit '
|
|
+ `(${donationTotal} > ${params.revaluationLimit})`);
|
|
}
|
|
if (this.citations.some(({ weight }) => Math.abs(weight) > params.revaluationLimit)) {
|
|
throw new Error(`Each citation magnitude must not exceed revaluation limit ${params.revaluationLimit}`);
|
|
}
|
|
}
|
|
|
|
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 WDAG(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,
|
|
}) {
|
|
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 },
|
|
);
|
|
|
|
// 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,
|
|
}) {
|
|
const postVertex = edge.to;
|
|
const post = postVertex.data;
|
|
this.actions.propagate.log(edge.from.data, post, `(${increment})`);
|
|
|
|
if (!!params.referenceChainLimit && depth > params.referenceChainLimit) {
|
|
this.actions.propagate.log(
|
|
edge.from.data,
|
|
post,
|
|
`referenceChainLimit (${params.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),
|
|
});
|
|
|
|
// 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 * params.leachingValue})`,
|
|
undefined,
|
|
'-->>',
|
|
);
|
|
}
|
|
}
|
|
return totalOutboundAmount;
|
|
};
|
|
|
|
// First, leach value via negative citations
|
|
const totalLeachingAmount = await propagate(false);
|
|
increment -= totalLeachingAmount * params.leachingValue;
|
|
|
|
// Now propagate value via positive citations
|
|
const totalDonationAmount = await propagate(true);
|
|
increment -= totalDonationAmount * params.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})`);
|
|
}
|
|
|
|
// Increment the value of the post
|
|
await post.setValue(newValue);
|
|
|
|
console.log('propagateValue end', {
|
|
depth,
|
|
increment,
|
|
rawNewValue,
|
|
newValue,
|
|
appliedIncrement,
|
|
refundToInbound,
|
|
});
|
|
|
|
return refundToInbound;
|
|
}
|
|
}
|