Work in progress: prototyping forum network
This commit is contained in:
parent
5036a1a8e3
commit
715943ec77
93
README.md
93
README.md
|
@ -1,92 +1,7 @@
|
|||
# Science Publishing DAO
|
||||
|
||||
## Subprojects
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
|
||||
## Add your files
|
||||
|
||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://gitlab.com/dao-governance-framework/science-publishing-dao.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
|
||||
- [ ] [Set up project integrations](https://gitlab.com/dao-governance-framework/science-publishing-dao/-/settings/integrations)
|
||||
|
||||
## Collaborate with your team
|
||||
|
||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
|
||||
|
||||
## Test and Deploy
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
|
||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
|
||||
***
|
||||
|
||||
# Editing this README
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
|
||||
## Suggestions for a good README
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| [forum-network](./forum-network) | Javascript prototyping forum architecture |
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: 'airbnb-base',
|
||||
overrides: [
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
},
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
ssl/
|
||||
node_modules/
|
||||
git/
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "forum-network",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"eslint": "^8.27.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"prettier": "^2.7.1"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
export class Action {
|
||||
constructor(name, scene) {
|
||||
this.name = name;
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
log(src, dest, msg, obj, symbol = '->>') {
|
||||
const logObj = false;
|
||||
this.scene.log(
|
||||
`${src.name} ${symbol} ${dest.name} : ${this.name} ${msg ?? ''} ${
|
||||
logObj && obj ? JSON.stringify(obj) : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Actor {
|
||||
constructor(name, scene) {
|
||||
this.name = name;
|
||||
this.scene = scene;
|
||||
this.callbacks = new Map();
|
||||
this.status = this.scene.addDisplayValue(`${this.name} status`);
|
||||
this.status.set('New');
|
||||
this.scene.log(`participant ${this.name}`);
|
||||
}
|
||||
|
||||
send(dest, action, detail) {
|
||||
action.log(this, dest, detail ? JSON.stringify(detail) : '');
|
||||
dest.recv(this, action, detail);
|
||||
return this;
|
||||
}
|
||||
|
||||
recv(src, action, detail) {
|
||||
const cb = this.callbacks.get(action.name);
|
||||
if (!cb) {
|
||||
throw new Error(
|
||||
`[${this.scene.name} actor ${this.name} does not have a callback registered for ${action.name}`,
|
||||
);
|
||||
}
|
||||
cb(src, detail);
|
||||
return this;
|
||||
}
|
||||
|
||||
on(action, cb) {
|
||||
this.callbacks.set(action.name, cb);
|
||||
return this;
|
||||
}
|
||||
|
||||
setStatus(status) {
|
||||
this.status.set(status);
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
class Blockchain {
|
||||
constructor() {
|
||||
this.posts = new CollectionWithReputation();
|
||||
this.authors = new CollectionWithReputation();
|
||||
this.nodes = new CollectionWithReputation();
|
||||
}
|
||||
|
||||
vote(voter, batch) {
|
||||
if (!this.activeVote) {
|
||||
this.activeVote = new VoteInstance(batch);
|
||||
this.activeVote.vote(voter, true);
|
||||
return;
|
||||
}
|
||||
if (this.activeVote.matches(batch)) {
|
||||
this.activeVote.vote(voter, true);
|
||||
} else {
|
||||
this.activeVote.vote(voter, false);
|
||||
}
|
||||
}
|
||||
|
||||
applyBatch(batch) {}
|
||||
}
|
||||
|
||||
class CollectionWithReputation {
|
||||
constructor() {
|
||||
this.collection = new Map();
|
||||
this.reputations = new Map();
|
||||
}
|
||||
|
||||
set(id, value) {
|
||||
this.collection.set(id, value);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
this.collection.get(id);
|
||||
}
|
||||
|
||||
setReputation(id, value) {
|
||||
this.reputations.set(id, value);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
this.reputations.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
class VoteInstance {
|
||||
constructor(content) {
|
||||
this.content = content;
|
||||
this.votes = new Map();
|
||||
}
|
||||
|
||||
matches(content) {
|
||||
return JSON.stringify(content) === JSON.stringify(this.content);
|
||||
}
|
||||
|
||||
vote(voter, opinion) {
|
||||
this.votes.set(voter.id, { voter, opinion });
|
||||
}
|
||||
|
||||
finalize() {
|
||||
const count = { for: 0, against: 0 };
|
||||
for (const vote of this.votes) {
|
||||
if (vote.opinion === true) {
|
||||
count.for++;
|
||||
} else {
|
||||
count.against++;
|
||||
}
|
||||
}
|
||||
return count.for > count.against;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
export class CryptoUtil {
|
||||
static algorithm = 'RSASSA-PKCS1-v1_5';
|
||||
|
||||
static hash = 'SHA-256';
|
||||
|
||||
static async generateSigningKey() {
|
||||
return await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: CryptoUtil.algorithm,
|
||||
hash: CryptoUtil.hash,
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
},
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
);
|
||||
}
|
||||
|
||||
static async sign(content, privateKey) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = encoder.encode(content);
|
||||
const signature = await window.crypto.subtle.sign(CryptoUtil.algorithm, privateKey, encoded);
|
||||
// Return base64-encoded signature
|
||||
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||
}
|
||||
|
||||
static async verify(content, b64publicKey, b64signature) {
|
||||
// Convert base64 javascript web key to CryptoKey
|
||||
const publicKey = await CryptoUtil.importKey(b64publicKey);
|
||||
// Convert base64 signature to an ArrayBuffer
|
||||
const signature = Uint8Array.from(atob(b64signature), (c) => c.charCodeAt(0));
|
||||
// TODO: make a single TextEncoder instance and reuse it
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = encoder.encode(content);
|
||||
return await window.crypto.subtle.verify(CryptoUtil.algorithm, publicKey, signature, encoded);
|
||||
}
|
||||
|
||||
static async exportKey(publicKey) {
|
||||
// Store public key as base64 javascript web key
|
||||
const jwk = await window.crypto.subtle.exportKey('jwk', publicKey);
|
||||
return btoa(JSON.stringify(jwk));
|
||||
}
|
||||
|
||||
static async importKey(b64jwk) {
|
||||
// Convert base64 javascript web key to CryptoKey
|
||||
const jwk = JSON.parse(atob(b64jwk));
|
||||
return await window.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{
|
||||
name: CryptoUtil.algorithm,
|
||||
hash: CryptoUtil.hash,
|
||||
},
|
||||
false,
|
||||
['verify'],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
export class DisplayValue {
|
||||
constructor(name, box) {
|
||||
this.value = undefined;
|
||||
this.name = name;
|
||||
this.box = box;
|
||||
this.nameBox = this.box.addBox(`${this.name}-name`).addClass('name');
|
||||
this.valueBox = this.box.addBox(`${this.name}-value`).addClass('value');
|
||||
this.nameBox.setInnerHTML(this.name);
|
||||
}
|
||||
|
||||
render() {
|
||||
this.valueBox.setInnerHTML(this.value);
|
||||
}
|
||||
|
||||
set(value) {
|
||||
this.value = value;
|
||||
this.render();
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export class Box {
|
||||
constructor(name, parentEl) {
|
||||
this.el = document.createElement('div');
|
||||
this.el.classList.add('box');
|
||||
this.el.setAttribute('box-name', name);
|
||||
if (parentEl) {
|
||||
parentEl.appendChild(this.el);
|
||||
}
|
||||
}
|
||||
|
||||
flex() {
|
||||
this.el.classList.add('flex');
|
||||
return this;
|
||||
}
|
||||
|
||||
monospace() {
|
||||
this.el.classList.add('monospace');
|
||||
return this;
|
||||
}
|
||||
|
||||
addClass(className) {
|
||||
this.el.classList.add(className);
|
||||
return this;
|
||||
}
|
||||
|
||||
addBox(name) {
|
||||
const box = new Box(name);
|
||||
this.el.appendChild(box.el);
|
||||
return box;
|
||||
}
|
||||
|
||||
addDisplayValue(value) {
|
||||
const box = this.addBox(value.name).flex();
|
||||
return new DisplayValue(value, box);
|
||||
}
|
||||
|
||||
setInnerHTML(html) {
|
||||
this.el.innerHTML = html;
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
export class ForumNetwork {
|
||||
constructor() {
|
||||
this.nodes = new Map();
|
||||
}
|
||||
|
||||
addNode(node) {
|
||||
this.nodes.set(node.keyPair.publicKey, node);
|
||||
return this;
|
||||
}
|
||||
|
||||
listNodes() {
|
||||
return Array.from(this.nodes.values());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import { Actor, Action } from './actor.js';
|
||||
import { Message, PostMessage, PeerMessage } from './message.js';
|
||||
import { CryptoUtil } from './crypto.js';
|
||||
import { ForumView } from './forum-view.js';
|
||||
import { PrioritizedQueue } from './prioritized-queue.js';
|
||||
|
||||
export class ForumNode extends Actor {
|
||||
constructor(name, scene) {
|
||||
super(name, scene);
|
||||
this.forumView = new ForumView();
|
||||
this.queue = new PrioritizedQueue();
|
||||
this.actions = {
|
||||
storePost: new Action('store post', scene),
|
||||
peerMessage: new Action('peer message', scene),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a signing key pair and connect to the network
|
||||
async initialize(forumNetwork) {
|
||||
this.keyPair = await CryptoUtil.generateSigningKey();
|
||||
this.forumNetwork = forumNetwork.addNode(this);
|
||||
this.status.set('Initialized');
|
||||
return this;
|
||||
}
|
||||
|
||||
// Send a message to all other nodes in the network
|
||||
async broadcast(message) {
|
||||
await message.sign(this.keyPair);
|
||||
const otherForumNodes = this.forumNetwork
|
||||
.listNodes()
|
||||
.filter((forumNode) => forumNode.keyPair.publicKey !== this.keyPair.publicKey);
|
||||
for (const forumNode of otherForumNodes) {
|
||||
// For now just call receiveMessage on the target node
|
||||
this.actions.peerMessage.log(this, forumNode, null, message.content);
|
||||
await forumNode.receiveMessage(JSON.stringify(message.toJSON()));
|
||||
}
|
||||
}
|
||||
|
||||
// Perform minimal processing to ingest a message.
|
||||
// Enqueue it for further processing.
|
||||
async receiveMessage(messageStr) {
|
||||
const messageJson = JSON.parse(messageStr);
|
||||
const senderReputation = this.forumView.getReputation(messageJson.publicKey) || 0;
|
||||
this.queue.add(messageJson, senderReputation);
|
||||
}
|
||||
|
||||
// Process next highest priority message in the queue
|
||||
async processNextMessage() {
|
||||
const messageJson = this.queue.pop();
|
||||
if (!messageJson) {
|
||||
return null;
|
||||
}
|
||||
await this.processMessage(messageJson);
|
||||
}
|
||||
|
||||
// Process a message from the queue
|
||||
async processMessage(messageJson) {
|
||||
try {
|
||||
await Message.verify(messageJson);
|
||||
} catch (e) {
|
||||
this.actions.processMessage.log(this, this, 'invalid signature', messageJson, '-x');
|
||||
console.log(`${this.name}: received message with invalid signature`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { publicKey } = messageJson;
|
||||
const message = Message.fromJSON(messageJson);
|
||||
console.log(`${this.name}: processMessage`, message);
|
||||
|
||||
if (message instanceof PostMessage) {
|
||||
await this.processPostMessage(publicKey, message.content);
|
||||
} else if (message instanceof PeerMessage) {
|
||||
await this.processPeerMessage(publicKey, message.content);
|
||||
} else {
|
||||
// Unknown message type
|
||||
// Penalize sender for wasting our time
|
||||
console.log(`${this.name}: penalizing sender for unknown message type ${message.type}`);
|
||||
this.forumView.incrementReputation(message.publicKey, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// Process an incoming post, received by whatever means
|
||||
processPost(authorId, post, stake) {
|
||||
this.actions.storePost.log(this, this, null, { authorId, post, stake });
|
||||
this.forumView.addPost(authorId, post.id, post, stake);
|
||||
}
|
||||
|
||||
// Process a post we received in a message
|
||||
async processPostMessage(authorId, { post, stake }) {
|
||||
this.processPost(authorId, post, stake);
|
||||
await this.broadcast(
|
||||
new PeerMessage({
|
||||
posts: [{ authorId, post, stake }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Process a message we receive from a peer
|
||||
async processPeerMessage(peerId, { posts }) {
|
||||
// We are trusting that the peer verified the signatures of the posts they're forwarding.
|
||||
// We could instead have the peer forward the signed messages and re-verify them.
|
||||
for (const { authorId, post, stake } of posts) {
|
||||
this.processPost(authorId, post, stake);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
import { Graph } from './graph.js';
|
||||
|
||||
class Author {
|
||||
constructor() {
|
||||
this.posts = new Map();
|
||||
this.reputation = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Consider merging with "client-side" Post class in `./post.js`
|
||||
class Post {
|
||||
constructor(id, author, stake, content, citations) {
|
||||
this.id = id;
|
||||
this.author = author;
|
||||
this.content = content;
|
||||
this.stake = stake;
|
||||
this.citations = citations;
|
||||
this.reputation = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForumView {
|
||||
constructor() {
|
||||
this.reputations = new Map();
|
||||
this.posts = new Graph();
|
||||
this.authors = new Map();
|
||||
}
|
||||
|
||||
getReputation(publicKey) {
|
||||
return this.reputations.get(publicKey);
|
||||
}
|
||||
|
||||
incrementReputation(publicKey, increment, reason) {
|
||||
const reputation = this.getReputation(publicKey) || 0;
|
||||
return this.reputations.set(publicKey, reputation + increment);
|
||||
}
|
||||
|
||||
getOrInitializeAuthor(authorId) {
|
||||
let author = this.authors.get(authorId);
|
||||
if (!author) {
|
||||
author = new Author(authorId);
|
||||
this.authors.set(authorId, author);
|
||||
}
|
||||
return author;
|
||||
}
|
||||
|
||||
addPost(authorId, postId, { citations = [], content }, stake) {
|
||||
const author = this.getOrInitializeAuthor(authorId);
|
||||
const post = new Post(postId, author, stake, content, citations);
|
||||
this.posts.addVertex(postId, post);
|
||||
for (const citation of citations) {
|
||||
this.posts.addEdge('citation', postId, citation.postId, citation);
|
||||
}
|
||||
this.applyNonbindingReputationEffects(post);
|
||||
}
|
||||
|
||||
getPost(postId) {
|
||||
return this.posts.getVertexData(postId);
|
||||
}
|
||||
|
||||
getPosts() {
|
||||
return this.posts.getVertices();
|
||||
}
|
||||
|
||||
// We'll start with naieve implementations of the computations we need.
|
||||
|
||||
// We want to derive a value -- maybe call it a reputation score -- for each post.
|
||||
// This value is a recursive sum of contributions from citations.
|
||||
|
||||
// There should be a diminishment of effect upon each recursion,
|
||||
// perhaps following a geometric progression.
|
||||
|
||||
// Each post gets some initial score due to the reputation that the author stakes.
|
||||
|
||||
// Citations are weighted, and can be positive or negative.
|
||||
|
||||
// So each post has a reputation score. Each author also has a reputation score.
|
||||
// The value of the author's reputation score could be a factor in the magnitude of the effects of their citations.
|
||||
// Post_rep = (Author_rep * stake);
|
||||
//
|
||||
|
||||
// Options:
|
||||
// - update a state model incrementally with each action in the history (/unfolding present) of the forum,
|
||||
// in order to arrive at the current view.
|
||||
|
||||
// When an author stakes reputation on a post, if it's a non-binding stake, then it merely expresses opinion.
|
||||
// If it's a binding stake, then they may lose the staked reputation as a result of other posts staking reputation against theirs.
|
||||
|
||||
citationFraction = 0.3;
|
||||
|
||||
applyNonbindingReputationEffects(newPost) {
|
||||
this.distributeNonbindingReputation(newPost, newPost, newPost.stake);
|
||||
}
|
||||
|
||||
distributeNonbindingReputation(newPost, post, amount, depth = 0) {
|
||||
console.log('distributeNonbindingReputation', { post, amount, depth });
|
||||
// Some of the incoming reputation goes to this post
|
||||
post.reputation += amount * (1 - this.citationFraction);
|
||||
// Some of the incoming reputation gets distributed among cited posts
|
||||
const distributeAmongCitations = amount * this.citationFraction;
|
||||
|
||||
// citation weights can be interpreted as a ratio, or we can somehow constrain the input to add up to some specified total.
|
||||
// It's easy enough to let them be on any arbitrary scale and just compute the ratios here.
|
||||
const totalWeight = post.citations
|
||||
?.map(({ weight }) => weight)
|
||||
.reduce((acc, cur) => (acc += cur), 0);
|
||||
|
||||
post.citations?.forEach((citation) => {
|
||||
const citedPost = this.getPost(citation.postId);
|
||||
if (!citedPost) {
|
||||
// TODO: Here is where we may want to engage our peer protocol to query for possible missing records
|
||||
throw new Error(`Post ${post.postId} cites unknown post ${citation.postId}`);
|
||||
}
|
||||
this.distributeNonbindingReputation(
|
||||
newPost,
|
||||
citedPost,
|
||||
(citation.weight / totalWeight) * distributeAmongCitations,
|
||||
depth + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
export class Vertex {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
this.edges = {
|
||||
from: [],
|
||||
to: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Edge {
|
||||
constructor(label, from, to, data) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.label = label;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
export class Graph {
|
||||
constructor() {
|
||||
this.vertices = new Map();
|
||||
this.edgeLabels = new Map();
|
||||
this.nextVertexId = 0;
|
||||
}
|
||||
|
||||
addVertex(id, data) {
|
||||
// Support simple case of auto-incremented numeric ids
|
||||
if (typeof id === 'object') {
|
||||
data = id;
|
||||
id = this.nextVertexId++;
|
||||
}
|
||||
const vertex = new Vertex(data);
|
||||
this.vertices.set(id, vertex);
|
||||
return this;
|
||||
}
|
||||
|
||||
getVertex(id) {
|
||||
return this.vertices.get(id);
|
||||
}
|
||||
|
||||
getVertexData(id) {
|
||||
return this.getVertex(id)?.data;
|
||||
}
|
||||
|
||||
getVertices() {
|
||||
return Array.from(this.vertices.values()).map(({ data }) => data);
|
||||
}
|
||||
|
||||
getEdge(label, from, to) {
|
||||
const edges = this.edgeLabels.get(label);
|
||||
return edges?.get(JSON.stringify({ from, to }));
|
||||
}
|
||||
|
||||
setEdge(label, from, to, edge) {
|
||||
let edges = this.edgeLabels.get(label);
|
||||
if (!edges) {
|
||||
edges = new Map();
|
||||
this.edgeLabels.set(label, edges);
|
||||
}
|
||||
edges.set(JSON.stringify({ from, to }), edge);
|
||||
}
|
||||
|
||||
addEdge(label, from, to, data) {
|
||||
if (this.getEdge(label, from, to)) {
|
||||
throw new Error(`Edge ${label} from ${from} to ${to} already exists`);
|
||||
}
|
||||
const edge = new Edge(label, from, to, data);
|
||||
this.setEdge(label, from, to, edge);
|
||||
this.getVertex(from).edges.from.push(edge);
|
||||
this.getVertex(to).edges.to.push(edge);
|
||||
return this;
|
||||
}
|
||||
|
||||
getEdges(label, from, to) {
|
||||
const edgeLabels = label ? [label] : Array.from(this.edgeLabels.keys());
|
||||
return edgeLabels.flatMap((edgeLabel) => {
|
||||
const edges = this.edgeLabels.get(edgeLabel);
|
||||
return Array.from(edges?.values() || []).filter((edge) => {
|
||||
const matchFrom = from === null || from === undefined || from === edge.from;
|
||||
const matchTo = to === null || to === undefined || to === edge.to;
|
||||
return matchFrom && matchTo;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { Actor, Action } from './actor.js';
|
||||
import { PostMessage } from './message.js';
|
||||
import { CryptoUtil } from './crypto.js';
|
||||
|
||||
export class Member extends Actor {
|
||||
constructor(name, scene) {
|
||||
super(name, scene);
|
||||
this.actions = {
|
||||
submitPost: new Action('submit post', scene),
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.keyPair = await CryptoUtil.generateSigningKey();
|
||||
this.status.set('Initialized');
|
||||
return this;
|
||||
}
|
||||
|
||||
async submitPost(forumNode, post, stake) {
|
||||
const postMessage = new PostMessage({ post, stake });
|
||||
await postMessage.sign(this.keyPair);
|
||||
this.actions.submitPost.log(this, forumNode, null, { id: post.id });
|
||||
// For now, directly call forumNode.receiveMessage();
|
||||
await forumNode.receiveMessage(JSON.stringify(postMessage.toJSON()));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { CryptoUtil } from './crypto.js';
|
||||
import { Post } from './post.js';
|
||||
|
||||
export class Message {
|
||||
constructor(content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
async sign({ publicKey, privateKey }) {
|
||||
this.publicKey = await CryptoUtil.exportKey(publicKey);
|
||||
// Call toJSON before signing, to match what we'll later send
|
||||
this.signature = await CryptoUtil.sign(this.contentToJSON(this.content), privateKey);
|
||||
return this;
|
||||
}
|
||||
|
||||
static async verify({ content, publicKey, signature }) {
|
||||
return await CryptoUtil.verify(content, publicKey, signature);
|
||||
}
|
||||
|
||||
static contentFromJSON(data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
contentToJSON(content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
static fromJSON({ type, content }) {
|
||||
const messageType = messageTypes.get(type) || Message;
|
||||
const messageContent = messageType.contentFromJSON(content);
|
||||
return new messageType(messageContent);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
content: this.contentToJSON(this.content),
|
||||
publicKey: this.publicKey,
|
||||
signature: this.signature,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PostMessage extends Message {
|
||||
type = 'post';
|
||||
|
||||
static contentFromJSON({ post, stake }) {
|
||||
return {
|
||||
post: Post.fromJSON(post),
|
||||
stake,
|
||||
};
|
||||
}
|
||||
|
||||
contentToJSON({ post, stake }) {
|
||||
return {
|
||||
post: post.toJSON(),
|
||||
stake,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PeerMessage extends Message {
|
||||
type = 'peer';
|
||||
}
|
||||
|
||||
const messageTypes = new Map([
|
||||
['post', PostMessage],
|
||||
['peer', PeerMessage],
|
||||
]);
|
|
@ -0,0 +1,46 @@
|
|||
export class Post {
|
||||
constructor(content) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.content = content;
|
||||
this.citations = [];
|
||||
}
|
||||
|
||||
addCitation(postId, weight) {
|
||||
const citation = new Citation(postId, weight);
|
||||
this.citations.push(citation);
|
||||
return this;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
content: this.content,
|
||||
citations: this.citations.map((citation) => citation.toJSON()),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON({ id, content, citations }) {
|
||||
const post = new Post(content);
|
||||
post.id = id;
|
||||
post.citations = citations.map((citation) => Citation.fromJSON(citation));
|
||||
return post;
|
||||
}
|
||||
}
|
||||
|
||||
export class Citation {
|
||||
constructor(postId, weight) {
|
||||
this.postId = postId;
|
||||
this.weight = weight;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
postId: this.postId,
|
||||
weight: this.weight,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON({ postId, weight }) {
|
||||
return new Citation(postId, weight);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
export class PrioritizedQueue {
|
||||
constructor() {
|
||||
this.buffer = [];
|
||||
}
|
||||
|
||||
// Add an item to the buffer, ahead of the next lowest priority item
|
||||
add(message, priority) {
|
||||
const idx = this.buffer.findIndex((item) => item.priority < priority);
|
||||
if (idx < 0) {
|
||||
this.buffer.push({ message, priority });
|
||||
} else {
|
||||
this.buffer.splice(idx, 0, { message, priority });
|
||||
}
|
||||
}
|
||||
|
||||
// Return the highest priority item in the buffer
|
||||
pop() {
|
||||
if (!this.buffer.length) {
|
||||
return null;
|
||||
}
|
||||
const item = this.buffer.shift();
|
||||
return item.message;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
class Reputation {
|
||||
constructor() {}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { Actor, Action } from './actor.js';
|
||||
|
||||
export class Scene {
|
||||
constructor(name, rootBox) {
|
||||
this.name = name;
|
||||
this.box = rootBox.addBox(name);
|
||||
this.titleBox = this.box.addBox().setInnerHTML(name);
|
||||
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||
this.displayValuesBox = this.box.addBox(`${this.name}-values`);
|
||||
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||
this.logBox = this.box.addBox(`${this.name}-log`);
|
||||
}
|
||||
|
||||
addActor(name) {
|
||||
const actor = new Actor(name, this);
|
||||
return actor;
|
||||
}
|
||||
|
||||
addAction(name) {
|
||||
const action = new Action(name, this);
|
||||
return action;
|
||||
}
|
||||
|
||||
addDisplayValue(name) {
|
||||
const dv = this.displayValuesBox.addDisplayValue(name);
|
||||
return dv;
|
||||
}
|
||||
|
||||
log(msg) {
|
||||
this.logBox.addBox().setInnerHTML(msg).monospace();
|
||||
return this;
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Forum</title>
|
||||
<script type="module" src="./forum-test.js" defer></script>
|
||||
<link type="text/css" rel="stylesheet" href="./index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="forum-network"></div>
|
||||
</body>
|
|
@ -0,0 +1,40 @@
|
|||
import { Box } from './classes/display-value.js';
|
||||
import { Scene } from './classes/scene.js';
|
||||
import { Post } from './classes/post.js';
|
||||
import { Member } from './classes/member.js';
|
||||
import { ForumNode } from './classes/forum-node.js';
|
||||
import { ForumNetwork } from './classes/forum-network.js';
|
||||
|
||||
const delay = async (ms) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById('forum-network');
|
||||
const rootBox = new Box('rootBox', rootElement).flex();
|
||||
|
||||
window.scene = new Scene('Forum test', rootBox).log('sequenceDiagram');
|
||||
|
||||
window.author1 = await new Member('author1', window.scene).initialize();
|
||||
window.author2 = await new Member('author2', window.scene).initialize();
|
||||
|
||||
window.forumNetwork = new ForumNetwork();
|
||||
|
||||
window.forumNode1 = await new ForumNode('node1', window.scene).initialize(window.forumNetwork);
|
||||
window.forumNode2 = await new ForumNode('node2', window.scene).initialize(window.forumNetwork);
|
||||
window.forumNode3 = await new ForumNode('node3', window.scene).initialize(window.forumNetwork);
|
||||
|
||||
setInterval(async () => {
|
||||
await window.forumNode1.processNextMessage();
|
||||
await window.forumNode2.processNextMessage();
|
||||
await window.forumNode3.processNextMessage();
|
||||
}, 100);
|
||||
|
||||
// const blockchain = new Blockchain();
|
||||
|
||||
window.post1 = new Post({ message: 'hi' });
|
||||
window.post2 = new Post({ message: 'hello' }).addCitation(window.post1.id, 1.0);
|
||||
|
||||
await delay(1000);
|
||||
await window.author1.submitPost(window.forumNode1, window.post1, 50);
|
||||
await delay(1000);
|
||||
await window.author2.submitPost(window.forumNode2, window.post2, 100);
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Forum Graph</title>
|
||||
<script type="module" src="./graph-test.js" defer></script>
|
||||
<link type="text/css" rel="stylesheet" href="./index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph-test"></div>
|
||||
</body>
|
|
@ -0,0 +1,23 @@
|
|||
import { Box } from './classes/display-value.js';
|
||||
import { Scene } from './classes/scene.js';
|
||||
import { Graph } from './classes/graph.js';
|
||||
|
||||
const rootElement = document.getElementById('graph-test');
|
||||
const rootBox = new Box('rootBox', rootElement).flex();
|
||||
|
||||
window.scene = new Scene('Graph test', rootBox);
|
||||
|
||||
window.graph = new Graph();
|
||||
|
||||
window.v = [];
|
||||
function addVertex() {
|
||||
const vertex = window.graph.addVertex({ seq: window.v.length });
|
||||
window.v.push(vertex);
|
||||
}
|
||||
addVertex();
|
||||
addVertex();
|
||||
addVertex();
|
||||
addVertex();
|
||||
addVertex();
|
||||
|
||||
window.graph.addEdge('e1', 0, 1);
|
|
@ -0,0 +1,24 @@
|
|||
.box {
|
||||
// border: 1px #eee solid;
|
||||
width: fit-content;
|
||||
font-family: sans-serif;
|
||||
font-size: 12pt;
|
||||
}
|
||||
.box .name {
|
||||
width: 10em;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
margin-right: 6pt;
|
||||
}
|
||||
.box .value {
|
||||
width: fit-content;
|
||||
// border: 0px;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.monospace {
|
||||
// border: 0px;
|
||||
font-family: monospace;
|
||||
font-size: 11pt;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Forum Network</title>
|
||||
<script src="./classes/display-value.js"></script>
|
||||
<script src="./classes/actor.js"></script>
|
||||
<script src="./classes/scene.js"></script>
|
||||
<script src="./classes/reputation.js"></script>
|
||||
<script src="./classes/forum.js"></script>
|
||||
<script src="./index.js" defer></script>
|
||||
<link type="text/css" rel="stylesheet" href="./index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="forum-network"></div>
|
||||
</body>
|
|
@ -0,0 +1,188 @@
|
|||
const rootElement = document.getElementById('forum-network');
|
||||
const rootBox = new Box('rootBox', rootElement).flex();
|
||||
|
||||
function randomDelay(min, max) {
|
||||
const delayMs = min + Math.random() * max;
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
function delay(min, max = min) {
|
||||
const delayMs = min + Math.random() * (max - min);
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
if (true) {
|
||||
const scene = new Scene('Scene 1', rootBox);
|
||||
const webClientStatus = scene.addDisplayValue('WebClient Status');
|
||||
const node1Status = scene.addDisplayValue('Node 1 Status');
|
||||
const blockchainStatus = scene.addDisplayValue('Blockchain Status');
|
||||
|
||||
const webClient = scene.addActor('web client');
|
||||
const node1 = scene.addActor('node 1');
|
||||
const blockchain = scene.addActor('blockchain');
|
||||
const requestForumPage = scene.addAction('requestForumPage');
|
||||
const readBlockchainData = scene.addAction('readBlockchainData');
|
||||
const blockchainData = scene.addAction('blockchainData');
|
||||
const forumPage = scene.addAction('forumPage');
|
||||
|
||||
webClientStatus.set('Initialized');
|
||||
node1Status.set('Idle');
|
||||
blockchainStatus.set('Idle');
|
||||
|
||||
node1.on(requestForumPage, (src, detail) => {
|
||||
node1Status.set('Processing request');
|
||||
node1.on(blockchainData, (_src, data) => {
|
||||
node1Status.set('Processing response');
|
||||
setTimeout(() => {
|
||||
node1.send(src, forumPage, data);
|
||||
node1Status.set('Idle');
|
||||
}, randomDelay(500, 1000));
|
||||
});
|
||||
setTimeout(() => {
|
||||
node1.send(blockchain, readBlockchainData, detail);
|
||||
}, randomDelay(500, 1500));
|
||||
});
|
||||
|
||||
blockchain.on(readBlockchainData, (src, detail) => {
|
||||
blockchainStatus.set('Processing request');
|
||||
setTimeout(() => {
|
||||
blockchain.send(src, blockchainData, {});
|
||||
blockchainStatus.set('Idle');
|
||||
}, randomDelay(500, 1500));
|
||||
});
|
||||
|
||||
webClient.on(forumPage, (_src, _detail) => {
|
||||
webClientStatus.set('Received forum page');
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
webClient.send(node1, requestForumPage);
|
||||
webClientStatus.set('Requested forum page');
|
||||
}, randomDelay(6000, 12000));
|
||||
}
|
||||
|
||||
(async function () {
|
||||
const scene = new Scene('Scene 2', rootBox);
|
||||
|
||||
const webClient = scene.addActor('webClient');
|
||||
|
||||
const nodes = [];
|
||||
const memories = [];
|
||||
const storages = [];
|
||||
|
||||
function addNode() {
|
||||
const idx = nodes.length;
|
||||
const node = scene.addActor(`node${idx}`);
|
||||
const memory = scene.addActor(`memory${idx}`);
|
||||
const storage = scene.addActor(`storage${idx}`);
|
||||
node.memory = memory;
|
||||
node.storage = storage;
|
||||
nodes.push(node);
|
||||
memories.push(memory);
|
||||
storages.push(storage);
|
||||
return node;
|
||||
}
|
||||
|
||||
function getPeer(node) {
|
||||
const peers = nodes.filter((peer) => peer !== node);
|
||||
const idx = Math.floor(Math.random() * peers.length);
|
||||
return peers[idx];
|
||||
}
|
||||
|
||||
addNode();
|
||||
addNode();
|
||||
|
||||
const [
|
||||
seekTruth,
|
||||
considerInfo,
|
||||
evaluateConfidence,
|
||||
chooseResponse,
|
||||
qualifiedOpinions,
|
||||
requestMemoryData,
|
||||
memoryData,
|
||||
requestStorageData,
|
||||
storageData,
|
||||
] = [
|
||||
'seek truth',
|
||||
'consider available information',
|
||||
'evaluate confidence',
|
||||
'choose response',
|
||||
'qualified opinions',
|
||||
'request in-memory data',
|
||||
'in-memory data',
|
||||
'request storage data',
|
||||
'storage data',
|
||||
].map((name) => scene.addAction(name));
|
||||
|
||||
memories.forEach((memory) => {
|
||||
memory.setStatus('Idle');
|
||||
memory.on(requestMemoryData, async (src, _detail) => {
|
||||
memory.setStatus('Retrieving data');
|
||||
await delay(1000);
|
||||
memory.send(src, memoryData, {});
|
||||
memory.setStatus('Idle');
|
||||
});
|
||||
});
|
||||
|
||||
storages.forEach((storage) => {
|
||||
storage.setStatus('Idle');
|
||||
storage.on(requestStorageData, async (src, _detail) => {
|
||||
storage.setStatus('Retrieving data');
|
||||
await delay(1000);
|
||||
storage.send(src, storageData, {});
|
||||
storage.setStatus('Idle');
|
||||
});
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.setStatus('Idle');
|
||||
node.on(seekTruth, async (seeker, detail) => {
|
||||
node.setStatus('Processing request');
|
||||
|
||||
node.on(chooseResponse, async (_src, _info) => {
|
||||
node.setStatus('Choosing response');
|
||||
await delay(1000);
|
||||
node.send(seeker, qualifiedOpinions, {});
|
||||
node.setStatus('Idle');
|
||||
});
|
||||
|
||||
node.on(evaluateConfidence, async (_src, _info) => {
|
||||
node.setStatus('Evaluating confidence');
|
||||
await delay(1000);
|
||||
node.send(node, chooseResponse);
|
||||
});
|
||||
|
||||
node.on(considerInfo, async (_src, _info) => {
|
||||
node.setStatus('Considering info');
|
||||
await delay(1000);
|
||||
node.send(node, evaluateConfidence);
|
||||
});
|
||||
|
||||
node.on(memoryData, (_src, _data) => {
|
||||
node.on(storageData, (_src, _data) => {
|
||||
if (detail?.readConcern === 'single') {
|
||||
node.send(node, considerInfo, {});
|
||||
} else {
|
||||
const peer = getPeer(node);
|
||||
node.on(qualifiedOpinions, (_src, info) => {
|
||||
node.send(node, considerInfo, info);
|
||||
});
|
||||
node.send(peer, seekTruth, { readConcern: 'single' });
|
||||
}
|
||||
});
|
||||
node.send(node.storage, requestStorageData);
|
||||
});
|
||||
|
||||
await delay(1000);
|
||||
node.send(node.memory, requestMemoryData);
|
||||
});
|
||||
});
|
||||
|
||||
webClient.on(qualifiedOpinions, (_src, _detail) => {
|
||||
webClient.setStatus('Received opinions and qualifications');
|
||||
});
|
||||
|
||||
await delay(1000);
|
||||
webClient.setStatus('Seek truth');
|
||||
webClient.send(nodes[0], seekTruth);
|
||||
}());
|
Loading…
Reference in New Issue