Create a node for each Discourse like (#1587)

*Let's use the syntax `(node)` to represent some node, and `> edge >` to
represent some edge.*

In the past, for every like, we would create the following graph
structure:

`(user) > likes > (post)`

As of this commit, we instead create:

`(user) > createsLike > (like) > likes > (post)`

We make this change because we want to mint cred for likes. Arguably,
this is more robust than minting cred for activity: something being
liked signals that at least one person in the community found a post
valuable, so you can think of moving cred minting away from raw activity
and towards likes as a sort of implicit "cred review".

Create a node for each like is a somewhat hacky way to do it--in
principle, we should have a heuristic which increases the cred weight of
a post based on the number of likes it has received--but it is expedient
so we can prototype this quickly.

Obviously, this is not robust to Sibyll attacks. If we decide to adopt
this, in the medium term we can add some filtering logic so that e.g. a
user must be whitelisted for their likes to mint cred. (And, in a nice
recursive step, the whitelist can be auto-generated from the last week's
cred scores, so that e.g. every user with at least 50 cred can mint more
cred.) I think it's OK to put in a Sibyll-vulnerable mechanism here
because SourceCred is still being designed for high trust-level
communities, and the existing system of minting cred for raw activity is
also vulnerable to Sibyll and spam attacks.

Test plan: Unit tests updated; also @s-ben can report back on whether
this is useful to him in demo-ing SourceCred [on MakerDAO][1].

If we merge this, we should simultaneously explicitly set the weight to
like nodes to 0 in our cred instance, so that we can separate merging
this feature from actually changing our own cred (which should go
through a separate review).

[1]: https://forum.makerdao.com/t/possible-data-source-for-determining-compensation
This commit is contained in:
Dandelion Mané 2020-01-28 18:10:24 -08:00 committed by GitHub
parent 6561a61a63
commit 3f42d72467
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 189 additions and 27 deletions

View File

@ -2,16 +2,32 @@
import {NodeAddress, type NodeAddressT} from "../../core/graph";
import {type PostId, type TopicId} from "./fetch";
import {type PostId, type TopicId, type LikeAction} from "./fetch";
import {topicNodeType, postNodeType, userNodeType} from "./declaration";
import {
topicNodeType,
postNodeType,
userNodeType,
likeNodeType,
} from "./declaration";
export function topicAddress(serverUrl: string, id: TopicId): NodeAddressT {
return NodeAddress.append(topicNodeType.prefix, serverUrl, String(id));
}
export function postAddress(serverUrl: string, id: PostId): NodeAddressT {
return NodeAddress.append(postNodeType.prefix, serverUrl, String(id));
}
export function userAddress(serverUrl: string, username: string): NodeAddressT {
return NodeAddress.append(userNodeType.prefix, serverUrl, username);
}
export function likeAddress(serverUrl: string, like: LikeAction): NodeAddressT {
return NodeAddress.append(
likeNodeType.prefix,
serverUrl,
like.username,
String(like.postId)
);
}

View File

@ -23,11 +23,12 @@ import {
postRepliesEdgeType,
topicContainsPostEdgeType,
likesEdgeType,
createsLikeEdgeType,
referencesTopicEdgeType,
referencesUserEdgeType,
referencesPostEdgeType,
} from "./declaration";
import {userAddress, topicAddress, postAddress} from "./address";
import {userAddress, topicAddress, postAddress, likeAddress} from "./address";
import {
type DiscourseReference,
parseLinks,
@ -54,14 +55,20 @@ export function topicNode(serverUrl: string, topic: Topic): Node {
export function postNode(
serverUrl: string,
post: Post,
topicTitle: string
description: string
): Node {
const url = `${serverUrl}/t/${String(post.topicId)}/${String(
post.indexWithinTopic
)}`;
const descr = `[post #${post.indexWithinTopic} on ${topicTitle}](${url})`;
const address = postAddress(serverUrl, post.id);
return {timestampMs: post.timestampMs, address, description: descr};
return {timestampMs: post.timestampMs, address, description};
}
export function likeNode(
serverUrl: string,
like: LikeAction,
postDescription: string
): Node {
const address = likeAddress(serverUrl, like);
const description = `❤️ by ${like.username} on post ${postDescription}`;
return {timestampMs: like.timestampMs, address, description};
}
export function authorsTopicEdge(serverUrl: string, topic: Topic): Edge {
@ -128,6 +135,21 @@ export function postRepliesEdge(
};
}
export function createsLikeEdge(serverUrl: string, like: LikeAction): Edge {
const address = EdgeAddress.append(
createsLikeEdgeType.prefix,
serverUrl,
like.username,
String(like.postId)
);
return {
address,
timestampMs: like.timestampMs,
src: userAddress(serverUrl, like.username),
dst: likeAddress(serverUrl, like),
};
}
export function likesEdge(serverUrl: string, like: LikeAction): Edge {
const address = EdgeAddress.append(
likesEdgeType.prefix,
@ -138,7 +160,7 @@ export function likesEdge(serverUrl: string, like: LikeAction): Edge {
return {
address,
timestampMs: like.timestampMs,
src: userAddress(serverUrl, like.username),
src: likeAddress(serverUrl, like),
dst: postAddress(serverUrl, like.postId),
};
}
@ -153,6 +175,7 @@ class _GraphCreator {
serverUrl: string;
data: ReadRepository;
topicIdToTitle: Map<TopicId, string>;
postIdToDescription: Map<PostId, string>;
constructor(serverUrl: string, data: ReadRepository) {
if (serverUrl.endsWith("/")) {
@ -162,6 +185,7 @@ class _GraphCreator {
this.data = data;
this.graph = new Graph();
this.topicIdToTitle = new Map();
this.postIdToDescription = new Map();
for (const username of data.users()) {
this.graph.addNode(userNode(serverUrl, username));
@ -174,18 +198,23 @@ class _GraphCreator {
}
for (const post of data.posts()) {
this.addPost(post);
const topicTitle =
this.topicIdToTitle.get(post.topicId) || `[unknown topic]`;
const url = `${this.serverUrl}/t/${String(post.topicId)}/${String(
post.indexWithinTopic
)}`;
const description = `[post #${post.indexWithinTopic} on ${topicTitle}](${url})`;
this.addPost(post, description);
this.postIdToDescription.set(post.id, description);
}
for (const like of data.likes()) {
this.graph.addEdge(likesEdge(serverUrl, like));
this.addLike(like);
}
}
addPost(post: Post) {
const topicTitle =
this.topicIdToTitle.get(post.topicId) || "[unknown topic]";
this.graph.addNode(postNode(this.serverUrl, post, topicTitle));
addPost(post: Post, description: string) {
this.graph.addNode(postNode(this.serverUrl, post, description));
this.graph.addEdge(authorsPostEdge(this.serverUrl, post));
this.graph.addEdge(topicContainsPostEdge(this.serverUrl, post));
this.maybeAddPostRepliesEdge(post);
@ -201,6 +230,14 @@ class _GraphCreator {
}
}
addLike(like: LikeAction) {
const postDescription =
this.postIdToDescription.get(like.postId) || "[unknown post]";
this.graph.addNode(likeNode(this.serverUrl, like, postDescription));
this.graph.addEdge(likesEdge(this.serverUrl, like));
this.graph.addEdge(createsLikeEdge(this.serverUrl, like));
}
/**
* Any post that is not the first post in the thread is a reply to some post.
* This method adds those reply edges. It is a bit hairy to work around unintuitive

View File

@ -1,6 +1,7 @@
// @flow
import sortBy from "lodash.sortby";
import * as NullUtil from "../../util/null";
import type {ReadRepository} from "./mirrorRepository";
import type {Topic, Post, PostId, TopicId, LikeAction} from "./fetch";
import {NodeAddress, EdgeAddress, type Node, type Edge} from "../../core/graph";
@ -9,24 +10,28 @@ import {
userNode,
topicNode,
postNode,
likeNode,
authorsTopicEdge,
authorsPostEdge,
topicContainsPostEdge,
postRepliesEdge,
likesEdge,
createsLikeEdge,
} from "./createGraph";
import {userAddress, postAddress, topicAddress} from "./address";
import {userAddress, postAddress, topicAddress, likeAddress} from "./address";
import {
userNodeType,
topicNodeType,
postNodeType,
likeNodeType,
authorsTopicEdgeType,
authorsPostEdgeType,
topicContainsPostEdgeType,
postRepliesEdgeType,
likesEdgeType,
createsLikeEdgeType,
referencesTopicEdgeType,
referencesUserEdgeType,
referencesPostEdgeType,
@ -171,6 +176,7 @@ describe("plugins/discourse/createGraph", () => {
]
`);
});
it("for topics", () => {
const {url, topic} = example();
const node = topicNode(url, topic);
@ -188,12 +194,12 @@ describe("plugins/discourse/createGraph", () => {
]
`);
});
it("for posts", () => {
const {url, topic, posts} = example();
const node = postNode(url, posts[1], topic.title);
expect(node.description).toMatchInlineSnapshot(
`"[post #2 on first topic](https://url.com/t/1/2)"`
);
const {url, posts} = example();
const description = "[post #2 on first topic](https://url.com/t/1/2)";
const node = postNode(url, posts[1], description);
expect(node.description).toEqual(description);
expect(node.timestampMs).toEqual(posts[1].timestampMs);
expect(NodeAddress.toParts(node.address)).toMatchInlineSnapshot(`
Array [
@ -205,6 +211,42 @@ describe("plugins/discourse/createGraph", () => {
]
`);
});
it("for likes", () => {
const {url, likes, posts, graph} = example();
const like = likes[0];
const post = posts[1];
expect(like.postId).toEqual(post.id);
const postDescription = `[post #2 on first topic](https://url.com/t/1/2)`;
const node = likeNode(url, like, postDescription);
expect(node.description).toMatchInlineSnapshot(
`"❤️ by mzargham on post [post #2 on first topic](https://url.com/t/1/2)"`
);
expect(node.timestampMs).toEqual(like.timestampMs);
expect(NodeAddress.toParts(node.address)).toMatchInlineSnapshot(`
Array [
"sourcecred",
"discourse",
"like",
"https://url.com",
"mzargham",
"2",
]
`);
expect(graph.node(node.address)).toEqual(node);
});
it("gives an [unknown post] description for likes without a matching post", () => {
const {likes} = example();
const like = likes[0];
const data = new MockData([], [], [like]);
const url = "https://foo";
const graph = createGraph(url, data);
const actual = Array.from(graph.nodes())[0];
const expected = likeNode(url, like, "[unknown post]");
expect(actual).toEqual(expected);
});
it("gives an [unknown topic] description for posts without a matching topic", () => {
const post = {
id: 1,
@ -218,8 +260,12 @@ describe("plugins/discourse/createGraph", () => {
const data = new MockData([], [post], []);
const url = "https://foo";
const graph = createGraph(url, data);
const postUrl = `${url}/t/${String(post.topicId)}/${String(
post.indexWithinTopic
)}`;
const expectedDescription = `[post #${post.indexWithinTopic} on [unknown topic]](${postUrl})`;
const actual = Array.from(graph.nodes({prefix: postNodeType.prefix}))[0];
const expected = postNode(url, post, "[unknown topic]");
const expected = postNode(url, post, expectedDescription);
expect(actual).toEqual(expected);
});
});
@ -310,7 +356,7 @@ describe("plugins/discourse/createGraph", () => {
it("for likes", () => {
const {url, likes} = example();
const like = likes[0];
const expectedSrc = userAddress(url, like.username);
const expectedSrc = likeAddress(url, like);
const expectedDst = postAddress(url, like.postId);
const edge = likesEdge(url, like);
expect(edge.src).toEqual(expectedSrc);
@ -327,6 +373,26 @@ describe("plugins/discourse/createGraph", () => {
]
`);
});
it("for createsLike", () => {
const {url, likes} = example();
const like = likes[0];
const expectedSrc = userAddress(url, like.username);
const expectedDst = likeAddress(url, like);
const edge = createsLikeEdge(url, like);
expect(edge.src).toEqual(expectedSrc);
expect(edge.dst).toEqual(expectedDst);
expect(edge.timestampMs).toEqual(like.timestampMs);
expect(EdgeAddress.toParts(edge.address)).toMatchInlineSnapshot(`
Array [
"sourcecred",
"discourse",
"createsLike",
"https://url.com",
"mzargham",
"2",
]
`);
});
});
describe("has the right nodes", () => {
@ -350,9 +416,30 @@ describe("plugins/discourse/createGraph", () => {
});
it("for posts", () => {
const {url, posts, topic} = example();
const expected = posts.map((x) => postNode(url, x, topic.title));
const expected = posts.map((x) => {
const postUrl = `${url}/t/${String(x.topicId)}/${String(
x.indexWithinTopic
)}`;
const description = `[post #${x.indexWithinTopic} on ${topic.title}](${postUrl})`;
return postNode(url, x, description);
});
expectNodesOfType(expected, postNodeType);
});
it("for likes", () => {
const {url, posts, topic, likes} = example();
const postIdToDescription = new Map();
for (const post of posts) {
const postUrl = `${url}/t/${String(post.topicId)}/${String(
post.indexWithinTopic
)}`;
const description = `[post #${post.indexWithinTopic} on ${topic.title}](${postUrl})`;
postIdToDescription.set(post.id, description);
}
const expected = likes.map((x) =>
likeNode(url, x, NullUtil.get(postIdToDescription.get(x.postId)))
);
expectNodesOfType(expected, likeNodeType);
});
});
describe("has the right edges", () => {
@ -394,6 +481,11 @@ describe("plugins/discourse/createGraph", () => {
const edges = likes.map((l) => likesEdge(url, l));
expectEdgesOfType(edges, likesEdgeType);
});
it("createsLike edges", () => {
const {url, likes} = example();
const edges = likes.map((l) => createsLikeEdge(url, l));
expectEdgesOfType(edges, createsLikeEdgeType);
});
it("references post edges", () => {
const {url, posts} = example();
const [post1, post2, post3] = posts;

View File

@ -33,6 +33,14 @@ export const userNodeType: NodeType = deepFreeze({
description: "A user account on a particular Discourse instance.",
});
export const likeNodeType: NodeType = deepFreeze({
name: "Like",
pluralName: "Likes",
prefix: NodeAddress.append(nodePrefix, "like"),
defaultWeight: 4,
description: "A like by some user, directed at some post",
});
export const topicContainsPostEdgeType: EdgeType = deepFreeze({
forwardName: "contains post",
backwardName: "is contained by topic",
@ -65,12 +73,20 @@ export const authorsPostEdgeType: EdgeType = deepFreeze({
description: "Connects an author to a post they've created.",
});
export const createsLikeEdgeType: EdgeType = deepFreeze({
forwardName: "creates like",
backwardName: "like created by",
prefix: EdgeAddress.append(edgePrefix, "createsLike"),
defaultWeight: {forwards: 1, backwards: 1 / 16},
description: "Connects a Discourse user to a like that they created.",
});
export const likesEdgeType: EdgeType = deepFreeze({
forwardName: "likes",
backwardName: "is liked by",
prefix: EdgeAddress.append(edgePrefix, "likes"),
defaultWeight: {forwards: 1, backwards: 1 / 16},
description: "Connects a Discourse user to a post they liked.",
description: "Connects a Discourse like to a post that was liked.",
});
export const referencesPostEdgeType: EdgeType = deepFreeze({
@ -101,13 +117,14 @@ export const declaration: PluginDeclaration = deepFreeze({
name: "Discourse",
nodePrefix,
edgePrefix,
nodeTypes: [userNodeType, topicNodeType, postNodeType],
nodeTypes: [userNodeType, topicNodeType, postNodeType, likeNodeType],
edgeTypes: [
postRepliesEdgeType,
authorsTopicEdgeType,
authorsPostEdgeType,
topicContainsPostEdgeType,
likesEdgeType,
createsLikeEdgeType,
referencesPostEdgeType,
referencesTopicEdgeType,
referencesUserEdgeType,