diff --git a/src/plugins/odyssey/analysisAdapter.js b/src/plugins/odyssey/analysisAdapter.js new file mode 100644 index 0000000..65efa0b --- /dev/null +++ b/src/plugins/odyssey/analysisAdapter.js @@ -0,0 +1,20 @@ +// @flow + +import {Graph} from "../../core/graph"; +import type {RepoId} from "../../core/repoId"; +import type {IAnalysisAdapter} from "../../analysis/analysisAdapter"; +import {hackathonExample} from "./example"; +import {declaration} from "./declaration"; + +export class AnalysisAdapter implements IAnalysisAdapter { + declaration() { + return declaration; + } + // TODO(@decentralion): Enable loading graphs other than the hackathon example. + async load( + _unused_sourcecredDirectory: string, + _unused_repoId: RepoId + ): Promise { + return hackathonExample().graph(); + } +} diff --git a/src/plugins/odyssey/declaration.js b/src/plugins/odyssey/declaration.js new file mode 100644 index 0000000..d6b8e41 --- /dev/null +++ b/src/plugins/odyssey/declaration.js @@ -0,0 +1,83 @@ +// @flow + +import {EdgeAddress, NodeAddress} from "../../core/graph"; + +import type {PluginDeclaration} from "../../analysis/pluginDeclaration"; +import type {NodeType} from "../../analysis/types"; + +export type OdysseyNodeTypeIdentifier = + | "ARTIFACT" + | "CONTRIBUTION" + | "VALUE" + | "PERSON"; + +export type OdysseyEdgeTypeIdentifier = "DEPENDS_ON"; + +export function isOdysseyNodeTypeIdentifier( + n: OdysseyNodeTypeIdentifier +): boolean { + return ["ARTIFACT", "CONTRIBUTION", "VALUE", "PERSON"].indexOf(n) !== -1; +} + +export const NODE_PREFIX = NodeAddress.fromParts(["sourcecred", "odyssey"]); + +export function isOdysseyEdgeTypeIdentifier(x: string): boolean { + return x === "DEPENDS_ON"; +} +export const EDGE_PREFIX = EdgeAddress.fromParts(["sourcecred", "odyssey"]); + +const artifactNodeType: NodeType = Object.freeze({ + name: "Artifact", + pluralName: "Artifacts", + prefix: NodeAddress.append(NODE_PREFIX, "ARTIFACT"), + defaultWeight: 2, + description: + "Represents a durably valuable piece of a project, e.g. a major subcomponent.", +}); + +const contributionNodeType: NodeType = Object.freeze({ + name: "Contribution", + pluralName: "Contributions", + prefix: NodeAddress.append(NODE_PREFIX, "CONTRIBUTION"), + defaultWeight: 1, + description: + "Represents any specific work or labor that went into a project.", +}); + +const valueNodeType: NodeType = Object.freeze({ + name: "Value", + pluralName: "Values", + prefix: NodeAddress.append(NODE_PREFIX, "VALUE"), + defaultWeight: 4, + description: "Represents a high-level value of the project.", +}); + +const personNodeType: NodeType = Object.freeze({ + name: "Person", + pluralName: "People", + prefix: NodeAddress.append(NODE_PREFIX, "PERSON"), + defaultWeight: 1, + description: "Represents an individual contributor.", +}); + +const dependsOnEdgeType = Object.freeze({ + forwardName: "depends on", + backwardName: "is depended on by", + prefix: EdgeAddress.append(EDGE_PREFIX, "DEPENDS_ON"), + defaultForwardWeight: 1, + defaultBackwardWeight: 0, + description: "Generic edge for flowing credit in the Odyssey plugin", +}); + +export const declaration: PluginDeclaration = Object.freeze({ + name: "Odyssey", + nodePrefix: NODE_PREFIX, + edgePrefix: EDGE_PREFIX, + nodeTypes: [ + contributionNodeType, + valueNodeType, + personNodeType, + artifactNodeType, + ], + edgeTypes: [dependsOnEdgeType], +}); diff --git a/src/plugins/odyssey/example.js b/src/plugins/odyssey/example.js new file mode 100644 index 0000000..694cd0e --- /dev/null +++ b/src/plugins/odyssey/example.js @@ -0,0 +1,114 @@ +// @flow + +import {OdysseyInstance, type Node} from "./instance"; + +/** + * An example Odyssey instance, based on work at the Odyssey Hackathon. + */ +export function hackathonExample(): OdysseyInstance { + // Define the types of nodes allowed in our instance + const instance = new OdysseyInstance(); + + // define our values for the hackathon + const logistics = instance.addNode("VALUE", "logistics"); + const design = instance.addNode("VALUE", "design"); + const narrative = instance.addNode("VALUE", "narrative"); + const prototype = instance.addNode("VALUE", "prototype"); + const outreach = instance.addNode("VALUE", "outreach"); + + // the cast of characters + const dl = instance.addNode("PERSON", "dandelion"); + const mz = instance.addNode("PERSON", "z zargham"); + const irene = instance.addNode("PERSON", "irene"); + const max = instance.addNode("PERSON", "max"); + const dennis = instance.addNode("PERSON", "dennis"); + const jonathan = instance.addNode("PERSON", "jonathan"); + const lb = instance.addNode("PERSON", "lb"); + const brian = instance.addNode("PERSON", "brian"); + const sarah = instance.addNode("PERSON", "sarah"); + const jmnemo = instance.addNode("PERSON", "@jmnemo"); + const talbott = instance.addNode("PERSON", "jonathan talbott"); + const agata = instance.addNode("PERSON", "agata"); + + // the artifacts + const graphviz = instance.addNode("ARTIFACT", "graph visualizer"); + const backend = instance.addNode("ARTIFACT", "backend"); + const frontend = instance.addNode("ARTIFACT", "frontend"); + const seededPagerank = instance.addNode("ARTIFACT", "seeded pagerank"); + const canvas = instance.addNode( + "ARTIFACT", + "the awesome illustrated poster board" + ); + const logo = instance.addNode("ARTIFACT", "the broken-lightbulb logo"); + + instance.addEdge("DEPENDS_ON", prototype, graphviz); + instance.addEdge("DEPENDS_ON", prototype, backend); + instance.addEdge("DEPENDS_ON", prototype, frontend); + instance.addEdge("DEPENDS_ON", frontend, seededPagerank); + instance.addEdge("DEPENDS_ON", design, graphviz); + instance.addEdge("DEPENDS_ON", design, frontend); + instance.addEdge("DEPENDS_ON", design, logo); + instance.addEdge("DEPENDS_ON", narrative, logo); + instance.addEdge("DEPENDS_ON", narrative, canvas); + + function addContribution( + description: string, + authors: Node[], + impacted: Node[] + ) { + const contrib = instance.addNode("CONTRIBUTION", description); + for (const author of authors) { + instance.addEdge("DEPENDS_ON", contrib, author); + } + for (const impact of impacted) { + instance.addEdge("DEPENDS_ON", impact, contrib); + } + } + + addContribution( + "colors for the graph visualizer", + [dennis, irene, lb, max, dl], + [design, graphviz] + ); + addContribution("design for graph visualizer", [dennis], [graphviz, design]); + addContribution( + "design for the frontend", + [dennis, irene], + [design, frontend] + ); + addContribution( + "pre-hack planning and project management", + [brian], + [prototype, logistics] + ); + addContribution("implementing the graph visualizer", [dl], [graphviz]); + addContribution("implementing seeded PageRank", [dl, mz], [seededPagerank]); + addContribution("implementing the frontend", [dl, jmnemo], [frontend]); + addContribution("implementing the backend", [dl], [backend]); + addContribution("logo--preliminary work", [lb, agata], [logo]); + addContribution("logo--lightbulb moment", [lb, max], [logo]); + addContribution("drawing the canvas", [lb], [canvas]); + addContribution( + "narrative shaping for canvas", + [lb, dl, mz], + [canvas, narrative] + ); + addContribution("oneline narrative statement", [dl, talbott], [narrative]); + addContribution("oneline narrative review", [max, dl], [narrative]); + addContribution("booking hotel stay", [sarah], [logistics]); + addContribution("booking plane tickets", [sarah], [logistics]); + addContribution("helping Dennis get into the space", [sarah], [logistics]); + addContribution( + "example prototype dataset", + [mz, brian], + [prototype, outreach] + ); + addContribution("final presentation", [dl, prototype], [narrative]); + addContribution("connection to the common stack", [mz], [outreach]); + addContribution("discussing the canvas with folks", [lb], [outreach]); + addContribution("general logistical defense", [jonathan], [logistics]); + addContribution("recruiting Max & company", [mz], [outreach, logistics]); + addContribution("forming the team", [mz], [logistics]); + + return instance; +} diff --git a/src/plugins/odyssey/explorerAdapter.js b/src/plugins/odyssey/explorerAdapter.js new file mode 100644 index 0000000..241b7f6 --- /dev/null +++ b/src/plugins/odyssey/explorerAdapter.js @@ -0,0 +1,48 @@ +// @flow + +import type {Assets} from "../../webutil/assets"; +import type {PluginDeclaration} from "../../analysis/pluginDeclaration"; +import type {RepoId} from "../../core/repoId"; +import type { + StaticExplorerAdapter as IStaticExplorerAdapter, + DynamicExplorerAdapter as IDynamicExplorerAdapter, +} from "../../explorer/adapters/explorerAdapter"; +import {NodeAddress, type NodeAddressT} from "../../core/graph"; +import {declaration} from "./declaration"; +import {OdysseyInstance} from "./instance"; +import {hackathonExample} from "./example"; + +export class StaticExplorerAdapter implements IStaticExplorerAdapter { + declaration(): PluginDeclaration { + return declaration; + } + + // TODO(@decentralion): Enable loading instances other than the hackathon example. + async load( + _unused_assets: Assets, + _unused_repoId: RepoId + ): Promise { + const instance = hackathonExample(); + return new DynamicExplorerAdapter(instance); + } +} + +class DynamicExplorerAdapter implements IDynamicExplorerAdapter { + +_instance: OdysseyInstance; + constructor(instance: OdysseyInstance): void { + this._instance = instance; + } + nodeDescription(address: NodeAddressT) { + const node = this._instance.node(address); + if (node == null) { + throw new Error(`No Odyssey node for: ${NodeAddress.toString(address)}`); + } + return node.description; + } + graph() { + return this._instance.graph(); + } + static() { + return new StaticExplorerAdapter(); + } +} diff --git a/src/plugins/odyssey/instance.js b/src/plugins/odyssey/instance.js new file mode 100644 index 0000000..4097ffa --- /dev/null +++ b/src/plugins/odyssey/instance.js @@ -0,0 +1,225 @@ +// @flow + +/** + * Core "model" logic for the Odyssey plugin. + * Basically allows creating a data store of priorities, contributions, and people, + * and compiling that data store into a cred Graph. + */ +import { + Graph, + EdgeAddress, + NodeAddress, + type NodeAddressT, + type GraphJSON, + sortedNodeAddressesFromJSON, +} from "../../core/graph"; + +import deepEqual from "lodash.isequal"; + +import { + NODE_PREFIX, + EDGE_PREFIX, + type OdysseyNodeTypeIdentifier, + isOdysseyNodeTypeIdentifier, + type OdysseyEdgeTypeIdentifier, + isOdysseyEdgeTypeIdentifier, +} from "./declaration"; + +import {toCompat, fromCompat, type Compatible} from "../../util/compat"; + +import * as NullUtil from "../../util/null"; + +export type Node = {| + +nodeTypeIdentifier: OdysseyNodeTypeIdentifier, + +address: NodeAddressT, + +description: string, +|}; + +const COMPAT_INFO = {type: "sourcecred/odyssey/instance", version: "0.1.0"}; +export type InstanceJSON = Compatible<{| + +graphJSON: GraphJSON, + +sortedDescriptions: $ReadOnlyArray, + +count: number, +|}>; + +/** + * This is the data model for a particular instance in the Odyssey Plugin. + * The OdysseyInstance allows adding "Entities", which are basically nodes + * in the Odyssey graph augmented with a type identifier, and a description. + * Currently, the types are restricted to types hard-coded in the + * [declaration](./declaration.js) but we intend to allow instance-specified + * types in the future. + * + * The OdysseyInstance maintains an internal graph which actually stores the + * node identities, as well as added nodes. You can get a copy of this graph + * by calling the `.graph()` method. + * + * Entities are identified by an incrementing id (`._count`). This is + * convenient for implementation, although it will make reconciling + * simultaneous edits challenging. Once that becomes a real issue, we should + * switch to a different node/edge identification strategy. + */ +export class OdysseyInstance { + _graph: Graph; + _descriptions: Map; + _count: number; + + /** + * Construct an Odyssey Instance. + */ + constructor() { + this._graph = new Graph(); + this._descriptions = new Map(); + this._count = 0; + } + + /** + * Add a new node to the instance (and a corresponding node to the graph). + * + * Requires a valid node type identifier (a string that uniquely identifies + * an Odyssey node type; see [declaration.js](./declaration.js)). + */ + addNode( + typeIdentifier: OdysseyNodeTypeIdentifier, + description: string + ): Node { + if (!isOdysseyNodeTypeIdentifier(typeIdentifier)) { + throw new Error( + `Tried to add node with invalid type identifier: ${typeIdentifier}` + ); + } + const address = NodeAddress.append( + NODE_PREFIX, + typeIdentifier, + String(this._count) + ); + this._graph.addNode(address); + this._count++; + this._descriptions.set(address, description); + return NullUtil.get(this.node(address)); + } + + /** + * Retrieve the Node corresponding to a given node address, if it exists. + */ + node(address: NodeAddressT): ?Node { + if (!this._graph.hasNode(address)) { + return null; + } + const parts = NodeAddress.toParts(address); + // We know it is an OdysseyNodeTypeIdentifier because the instance's internal + // graph only has Odyssey nodes in it. + const nodeTypeIdentifier: OdysseyNodeTypeIdentifier = (parts[2]: any); + if (!isOdysseyNodeTypeIdentifier(nodeTypeIdentifier)) { + throw new Error( + `Invariant violation: ${nodeTypeIdentifier} is not odyssey type identifier` + ); + } + const description = NullUtil.get(this._descriptions.get(address)); + return {address, nodeTypeIdentifier, description}; + } + + /** + * Retrieve all the Nodes in the instance. + * + * Optionally filter to only nodes of a chosen type. + */ + nodes(typeIdentifier?: OdysseyNodeTypeIdentifier): Iterator { + const prefix = + typeIdentifier == null + ? NODE_PREFIX + : NodeAddress.append(NODE_PREFIX, typeIdentifier); + return this._nodesIterator(prefix); + } + + *_nodesIterator(prefix: NodeAddressT): Iterator { + for (const a of this._graph.nodes({prefix})) { + yield NullUtil.get(this.node(a)); + } + } + + /** + * Add an edge to the Odyssey instance. + */ + // TODO(@decentralion): Add support for edge types (also configured on a per-instance basis). + addEdge( + type: OdysseyEdgeTypeIdentifier, + src: Node, + dst: Node + ): OdysseyInstance { + if (!isOdysseyEdgeTypeIdentifier(type)) { + throw new Error(`Invalid Odyssey edge type identifier: ${type}`); + } + const edge = { + src: src.address, + dst: dst.address, + address: EdgeAddress.append(EDGE_PREFIX, type, String(this._count)), + }; + this._graph.addEdge(edge); + this._count++; + return this; + } + + /** + * Returns a copy of the graph underlying this instance. + */ + graph(): Graph { + return this._graph.copy(); + } + + toJSON(): InstanceJSON { + const graphJSON = this._graph.toJSON(); + const sortedNodeAddresses = sortedNodeAddressesFromJSON(graphJSON); + const sortedDescriptions = sortedNodeAddresses.map((a) => + NullUtil.get(this._descriptions.get(a)) + ); + return toCompat(COMPAT_INFO, { + graphJSON, + sortedDescriptions, + count: this._count, + }); + } + + static fromJSON(j: InstanceJSON): OdysseyInstance { + const {graphJSON, sortedDescriptions, count} = fromCompat(COMPAT_INFO, j); + const instance = new OdysseyInstance(); + instance._graph = Graph.fromJSON(graphJSON); + instance._count = count; + const descriptions = new Map(); + const sortedNodeAddresses = sortedNodeAddressesFromJSON(graphJSON); + for (let i = 0; i < sortedNodeAddresses.length; i++) { + descriptions.set(sortedNodeAddresses[i], sortedDescriptions[i]); + } + instance._descriptions = descriptions; + return instance; + } + + /** + * Returns whether two Odyssey instances have identical histories. + * + * Two instances are historically identical if they have the same ordered + * sequence of node additions and deletions. This is because the address of + * Nodes and Edges in the instance is determined by the order in which they + * were added. + * + * For an illustration, consider the following case: + * ```js + * const i1 = new OdysseyInstance(); + * i1.addNode("PERSON", "me") + * i1.addNode("PERSON", "you") + * + * const i2 = new OdysseyInstance(); + * i2.addNode("PERSON", "you") + * i2.addNode("PERSON", "me") + * + * expect(i1.isHistoricallyIdentical(i2)).toBe(false); + * ``` + */ + isHistoricallyIdentical(that: OdysseyInstance): boolean { + return ( + this._count === that._count && + this._graph.equals(that._graph) && + deepEqual(this._descriptions, that._descriptions) + ); + } +} diff --git a/src/plugins/odyssey/instance.test.js b/src/plugins/odyssey/instance.test.js new file mode 100644 index 0000000..1888760 --- /dev/null +++ b/src/plugins/odyssey/instance.test.js @@ -0,0 +1,152 @@ +// @flow + +import {OdysseyInstance} from "./instance"; +import {EdgeAddress, NodeAddress, Direction} from "../../core/graph"; + +describe("plugins/odyssey/instance", () => { + function exampleInstance() { + const instance = new OdysseyInstance(); + const me = instance.addNode("PERSON", "me"); + const you = instance.addNode("PERSON", "you"); + const value = instance.addNode("VALUE", "valuable-ness"); + const contribution = instance.addNode("CONTRIBUTION", "a good deed"); + const artifact = instance.addNode( + "ARTIFACT", + "the thing that creates value" + ); + + // first off I'd like to thank myself + instance + .addEdge("DEPENDS_ON", me, me) + // thank you for your support + .addEdge("DEPENDS_ON", me, you) + .addEdge("DEPENDS_ON", contribution, me) + .addEdge("DEPENDS_ON", artifact, contribution) + .addEdge("DEPENDS_ON", value, artifact); + return {instance, you, me, value, contribution, artifact}; + } + it("can retrieve the graph", () => { + const {instance, me} = exampleInstance(); + const graph = instance.graph(); + const nodes = Array.from(graph.nodes()); + const edges = Array.from(graph.edges()); + expect(nodes).toHaveLength(5); + expect(edges).toHaveLength(5); + const myNeighbors = graph.neighbors(me.address, { + direction: Direction.ANY, + nodePrefix: NodeAddress.empty, + edgePrefix: EdgeAddress.empty, + }); + expect(Array.from(myNeighbors)).toHaveLength(3); + }); + it("retrieved graph is a copy", () => { + const {instance} = exampleInstance(); + expect(instance.graph()).not.toBe(instance.graph()); + }); + it("returns nodes as they are added", () => { + const {me} = exampleInstance(); + expect(me).toEqual({ + address: NodeAddress.fromParts(["sourcecred", "odyssey", "PERSON", "0"]), + nodeTypeIdentifier: "PERSON", + description: "me", + }); + }); + it("can retrieve nodes by address", () => { + const {instance, me} = exampleInstance(); + expect(instance.node(me.address)).toEqual(me); + }); + it("returns null for non-existent node", () => { + const instance = new OdysseyInstance(); + expect(instance.node(NodeAddress.empty)).toEqual(null); + }); + it("throws an error when adding an node with bad type", () => { + const instance = new OdysseyInstance(); + // $ExpectFlowError + expect(() => instance.addNode("FOO", "foo")).toThrowError( + "invalid type identifier: FOO" + ); + }); + it("can retrieve all nodes", () => { + const {instance} = exampleInstance(); + expect(Array.from(instance.nodes())).toHaveLength(5); + }); + it("can retrieve nodes by type", () => { + const {instance, artifact, contribution, value} = exampleInstance(); + expect(Array.from(instance.nodes("ARTIFACT"))).toEqual([artifact]); + expect(Array.from(instance.nodes("VALUE"))).toEqual([value]); + expect(Array.from(instance.nodes("CONTRIBUTION"))).toEqual([contribution]); + // $ExpectFlowError + expect(Array.from(instance.nodes("NONEXISTENT"))).toEqual([]); + }); + it("errors if adding edge between Nodes that don't exist", () => { + const {me, you} = exampleInstance(); + const i = new OdysseyInstance(); + expect(() => i.addEdge("DEPENDS_ON", me, you)).toThrowError( + "Missing src on edge:" + ); + }); + describe("equality", () => { + it("empty instance isHistoricallyIdentical empty instance", () => { + const a = new OdysseyInstance(); + const b = new OdysseyInstance(); + expect(a.isHistoricallyIdentical(b)).toBe(true); + }); + it("empty instance does not equal nonempty instance", () => { + const a = new OdysseyInstance(); + a.addNode("PERSON", "me"); + const b = new OdysseyInstance(); + expect(a.isHistoricallyIdentical(b)).toBe(false); + }); + it("complex but identically generated instances are equal", () => { + const {instance: a} = exampleInstance(); + const {instance: b} = exampleInstance(); + expect(a.isHistoricallyIdentical(b)).toBe(true); + }); + it("instances with different descriptions are not equal", () => { + const a = new OdysseyInstance(); + const b = new OdysseyInstance(); + a.addNode("PERSON", "me"); + b.addNode("PERSON", "me2"); + expect(a.isHistoricallyIdentical(b)).toBe(false); + }); + it("instances with different types are not equal", () => { + const a = new OdysseyInstance(); + const b = new OdysseyInstance(); + a.addNode("PERSON", "me"); + b.addNode("ARTIFACT", "me"); + expect(a.isHistoricallyIdentical(b)).toBe(false); + }); + it("instances with different edges are not equal", () => { + const a = new OdysseyInstance(); + const b = new OdysseyInstance(); + const me = a.addNode("PERSON", "me"); + b.addNode("PERSON", "me"); + a.addEdge("DEPENDS_ON", me, me); + expect(a.isHistoricallyIdentical(b)).toBe(false); + }); + it("instances with different histories are not equal", () => { + const i1 = new OdysseyInstance(); + i1.addNode("PERSON", "me"); + i1.addNode("PERSON", "you"); + + const i2 = new OdysseyInstance(); + i2.addNode("PERSON", "you"); + i2.addNode("PERSON", "me"); + + expect(i1.isHistoricallyIdentical(i2)).toBe(false); + }); + }); + describe("to/from JSON", () => { + it("to->fro is identity", () => { + const {instance} = exampleInstance(); + const instance2 = OdysseyInstance.fromJSON(instance.toJSON()); + expect(instance.isHistoricallyIdentical(instance2)).toBe(true); + }); + it("fro->to is identity", () => { + const {instance} = exampleInstance(); + const json1 = instance.toJSON(); + const json2 = OdysseyInstance.fromJSON(json1).toJSON(); + expect(json1).toEqual(json2); + }); + }); +});