Rename Contributor & Contribution types (#607)

Consider the following types:

```
// Used to be called "Contributor"
export type Adjacency =
  | {|+type: "SYNTHETIC_LOOP"|}
  | {|+type: "IN_EDGE", +edge: Edge|}
  | {|+type: "OUT_EDGE", +edge: Edge|};

// Used to be called "Contribution"
export type Connection = {|
  +adjacency: Adjacency,
  // This `weight` is a conditional probability: given that you're at
  // the source of this connection's adjacency, what's the
  // probability that you travel along this connection to the target?
  +weight: Probability,
|};

// Used to be called "ScoredContribution"
export type ScoredConnection = {|
  +connection: Connection,
  +source: NodeAddressT,
  +sourceScore: number,
  +connectionScore: number,
|};
```

These types represent how a node's PagerankScore is influenced by its
connections in the markov chain. The previous names, "Contributor",
"Contribution" and "ScoredContribution", were quite confusing as
elsewhere in the project "contributon" means something that added value
to the project, and "contributor" means the author of contributions.
While these new names aren't necessarily much better a priori, in the
context of the project's vernacular they are much less confusing.

Test plan: It's just a rename, and `yarn test` passes.
This commit is contained in:
Dandelion Mané 2018-08-06 12:17:52 -07:00 committed by GitHub
parent 59ac10b612
commit 00da630bb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 274 additions and 292 deletions

View File

@ -11,9 +11,9 @@ import {
} from "../../core/graph";
import type {
PagerankNodeDecomposition,
ScoredContribution,
ScoredConnection,
} from "../../core/attribution/pagerankNodeDecomposition";
import type {Contribution} from "../../core/attribution/graphToMarkovChain";
import type {Connection} from "../../core/attribution/graphToMarkovChain";
import type {DynamicPluginAdapter} from "../pluginAdapter";
import * as NullUtil from "../../util/null";
@ -164,7 +164,7 @@ export class PagerankTable extends React.PureComponent<
<thead>
<tr>
<th style={{textAlign: "left"}}>Description</th>
<th style={{textAlign: "right"}}>Contribution</th>
<th style={{textAlign: "right"}}>Connection</th>
<th style={{textAlign: "right"}}>Score</th>
</tr>
</thead>
@ -241,7 +241,7 @@ export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
<td style={{textAlign: "right"}}>{scoreDisplay(score)}</td>
</tr>
{expanded && (
<ContributionRowList
<ConnectionRowList
key="children"
depth={1}
node={node}
@ -253,29 +253,29 @@ export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
}
}
type ContributionRowListProps = {|
type ConnectionRowListProps = {|
+depth: number,
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class ContributionRowList extends React.PureComponent<
ContributionRowListProps
export class ConnectionRowList extends React.PureComponent<
ConnectionRowListProps
> {
render() {
const {depth, node, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
const {scoredContributions} = NullUtil.get(pnd.get(node));
const {scoredConnections} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
{scoredContributions
{scoredConnections
.slice(0, maxEntriesPerList)
.map((sc) => (
<ContributionRow
key={JSON.stringify(sc.contribution.contributor)}
<ConnectionRow
key={JSON.stringify(sc.connection.adjacency)}
depth={depth}
target={node}
scoredContribution={sc}
scoredConnection={sc}
sharedProps={sharedProps}
/>
))}
@ -284,15 +284,15 @@ export class ContributionRowList extends React.PureComponent<
}
}
type ContributionRowProps = {|
type ConnectionRowProps = {|
+depth: number,
+target: NodeAddressT,
+scoredContribution: ScoredContribution,
+scoredConnection: ScoredConnection,
+sharedProps: SharedProps,
|};
export class ContributionRow extends React.PureComponent<
ContributionRowProps,
export class ConnectionRow extends React.PureComponent<
ConnectionRowProps,
RowState
> {
constructor() {
@ -304,18 +304,13 @@ export class ContributionRow extends React.PureComponent<
sharedProps,
target,
depth,
scoredContribution: {
contribution,
source,
sourceScore,
contributionScore,
},
scoredConnection: {connection, source, sourceScore, connectionScore},
} = this.props;
const {pnd, adapters} = sharedProps;
const {expanded} = this.state;
const {score: targetScore} = NullUtil.get(pnd.get(target));
const contributionProportion = contributionScore / targetScore;
const contributionPercent = (contributionProportion * 100).toFixed(2);
const connectionProportion = connectionScore / targetScore;
const connectionPercent = (connectionProportion * 100).toFixed(2);
return (
<React.Fragment>
@ -337,13 +332,13 @@ export class ContributionRow extends React.PureComponent<
>
{expanded ? "\u2212" : "+"}
</button>
<ContributionView contribution={contribution} adapters={adapters} />
<ConnectionView connection={connection} adapters={adapters} />
</td>
<td style={{textAlign: "right"}}>{contributionPercent}%</td>
<td style={{textAlign: "right"}}>{connectionPercent}%</td>
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
</tr>
{expanded && (
<ContributionRowList
<ConnectionRowList
key="children"
depth={depth + 1}
node={source}
@ -355,12 +350,12 @@ export class ContributionRow extends React.PureComponent<
}
}
export class ContributionView extends React.PureComponent<{|
+contribution: Contribution,
export class ConnectionView extends React.PureComponent<{|
+connection: Connection,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|}> {
render() {
const {contribution, adapters} = this.props;
const {connection, adapters} = this.props;
function Badge({children}) {
return (
// The outer <span> acts as a strut to ensure that the badge
@ -378,30 +373,30 @@ export class ContributionView extends React.PureComponent<{|
</span>
);
}
const {contributor} = contribution;
switch (contributor.type) {
const {adjacency} = connection;
switch (adjacency.type) {
case "SYNTHETIC_LOOP":
return <Badge>synthetic loop</Badge>;
case "IN_EDGE":
return (
<span>
<Badge>
{edgeVerb(contributor.edge.address, "BACKWARD", adapters)}
{edgeVerb(adjacency.edge.address, "BACKWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(contributor.edge.src, adapters)}</span>
<span>{nodeDescription(adjacency.edge.src, adapters)}</span>
</span>
);
case "OUT_EDGE":
return (
<span>
<Badge>
{edgeVerb(contributor.edge.address, "FORWARD", adapters)}
{edgeVerb(adjacency.edge.address, "FORWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(contributor.edge.dst, adapters)}</span>
<span>{nodeDescription(adjacency.edge.dst, adapters)}</span>
</span>
);
default:
throw new Error((contributor.type: empty));
throw new Error((adjacency.type: empty));
}
}
}

View File

@ -8,13 +8,13 @@ import {
PagerankTable,
NodeRowList,
NodeRow,
ContributionRowList,
ContributionRow,
ContributionView,
ConnectionRowList,
ConnectionRow,
ConnectionView,
} from "./PagerankTable";
import {pagerank} from "../../core/attribution/pagerank";
import sortBy from "lodash.sortby";
import {type Contribution} from "../../core/attribution/graphToMarkovChain";
import {type Connection} from "../../core/attribution/graphToMarkovChain";
import * as NullUtil from "../../util/null";
import {
@ -26,7 +26,7 @@ import {
require("../testUtil").configureEnzyme();
const COLUMNS = () => ["Description", "Contribution", "Score"];
const COLUMNS = () => ["Description", "Connection", "Score"];
async function example() {
const graph = new Graph();
@ -337,14 +337,14 @@ describe("app/credExplorer/PagerankTable", () => {
.text()
).toEqual(expectedDescription);
});
it("renders an empty contribution column", async () => {
it("renders an empty connection column", async () => {
const {element} = await setup();
const contributionColumn = COLUMNS().indexOf("Contribution");
expect(contributionColumn).not.toEqual(-1);
const connectionColumn = COLUMNS().indexOf("Connection");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(contributionColumn)
.at(connectionColumn)
.text()
).toEqual("—");
});
@ -352,18 +352,18 @@ describe("app/credExplorer/PagerankTable", () => {
const {element, sharedProps, node} = await setup();
const {score: rawScore} = NullUtil.get(sharedProps.pnd.get(node));
const expectedScore = (-Math.log(rawScore)).toFixed(2);
const contributionColumn = COLUMNS().indexOf("Score");
expect(contributionColumn).not.toEqual(-1);
const connectionColumn = COLUMNS().indexOf("Score");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(contributionColumn)
.at(connectionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", async () => {
const {element} = await setup();
expect(element.find("ContributionRowList")).toHaveLength(0);
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
it('has a working "expand" button', async () => {
const {element, sharedProps, node} = await setup();
@ -371,7 +371,7 @@ describe("app/credExplorer/PagerankTable", () => {
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ContributionRowList");
const crl = element.find("ConnectionRowList");
expect(crl).toHaveLength(1);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(1);
@ -379,18 +379,18 @@ describe("app/credExplorer/PagerankTable", () => {
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ContributionRowList")).toHaveLength(0);
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
});
describe("ContributionRowList", () => {
describe("ConnectionRowList", () => {
async function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd, nodes} = await example();
const depth = 2;
const node = nodes.bar1;
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = (
<ContributionRowList
<ConnectionRowList
depth={depth}
node={node}
sharedProps={sharedProps}
@ -399,64 +399,60 @@ describe("app/credExplorer/PagerankTable", () => {
const element = shallow(component);
return {element, depth, node, sharedProps};
}
it("creates `ContributionRow`s with the right props", async () => {
it("creates `ConnectionRow`s with the right props", async () => {
const {element, depth, node, sharedProps} = await setup();
const contributions = NullUtil.get(sharedProps.pnd.get(node))
.scoredContributions;
const rows = element.find("ContributionRow");
expect(rows).toHaveLength(contributions.length);
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
const rows = element.find("ConnectionRow");
expect(rows).toHaveLength(connections.length);
const rowPropses = rows.map((row) => row.props());
// Order should be the same as the order in the decomposition.
expect(rowPropses).toEqual(
contributions.map((sc) => ({
connections.map((sc) => ({
depth,
sharedProps,
target: node,
scoredContribution: sc,
scoredConnection: sc,
}))
);
});
it("limits the number of rows by `maxEntriesPerList`", async () => {
const maxEntriesPerList = 1;
const {element, node, sharedProps} = await setup(maxEntriesPerList);
const contributions = NullUtil.get(sharedProps.pnd.get(node))
.scoredContributions;
expect(contributions.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("ContributionRow");
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
expect(connections.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("ConnectionRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowContributions = rows.map((row) =>
row.prop("scoredContribution")
);
const rowConnections = rows.map((row) => row.prop("scoredConnection"));
// Should have selected the right nodes.
expect(rowContributions).toEqual(
contributions.slice(0, maxEntriesPerList)
);
expect(rowConnections).toEqual(connections.slice(0, maxEntriesPerList));
});
});
describe("ContributionRow", () => {
describe("ConnectionRow", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const target = nodes.bar1;
const {scoredContributions} = NullUtil.get(pnd.get(target));
const alphaContributions = scoredContributions.filter(
const {scoredConnections} = NullUtil.get(pnd.get(target));
const alphaConnections = scoredConnections.filter(
(sc) => sc.source === nodes.fooAlpha
);
expect(alphaContributions).toHaveLength(1);
const contribution = alphaContributions[0];
const {source} = contribution;
expect(alphaConnections).toHaveLength(1);
const connection = alphaConnections[0];
const {source} = connection;
const depth = 2;
const component = (
<ContributionRow
<ConnectionRow
depth={depth}
target={target}
scoredContribution={contribution}
scoredConnection={connection}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
return {element, depth, target, source, contribution, sharedProps};
return {element, depth, target, source, connection, sharedProps};
}
it("renders the right number of columns", async () => {
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
@ -469,25 +465,25 @@ describe("app/credExplorer/PagerankTable", () => {
}).toMatchSnapshot();
});
it("renders the source view", async () => {
const {element, sharedProps, contribution} = await setup();
const {element, sharedProps, connection} = await setup();
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
const view = element
.find("td")
.at(descriptionColumn)
.find("ContributionView");
.find("ConnectionView");
expect(view).toHaveLength(1);
expect(view.props()).toEqual({
adapters: sharedProps.adapters,
contribution: contribution.contribution,
connection: connection.connection,
});
});
it("renders the contribution percentage", async () => {
const {element, contribution, sharedProps, target} = await setup();
const contributionColumn = COLUMNS().indexOf("Contribution");
expect(contributionColumn).not.toEqual(-1);
it("renders the connection percentage", async () => {
const {element, connection, sharedProps, target} = await setup();
const connectionColumn = COLUMNS().indexOf("Connection");
expect(connectionColumn).not.toEqual(-1);
const proportion =
contribution.contributionScore /
connection.connectionScore /
NullUtil.get(sharedProps.pnd.get(target)).score;
expect(proportion).toBeGreaterThan(0.0);
expect(proportion).toBeLessThan(1.0);
@ -495,25 +491,25 @@ describe("app/credExplorer/PagerankTable", () => {
expect(
element
.find("td")
.at(contributionColumn)
.at(connectionColumn)
.text()
).toEqual(expectedText);
});
it("renders a score column with the source's log-score", async () => {
const {element, contribution} = await setup();
const expectedScore = (-Math.log(contribution.sourceScore)).toFixed(2);
const contributionColumn = COLUMNS().indexOf("Score");
expect(contributionColumn).not.toEqual(-1);
const {element, connection} = await setup();
const expectedScore = (-Math.log(connection.sourceScore)).toFixed(2);
const connectionColumn = COLUMNS().indexOf("Score");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(contributionColumn)
.at(connectionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", async () => {
const {element} = await setup();
expect(element.find("ContributionRowList")).toHaveLength(0);
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
it('has a working "expand" button', async () => {
const {element, depth, sharedProps, source} = await setup();
@ -521,7 +517,7 @@ describe("app/credExplorer/PagerankTable", () => {
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ContributionRowList");
const crl = element.find("ConnectionRowList");
expect(crl).toHaveLength(1);
expect(crl).not.toHaveLength(0);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
@ -530,75 +526,75 @@ describe("app/credExplorer/PagerankTable", () => {
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ContributionRowList")).toHaveLength(0);
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
});
describe("ContributionView", () => {
describe("ConnectionView", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const {scoredContributions} = NullUtil.get(pnd.get(nodes.bar1));
const contributions = scoredContributions.map((sc) => sc.contribution);
function contributionByType(t) {
const {scoredConnections} = NullUtil.get(pnd.get(nodes.bar1));
const connections = scoredConnections.map((sc) => sc.connection);
function connectionByType(t) {
return NullUtil.get(
contributions.filter((c) => c.contributor.type === t)[0],
`Couldn't find contribution for type ${t}`
connections.filter((c) => c.adjacency.type === t)[0],
`Couldn't find connection for type ${t}`
);
}
const inContribution = contributionByType("IN_EDGE");
const outContribution = contributionByType("OUT_EDGE");
const syntheticContribution = contributionByType("SYNTHETIC_LOOP");
function cvForContribution(contribution: Contribution) {
const inConnection = connectionByType("IN_EDGE");
const outConnection = connectionByType("OUT_EDGE");
const syntheticConnection = connectionByType("SYNTHETIC_LOOP");
function cvForConnection(connection: Connection) {
return shallow(
<ContributionView adapters={adapters} contribution={contribution} />
<ConnectionView adapters={adapters} connection={connection} />
);
}
return {
adapters,
contributions,
connections,
pnd,
cvForContribution,
inContribution,
outContribution,
syntheticContribution,
cvForConnection,
inConnection,
outConnection,
syntheticConnection,
};
}
it("always renders exactly one `Badge`", async () => {
const {
cvForContribution,
inContribution,
outContribution,
syntheticContribution,
cvForConnection,
inConnection,
outConnection,
syntheticConnection,
} = await setup();
for (const contribution of [
syntheticContribution,
inContribution,
outContribution,
for (const connection of [
syntheticConnection,
inConnection,
outConnection,
]) {
expect(cvForContribution(contribution).find("Badge")).toHaveLength(1);
expect(cvForConnection(connection).find("Badge")).toHaveLength(1);
}
});
it("for inward contributions, renders a `Badge` and description", async () => {
const {cvForContribution, inContribution} = await setup();
const view = cvForContribution(inContribution);
it("for inward connections, renders a `Badge` and description", async () => {
const {cvForConnection, inConnection} = await setup();
const view = cvForConnection(inConnection);
const outerSpan = view.find("span").first();
const badge = outerSpan.find("Badge");
const description = outerSpan.children().find("span");
expect(badge.children().text()).toEqual("is barred by");
expect(description.text()).toEqual('bar: NodeAddress["bar","a","1"]');
});
it("for outward contributions, renders a `Badge` and description", async () => {
const {cvForContribution, outContribution} = await setup();
const view = cvForContribution(outContribution);
it("for outward connections, renders a `Badge` and description", async () => {
const {cvForConnection, outConnection} = await setup();
const view = cvForConnection(outConnection);
const outerSpan = view.find("span").first();
const badge = outerSpan.find("Badge");
const description = outerSpan.children().find("span");
expect(badge.children().text()).toEqual("bars");
expect(description.text()).toEqual("xox node!");
});
it("for synthetic contributions, renders only a `Badge`", async () => {
const {cvForContribution, syntheticContribution} = await setup();
const view = cvForContribution(syntheticContribution);
it("for synthetic connections, renders only a `Badge`", async () => {
const {cvForConnection, syntheticConnection} = await setup();
const view = cvForConnection(syntheticConnection);
expect(view.find("span")).toHaveLength(0);
expect(
view

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app/credExplorer/PagerankTable ContributionRow has proper depth-based styling 1`] = `
exports[`app/credExplorer/PagerankTable ConnectionRow has proper depth-based styling 1`] = `
Object {
"buttonStyle": Object {
"marginLeft": 30,

View File

@ -1,13 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`core/attribution/contributions decompose has the expected output on a simple asymmetric chain 1`] = `
exports[`core/attribution/connections decompose has the expected output on a simple asymmetric chain 1`] = `
Map {
"NodeAddress[\\"n1\\"]" => Object {
"score": 0.19117656878499834,
"scoredContributions": Array [
"scoredConnections": Array [
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e3\\"]",
"dst": "NodeAddress[\\"sink\\"]",
@ -17,13 +17,13 @@ Map {
},
"weight": 0.1875,
},
"contributionScore": 0.1102941261444197,
"connectionScore": 0.1102941261444197,
"source": "NodeAddress[\\"sink\\"]",
"sourceScore": 0.5882353394369051,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e1\\"]",
"dst": "NodeAddress[\\"n2\\"]",
@ -33,18 +33,18 @@ Map {
},
"weight": 0.3,
},
"contributionScore": 0.066176427533429,
"connectionScore": 0.066176427533429,
"source": "NodeAddress[\\"n2\\"]",
"sourceScore": 0.22058809177809668,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"type": "SYNTHETIC_LOOP",
},
"weight": 0.07692307692307693,
},
"contributionScore": 0.014705889906538334,
"connectionScore": 0.014705889906538334,
"source": "NodeAddress[\\"n1\\"]",
"sourceScore": 0.19117656878499834,
},
@ -52,10 +52,10 @@ Map {
},
"NodeAddress[\\"n2\\"]" => Object {
"score": 0.22058809177809668,
"scoredContributions": Array [
"scoredConnections": Array [
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e2\\"]",
"dst": "NodeAddress[\\"sink\\"]",
@ -65,13 +65,13 @@ Map {
},
"weight": 0.1875,
},
"contributionScore": 0.1102941261444197,
"connectionScore": 0.1102941261444197,
"source": "NodeAddress[\\"sink\\"]",
"sourceScore": 0.5882353394369051,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e1\\"]",
"dst": "NodeAddress[\\"n2\\"]",
@ -81,18 +81,18 @@ Map {
},
"weight": 0.46153846153846156,
},
"contributionScore": 0.08823533943923001,
"connectionScore": 0.08823533943923001,
"source": "NodeAddress[\\"n1\\"]",
"sourceScore": 0.19117656878499834,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"type": "SYNTHETIC_LOOP",
},
"weight": 0.1,
},
"contributionScore": 0.02205880917780967,
"connectionScore": 0.02205880917780967,
"source": "NodeAddress[\\"n2\\"]",
"sourceScore": 0.22058809177809668,
},
@ -100,10 +100,10 @@ Map {
},
"NodeAddress[\\"sink\\"]" => Object {
"score": 0.5882353394369051,
"scoredContributions": Array [
"scoredConnections": Array [
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e4\\"]",
"dst": "NodeAddress[\\"sink\\"]",
@ -113,13 +113,13 @@ Map {
},
"weight": 0.375,
},
"contributionScore": 0.2205882522888394,
"connectionScore": 0.2205882522888394,
"source": "NodeAddress[\\"sink\\"]",
"sourceScore": 0.5882353394369051,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e2\\"]",
"dst": "NodeAddress[\\"sink\\"]",
@ -129,13 +129,13 @@ Map {
},
"weight": 0.6,
},
"contributionScore": 0.132352855066858,
"connectionScore": 0.132352855066858,
"source": "NodeAddress[\\"n2\\"]",
"sourceScore": 0.22058809177809668,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e4\\"]",
"dst": "NodeAddress[\\"sink\\"]",
@ -145,13 +145,13 @@ Map {
},
"weight": 0.1875,
},
"contributionScore": 0.1102941261444197,
"connectionScore": 0.1102941261444197,
"source": "NodeAddress[\\"sink\\"]",
"sourceScore": 0.5882353394369051,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"edge": Object {
"address": "EdgeAddress[\\"e3\\"]",
"dst": "NodeAddress[\\"sink\\"]",
@ -161,18 +161,18 @@ Map {
},
"weight": 0.46153846153846156,
},
"contributionScore": 0.08823533943923001,
"connectionScore": 0.08823533943923001,
"source": "NodeAddress[\\"n1\\"]",
"sourceScore": 0.19117656878499834,
},
Object {
"contribution": Object {
"contributor": Object {
"connection": Object {
"adjacency": Object {
"type": "SYNTHETIC_LOOP",
},
"weight": 0.0625,
},
"contributionScore": 0.03676470871480657,
"connectionScore": 0.03676470871480657,
"source": "NodeAddress[\\"sink\\"]",
"sourceScore": 0.5882353394369051,
},

View File

@ -6,40 +6,34 @@ import * as MapUtil from "../../util/map";
import * as NullUtil from "../../util/null";
export type Probability = number;
export type Contributor =
export type Adjacency =
| {|+type: "SYNTHETIC_LOOP"|}
| {|+type: "IN_EDGE", +edge: Edge|}
| {|+type: "OUT_EDGE", +edge: Edge|};
export type Contribution = {|
+contributor: Contributor,
export type Connection = {|
+adjacency: Adjacency,
// This `weight` is a conditional probability: given that you're at
// the source of this contribution's contributor, what's the
// probability that you travel along this contribution to the target?
// the source of this connection's adjacency, what's the
// probability that you travel along this connection to the target?
+weight: Probability,
|};
export function contributorSource(
target: NodeAddressT,
contributor: Contributor
) {
switch (contributor.type) {
export function adjacencySource(target: NodeAddressT, adjacency: Adjacency) {
switch (adjacency.type) {
case "SYNTHETIC_LOOP":
return target;
case "IN_EDGE":
return contributor.edge.src;
return adjacency.edge.src;
case "OUT_EDGE":
return contributor.edge.dst;
return adjacency.edge.dst;
default:
throw new Error((contributor.type: empty));
throw new Error((adjacency.type: empty));
}
}
export type NodeDistribution = Map<NodeAddressT, Probability>;
export type NodeToContributions = Map<
NodeAddressT,
$ReadOnlyArray<Contribution>
>;
export type NodeToConnections = Map<NodeAddressT, $ReadOnlyArray<Connection>>;
type NodeAddressMarkovChain = Map<
NodeAddressT,
@ -56,11 +50,11 @@ export type EdgeWeight = {|
+froWeight: number, // weight from dst to src
|};
export function createContributions(
export function createConnections(
graph: Graph,
edgeWeight: (Edge) => EdgeWeight,
syntheticLoopWeight: number
): NodeToContributions {
): NodeToConnections {
const result = new Map();
const totalOutWeight: Map<NodeAddressT, number> = new Map();
for (const node of graph.nodes()) {
@ -68,23 +62,20 @@ export function createContributions(
totalOutWeight.set(node, 0);
}
function processContribution(
target: NodeAddressT,
contribution: Contribution
) {
const contributions = NullUtil.get(result.get(target));
(((contributions: $ReadOnlyArray<Contribution>): any): Contribution[]).push(
contribution
function processConnection(target: NodeAddressT, connection: Connection) {
const connections = NullUtil.get(result.get(target));
(((connections: $ReadOnlyArray<Connection>): any): Connection[]).push(
connection
);
const source = contributorSource(target, contribution.contributor);
const source = adjacencySource(target, connection.adjacency);
const priorOutWeight = NullUtil.get(totalOutWeight.get(source));
totalOutWeight.set(source, priorOutWeight + contribution.weight);
totalOutWeight.set(source, priorOutWeight + connection.weight);
}
// Add self-loops.
for (const node of graph.nodes()) {
processContribution(node, {
contributor: {type: "SYNTHETIC_LOOP"},
processConnection(node, {
adjacency: {type: "SYNTHETIC_LOOP"},
weight: syntheticLoopWeight,
});
}
@ -93,25 +84,25 @@ export function createContributions(
for (const edge of graph.edges()) {
const {toWeight, froWeight} = edgeWeight(edge);
const {src, dst} = edge;
processContribution(dst, {
contributor: {type: "IN_EDGE", edge},
processConnection(dst, {
adjacency: {type: "IN_EDGE", edge},
weight: toWeight,
});
processContribution(src, {
contributor: {type: "OUT_EDGE", edge},
processConnection(src, {
adjacency: {type: "OUT_EDGE", edge},
weight: froWeight,
});
}
// Normalize in-weights.
for (const [target, contributions] of result.entries()) {
for (const contribution of contributions) {
const source = contributorSource(target, contribution.contributor);
for (const [target, connections] of result.entries()) {
for (const connection of connections) {
const source = adjacencySource(target, connection.adjacency);
const normalization = NullUtil.get(totalOutWeight.get(source));
const newWeight: typeof contribution.weight =
contribution.weight / normalization;
const newWeight: typeof connection.weight =
connection.weight / normalization;
// (any-cast because property is not writable)
(contribution: any).weight = newWeight;
(connection: any).weight = newWeight;
}
}
@ -119,15 +110,15 @@ export function createContributions(
}
function createNodeAddressMarkovChain(
ntc: NodeToContributions
ntc: NodeToConnections
): NodeAddressMarkovChain {
return MapUtil.mapValues(ntc, (target, contributions) => {
return MapUtil.mapValues(ntc, (target, connections) => {
const inNeighbors = new Map();
for (const contribution of contributions) {
const source = contributorSource(target, contribution.contributor);
for (const connection of connections) {
const source = adjacencySource(target, connection.adjacency);
inNeighbors.set(
source,
contribution.weight + NullUtil.orElse(inNeighbors.get(source), 0)
connection.weight + NullUtil.orElse(inNeighbors.get(source), 0)
);
}
return inNeighbors;
@ -163,9 +154,9 @@ function nodeAddressMarkovChainToOrderedSparseMarkovChain(
}
export function createOrderedSparseMarkovChain(
contributions: NodeToContributions
connections: NodeToConnections
): OrderedSparseMarkovChain {
const chain = createNodeAddressMarkovChain(contributions);
const chain = createNodeAddressMarkovChain(connections);
return nodeAddressMarkovChainToOrderedSparseMarkovChain(chain);
}

View File

@ -5,7 +5,7 @@ import sortBy from "lodash.sortby";
import {EdgeAddress, Graph, NodeAddress} from "../graph";
import {
distributionToNodeDistribution,
createContributions,
createConnections,
createOrderedSparseMarkovChain,
normalize,
normalizeNeighbors,
@ -81,9 +81,9 @@ describe("core/attribution/graphToMarkovChain", () => {
expect(actual).toEqual(expected);
});
describe("createContributions", () => {
describe("createConnections", () => {
// The tests for `createOrderedSparseMarkovChain` also must invoke
// `createContributions`, so we add only light testing separately.
// `createConnections`, so we add only light testing separately.
it("works on a simple asymmetric chain", () => {
const n1 = NodeAddress.fromParts(["n1"]);
const n2 = NodeAddress.fromParts(["n2"]);
@ -101,28 +101,28 @@ describe("core/attribution/graphToMarkovChain", () => {
.addEdge(e3)
.addEdge(e4);
const edgeWeight = () => ({toWeight: 6.0, froWeight: 3.0});
const actual = createContributions(g, edgeWeight, 1.0);
const actual = createConnections(g, edgeWeight, 1.0);
// Total out-weights (for normalization factors):
// - for `n1`: 2 out, 0 in, 1 synthetic: 12 + 0 + 1 = 13
// - for `n2`: 1 out, 1 in, 1 synthetic: 6 + 3 + 1 = 10
// - for `n3`: 1 out, 3 in, 1 synthetic: 6 + 9 + 1 = 16
const expected = new Map()
.set(n1, [
{contributor: {type: "SYNTHETIC_LOOP"}, weight: 1 / 13},
{contributor: {type: "OUT_EDGE", edge: e1}, weight: 3 / 10},
{contributor: {type: "OUT_EDGE", edge: e3}, weight: 3 / 16},
{adjacency: {type: "SYNTHETIC_LOOP"}, weight: 1 / 13},
{adjacency: {type: "OUT_EDGE", edge: e1}, weight: 3 / 10},
{adjacency: {type: "OUT_EDGE", edge: e3}, weight: 3 / 16},
])
.set(n2, [
{contributor: {type: "SYNTHETIC_LOOP"}, weight: 1 / 10},
{contributor: {type: "IN_EDGE", edge: e1}, weight: 6 / 13},
{contributor: {type: "OUT_EDGE", edge: e2}, weight: 3 / 16},
{adjacency: {type: "SYNTHETIC_LOOP"}, weight: 1 / 10},
{adjacency: {type: "IN_EDGE", edge: e1}, weight: 6 / 13},
{adjacency: {type: "OUT_EDGE", edge: e2}, weight: 3 / 16},
])
.set(n3, [
{contributor: {type: "SYNTHETIC_LOOP"}, weight: 1 / 16},
{contributor: {type: "IN_EDGE", edge: e2}, weight: 6 / 10},
{contributor: {type: "IN_EDGE", edge: e3}, weight: 6 / 13},
{contributor: {type: "IN_EDGE", edge: e4}, weight: 6 / 16},
{contributor: {type: "OUT_EDGE", edge: e4}, weight: 3 / 16},
{adjacency: {type: "SYNTHETIC_LOOP"}, weight: 1 / 16},
{adjacency: {type: "IN_EDGE", edge: e2}, weight: 6 / 10},
{adjacency: {type: "IN_EDGE", edge: e3}, weight: 6 / 13},
{adjacency: {type: "IN_EDGE", edge: e4}, weight: 6 / 16},
{adjacency: {type: "OUT_EDGE", edge: e4}, weight: 3 / 16},
]);
const canonicalize = (map) =>
MapUtil.mapValues(map, (_, v) => sortBy(v, (x) => JSON.stringify(x)));
@ -138,7 +138,7 @@ describe("core/attribution/graphToMarkovChain", () => {
throw new Error("Don't even look at me");
};
const osmc = createOrderedSparseMarkovChain(
createContributions(g, edgeWeight, 1e-3)
createConnections(g, edgeWeight, 1e-3)
);
const expected = {
nodeOrder: [n],
@ -167,7 +167,7 @@ describe("core/attribution/graphToMarkovChain", () => {
.addEdge(e4);
const edgeWeight = () => ({toWeight: 1, froWeight: 0});
const osmc = createOrderedSparseMarkovChain(
createContributions(g, edgeWeight, 0.0)
createConnections(g, edgeWeight, 0.0)
);
const expected = {
nodeOrder: [n1, n2, n3],
@ -205,7 +205,7 @@ describe("core/attribution/graphToMarkovChain", () => {
.addEdge(e3);
const edgeWeight = () => ({toWeight: 1, froWeight: 1});
const osmc = createOrderedSparseMarkovChain(
createContributions(g, edgeWeight, 0.0)
createConnections(g, edgeWeight, 0.0)
);
const expected = {
nodeOrder: [n1, n2, n3],
@ -237,7 +237,7 @@ describe("core/attribution/graphToMarkovChain", () => {
return {toWeight: 4 - epsilon / 2, froWeight: 1 - epsilon / 2};
}
const osmc = createOrderedSparseMarkovChain(
createContributions(g, edgeWeight, epsilon)
createConnections(g, edgeWeight, epsilon)
);
// Edges from `src`:
// - to `src` with weight `epsilon`

View File

@ -3,7 +3,7 @@
import {type Edge, Graph} from "../graph";
import {
distributionToNodeDistribution,
createContributions,
createConnections,
createOrderedSparseMarkovChain,
type EdgeWeight,
} from "./graphToMarkovChain";
@ -44,12 +44,12 @@ export async function pagerank(
...defaultOptions(),
...(options || {}),
};
const contributions = createContributions(
const connections = createConnections(
graph,
edgeWeight,
fullOptions.selfLoopWeight
);
const osmc = createOrderedSparseMarkovChain(contributions);
const osmc = createOrderedSparseMarkovChain(connections);
const distribution = await findStationaryDistribution(osmc.chain, {
verbose: fullOptions.verbose,
convergenceThreshold: fullOptions.convergenceThreshold,
@ -57,5 +57,5 @@ export async function pagerank(
yieldAfterMs: 30,
});
const pi = distributionToNodeDistribution(osmc.nodeOrder, distribution);
return decompose(pi, contributions);
return decompose(pi, connections);
}

View File

@ -4,50 +4,50 @@ import sortBy from "lodash.sortby";
import type {NodeAddressT} from "../graph";
import {
type Contribution,
type NodeToContributions,
contributorSource,
type Connection,
type NodeToConnections,
adjacencySource,
} from "./graphToMarkovChain";
import type {NodeDistribution} from "./pagerank";
import * as MapUtil from "../../util/map";
import * as NullUtil from "../../util/null";
export type ScoredContribution = {|
+contribution: Contribution,
export type ScoredConnection = {|
+connection: Connection,
+source: NodeAddressT,
+sourceScore: number,
+contributionScore: number,
+connectionScore: number,
|};
export type PagerankNodeDecomposition = Map<
NodeAddressT,
{|
+score: number,
// Contributions are sorted by `contributorScore` descending,
// Connections are sorted by `adjacencyScore` descending,
// breaking ties in a deterministic (but unspecified) order.
+scoredContributions: $ReadOnlyArray<ScoredContribution>,
+scoredConnections: $ReadOnlyArray<ScoredConnection>,
|}
>;
export function decompose(
pr: NodeDistribution,
contributions: NodeToContributions
connections: NodeToConnections
): PagerankNodeDecomposition {
return MapUtil.mapValues(contributions, (target, contributions) => {
return MapUtil.mapValues(connections, (target, connections) => {
const score = NullUtil.get(pr.get(target));
const scoredContributions = sortBy(
contributions.map(
(contribution): ScoredContribution => {
const source = contributorSource(target, contribution.contributor);
const scoredConnections = sortBy(
connections.map(
(connection): ScoredConnection => {
const source = adjacencySource(target, connection.adjacency);
const sourceScore = NullUtil.get(pr.get(source));
const contributionScore = contribution.weight * sourceScore;
return {contribution, source, sourceScore, contributionScore};
const connectionScore = connection.weight * sourceScore;
return {connection, source, sourceScore, connectionScore};
}
),
(x) => -x.contributionScore,
(x) => -x.connectionScore,
// The following should be called rarely and on small objects.
(x) => JSON.stringify(x.contribution.contributor)
(x) => JSON.stringify(x.connection.adjacency)
);
return {score, scoredContributions};
return {score, scoredConnections};
});
}

View File

@ -3,7 +3,7 @@
import {EdgeAddress, Graph, NodeAddress, edgeToStrings} from "../graph";
import {
distributionToNodeDistribution,
createContributions,
createConnections,
createOrderedSparseMarkovChain,
} from "./graphToMarkovChain";
import {findStationaryDistribution} from "./markovChain";
@ -17,57 +17,57 @@ import {advancedGraph} from "../graphTestUtil";
* addresses and edges to strings to avoid NUL characters.
*/
function formatDecomposition(d) {
return MapUtil.mapEntries(d, (key, {score, scoredContributions}) => [
return MapUtil.mapEntries(d, (key, {score, scoredConnections}) => [
NodeAddress.toString(key),
{
score,
scoredContributions: scoredContributions.map(
({contribution, source, sourceScore, contributionScore}) => ({
contribution: {
contributor: formatContributor(contribution.contributor),
weight: contribution.weight,
scoredConnections: scoredConnections.map(
({connection, source, sourceScore, connectionScore}) => ({
connection: {
adjacency: formatAdjacency(connection.adjacency),
weight: connection.weight,
},
source: NodeAddress.toString(source),
sourceScore,
contributionScore,
connectionScore,
})
),
},
]);
function formatContributor(contributor) {
switch (contributor.type) {
function formatAdjacency(adjacency) {
switch (adjacency.type) {
case "SYNTHETIC_LOOP":
return {type: "SYNTHETIC_LOOP"};
case "IN_EDGE":
return {type: "IN_EDGE", edge: edgeToStrings(contributor.edge)};
return {type: "IN_EDGE", edge: edgeToStrings(adjacency.edge)};
case "OUT_EDGE":
return {type: "OUT_EDGE", edge: edgeToStrings(contributor.edge)};
return {type: "OUT_EDGE", edge: edgeToStrings(adjacency.edge)};
default:
throw new Error((contributor.type: empty));
throw new Error((adjacency.type: empty));
}
}
}
/**
* Perform basic sanity checks on a decomposition. This ensures that
* every node's score is the sum of its contributions' scores, that the
* every node's score is the sum of its connections' scores, that the
* scores of the decomposition sum to 1, and that each node's
* contributions are listed in non-increasing order of score.
* connections are listed in non-increasing order of score.
*/
function validateDecomposition(decomposition) {
const epsilon = 1e-6;
// Check that each node's score is the sum of its subscores.
for (const [key, {score, scoredContributions}] of decomposition.entries()) {
const totalSubscore = scoredContributions
.map((sc) => sc.contributionScore)
for (const [key, {score, scoredConnections}] of decomposition.entries()) {
const totalSubscore = scoredConnections
.map((sc) => sc.connectionScore)
.reduce((a, b) => a + b, 0);
const delta = totalSubscore - score;
if (Math.abs(delta) > epsilon) {
const message = [
`for node ${NodeAddress.toString(key)}: `,
`expected total score (${score}) to equal `,
`sum of contribution scores (${totalSubscore}) `,
`sum of connection scores (${totalSubscore}) `,
`within ${epsilon}, but the difference is ${delta}`,
].join("");
throw new Error(message);
@ -89,18 +89,18 @@ function validateDecomposition(decomposition) {
}
}
// Check that each node's contributions are in score-descending order.
for (const {scoredContributions} of decomposition.values()) {
scoredContributions.forEach((current, index) => {
// Check that each node's connections are in score-descending order.
for (const {scoredConnections} of decomposition.values()) {
scoredConnections.forEach((current, index) => {
if (index === 0) {
return;
}
const previous = scoredContributions[index - 1];
if (current.contributionScore > previous.contributionScore) {
const previous = scoredConnections[index - 1];
if (current.connectionScore > previous.connectionScore) {
const message = [
`expected contribution score to be non-increasing, but `,
`element at index ${index} has score ${current.contributionScore}, `,
`higher than that of its predecessor (${previous.contributionScore})`,
`expected connection score to be non-increasing, but `,
`element at index ${index} has score ${current.connectionScore}, `,
`higher than that of its predecessor (${previous.connectionScore})`,
].join("");
throw new Error(message);
}
@ -108,7 +108,7 @@ function validateDecomposition(decomposition) {
}
}
describe("core/attribution/contributions", () => {
describe("core/attribution/connections", () => {
describe("decompose", () => {
it("has the expected output on a simple asymmetric chain", async () => {
const n1 = NodeAddress.fromParts(["n1"]);
@ -127,8 +127,8 @@ describe("core/attribution/contributions", () => {
.addEdge(e3)
.addEdge(e4);
const edgeWeight = () => ({toWeight: 6.0, froWeight: 3.0});
const contributions = createContributions(g, edgeWeight, 1.0);
const osmc = createOrderedSparseMarkovChain(contributions);
const connections = createConnections(g, edgeWeight, 1.0);
const osmc = createOrderedSparseMarkovChain(connections);
const pi = await findStationaryDistribution(osmc.chain, {
verbose: false,
convergenceThreshold: 1e-6,
@ -136,7 +136,7 @@ describe("core/attribution/contributions", () => {
yieldAfterMs: 1,
});
const pr = distributionToNodeDistribution(osmc.nodeOrder, pi);
const result = decompose(pr, contributions);
const result = decompose(pr, connections);
expect(formatDecomposition(result)).toMatchSnapshot();
validateDecomposition(result);
});
@ -144,8 +144,8 @@ describe("core/attribution/contributions", () => {
it("is valid on the example graph", async () => {
const g = advancedGraph().graph1();
const edgeWeight = () => ({toWeight: 6.0, froWeight: 3.0});
const contributions = createContributions(g, edgeWeight, 1.0);
const osmc = createOrderedSparseMarkovChain(contributions);
const connections = createConnections(g, edgeWeight, 1.0);
const osmc = createOrderedSparseMarkovChain(connections);
const pi = await findStationaryDistribution(osmc.chain, {
verbose: false,
convergenceThreshold: 1e-6,
@ -153,7 +153,7 @@ describe("core/attribution/contributions", () => {
yieldAfterMs: 1,
});
const pr = distributionToNodeDistribution(osmc.nodeOrder, pi);
const result = decompose(pr, contributions);
const result = decompose(pr, connections);
validateDecomposition(result);
});
});