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:
parent
6561a61a63
commit
3f42d72467
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue